Преглед на файлове

fix: preserve EventPrompt ordering

Problem: regenerating EventPrompt after mode or tool changes moved it to the end of the sortable prompt list.

Risk: insertion tracking must ignore duplicate generated prompts while preserving custom prompt order.
zhenyu.hu преди 2 седмици
родител
ревизия
c4c02c0759
променени са 2 файла, в които са добавени 252 реда и са изтрити 15 реда
  1. 55 11
      src/agent_lab/presentation/static/app.js
  2. 197 4
      tests/test_websocket_api.py

+ 55 - 11
src/agent_lab/presentation/static/app.js

@@ -44,6 +44,7 @@ let auditEntries = [];
 let selectedAuditKey = null;
 let liveAuditSequence = 0;
 let turnActive = false;
+let eventPromptInsertionIndex = 0;
 
 bindClick("#add-system-prompt", () => {
   systemPrompts.append(createSystemPrompt({ content: "" }));
@@ -446,6 +447,10 @@ function refreshToolModeState() {
     .forEach((control) => {
       control.disabled = isChatAgentTools;
     });
+  const capturedEventPromptIndex = captureEventPromptInsertionIndex();
+  if (capturedEventPromptIndex !== null) {
+    eventPromptInsertionIndex = capturedEventPromptIndex;
+  }
   removeGeneratedEventPromptItems();
   if (!isChatAgentTools) {
     updateEventPromptItem(availableTools);
@@ -488,6 +493,10 @@ function restoreExtraBody(prefix, extraBody = {}) {
 }
 
 function restoreSystemPrompts(prompts) {
+  const restoredEventPromptIndex = deriveEventPromptInsertionIndex(prompts);
+  if (restoredEventPromptIndex !== null) {
+    eventPromptInsertionIndex = restoredEventPromptIndex;
+  }
   systemPrompts.textContent = "";
   const promptItems = normalizePromptItems(prompts);
   promptItems.forEach((prompt) => {
@@ -628,11 +637,7 @@ function normalizePromptItems(prompts) {
   const items = Array.isArray(prompts) ? prompts : [];
   const normalized = items
     .map((prompt) => normalizePromptItem(prompt))
-    .filter((prompt) => (
-      prompt.prompt_kind !== "event_agent_system"
-      && prompt.content
-      && !isGeneratedEventPromptContent(prompt.content)
-    ));
+    .filter((prompt) => isRetainedCustomPrompt(prompt));
   if (!normalized.some((prompt) => prompt.prompt_kind === "custom")) {
     normalized.unshift({ prompt_kind: "custom", role: "system", content: "" });
   }
@@ -656,6 +661,30 @@ function normalizePromptItem(prompt) {
   };
 }
 
+function deriveEventPromptInsertionIndex(prompts) {
+  const items = Array.isArray(prompts) ? prompts : [];
+  let retainedPromptCount = 0;
+  for (const prompt of items) {
+    const normalized = normalizePromptItem(prompt);
+    if (
+      normalized.prompt_kind === "event_agent_system"
+      || isGeneratedEventPromptContent(normalized.content)
+    ) {
+      return retainedPromptCount;
+    }
+    if (isRetainedCustomPrompt(normalized)) {
+      retainedPromptCount += 1;
+    }
+  }
+  return null;
+}
+
+function isRetainedCustomPrompt(prompt) {
+  return prompt.prompt_kind !== "event_agent_system"
+    && Boolean(prompt.content)
+    && !isGeneratedEventPromptContent(prompt.content);
+}
+
 function isGeneratedEventPromptContent(content) {
   const normalized = typeof content === "string" ? content.trim() : "";
   return normalized.startsWith(
@@ -694,13 +723,26 @@ function updateEventPromptItem(tools) {
   eventPrompt.querySelector(".system-prompt").value = prompt;
 }
 
+function isGeneratedEventPromptItem(item) {
+  const content = item.querySelector(".system-prompt").value;
+  return item.dataset.promptKind === "event_agent_system"
+    || isGeneratedEventPromptContent(content);
+}
+
+function captureEventPromptInsertionIndex() {
+  let retainedPromptCount = 0;
+  for (const item of systemPrompts.querySelectorAll(".prompt-item")) {
+    if (isGeneratedEventPromptItem(item)) {
+      return retainedPromptCount;
+    }
+    retainedPromptCount += 1;
+  }
+  return null;
+}
+
 function removeGeneratedEventPromptItems() {
   systemPrompts.querySelectorAll(".prompt-item").forEach((item) => {
-    const content = item.querySelector(".system-prompt").value;
-    if (
-      item.dataset.promptKind === "event_agent_system"
-      || isGeneratedEventPromptContent(content)
-    ) {
+    if (isGeneratedEventPromptItem(item)) {
       item.remove();
     }
   });
@@ -716,7 +758,9 @@ function ensureEventPromptItem() {
     prompt_kind: "event_agent_system",
     content: "",
   });
-  systemPrompts.append(item);
+  const promptItems = [...systemPrompts.querySelectorAll(".prompt-item")];
+  const insertionTarget = promptItems[eventPromptInsertionIndex] || null;
+  systemPrompts.insertBefore(item, insertionTarget);
   return item;
 }
 

+ 197 - 4
tests/test_websocket_api.py

@@ -1428,23 +1428,216 @@ def test_static_event_prompt_refresh_uses_unified_tool_mode_state_path():
         'toolInvocationMode.addEventListener("change", refreshToolModeState);'
         in js
     )
-    assert render_tools.count("refreshToolModeState();") == 3
+    assert "refreshToolModeState();" in render_tools
     assert "updateEventPromptItem(" not in render_tools
     assert "refreshToolModeState();" in set_all_tools
     assert "updateEventPromptItem(" not in set_all_tools
     assert "refreshToolModeState();" in restore_snapshot
     assert "refreshToolModeState();" in apply_selected_tools
     assert "refreshToolModeState();" in initialize_prompt_list
-    assert js.count("updateEventPromptItem(") == 2
     assert "removeGeneratedEventPromptItems();" in refresh_tool_mode
     assert "if (!isChatAgentTools)" in refresh_tool_mode
     assert "updateEventPromptItem(availableTools);" in refresh_tool_mode
     assert 'querySelectorAll(".prompt-item")' in remove_generated_prompts
-    assert 'item.dataset.promptKind === "event_agent_system"' in remove_generated_prompts
-    assert "isGeneratedEventPromptContent(content)" in remove_generated_prompts
+    assert "isGeneratedEventPromptItem(item)" in remove_generated_prompts
     assert "item.remove();" in remove_generated_prompts
     assert 'textarea.readOnly = promptItem.prompt_kind === "event_agent_system"' in js
 
+    assert "let eventPromptInsertionIndex" in js
+    capture_position = js[
+        js.index("function captureEventPromptInsertionIndex(") :
+        js.index("function removeGeneratedEventPromptItems(")
+    ]
+    ensure_event_prompt = js[
+        js.index("function ensureEventPromptItem(") :
+        js.index("function bindPromptItemControls(")
+    ]
+    restore_prompts = js[
+        js.index("function restoreSystemPrompts(") :
+        js.index("function restorePreMessages(")
+    ]
+
+    assert "return retainedPromptCount;" in capture_position
+    assert "captureEventPromptInsertionIndex();" in refresh_tool_mode
+    assert "capturedEventPromptIndex !== null" in refresh_tool_mode
+    assert "eventPromptInsertionIndex = capturedEventPromptIndex;" in refresh_tool_mode
+    assert "deriveEventPromptInsertionIndex(prompts);" in restore_prompts
+    assert "eventPromptInsertionIndex = restoredEventPromptIndex;" in restore_prompts
+    assert "const insertionTarget" in ensure_event_prompt
+    assert "systemPrompts.insertBefore(item, insertionTarget);" in ensure_event_prompt
+    assert "systemPrompts.append(item);" not in ensure_event_prompt
+
+
+def test_static_event_prompt_order_survives_refresh_and_tool_mode_roundtrip():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    generated = "\n".join(
+        [
+            "You may request EventAgent work using this text protocol.",
+            "Available events:",
+            "- handoff_note: Handoff a note",
+            "<agent_events>",
+            "handoff_note",
+            "</agent_events>",
+            "Use exact event names only, one per line. Do not include parameters.",
+        ]
+    )
+
+    assert "function captureEventPromptInsertionIndex(" in js
+    refresh_tool_mode = js[
+        js.index("function refreshToolModeState(") :
+        js.index("function updateToolModeHelp(")
+    ]
+    generated_matcher = js[
+        js.index("function isGeneratedEventPromptContent(") :
+        js.index("function buildAvailableEventsPrompt(")
+    ]
+    event_prompt_functions = js[
+        js.index("function updateEventPromptItem(") :
+        js.index("function bindPromptItemControls(")
+    ]
+    result = _run_node_json(
+        "\n".join(
+            [
+                refresh_tool_mode,
+                generated_matcher,
+                event_prompt_functions,
+                """
+const generated = [
+  "You may request EventAgent work using this text protocol.",
+  "Available events:",
+  "- handoff_note: Handoff a note",
+  "<agent_events>",
+  "handoff_note",
+  "</agent_events>",
+  "Use exact event names only, one per line. Do not include parameters.",
+].join("\\n");
+const items = [];
+function makePromptItem(promptKind, content) {
+  const item = {
+    dataset: { promptKind, promptRole: "system" },
+    content,
+    querySelector() {
+      return {
+        get value() { return item.content; },
+        set value(value) { item.content = value; },
+      };
+    },
+    remove() {
+      const index = items.indexOf(item);
+      if (index >= 0) items.splice(index, 1);
+    },
+  };
+  return item;
+}
+items.push(
+  makePromptItem("custom", "alpha"),
+  makePromptItem("event_agent_system", "stale generated prompt"),
+  makePromptItem("custom", "beta"),
+  makePromptItem("custom", generated),
+  makePromptItem("custom", "gamma"),
+);
+const systemPrompts = {
+  querySelectorAll() { return [...items]; },
+  querySelector() {
+    return items.find((item) => item.dataset.promptKind === "event_agent_system") || null;
+  },
+  insertBefore(item, target) {
+    const currentIndex = items.indexOf(item);
+    if (currentIndex >= 0) items.splice(currentIndex, 1);
+    const targetIndex = target ? items.indexOf(target) : items.length;
+    items.splice(targetIndex < 0 ? items.length : targetIndex, 0, item);
+  },
+};
+const toolInvocationMode = { value: "dual_agent" };
+const eventAgentModelSettings = { querySelectorAll() { return []; } };
+const availableTools = [{ name: "handoff_note" }];
+let eventPromptInsertionIndex = 0;
+function updateToolModeHelp() {}
+function selectedTools() { return ["handoff_note"]; }
+function buildAvailableEventsPrompt() { return generated; }
+function createSystemPrompt() {
+  return makePromptItem("event_agent_system", "");
+}
+function snapshot() {
+  return items.map((item) => [item.dataset.promptKind, item.content]);
+}
+
+refreshToolModeState();
+const afterRefresh = snapshot();
+toolInvocationMode.value = "chat_agent_tools";
+refreshToolModeState();
+refreshToolModeState();
+const whileHidden = snapshot();
+const hiddenIndex = eventPromptInsertionIndex;
+toolInvocationMode.value = "dual_agent";
+refreshToolModeState();
+const afterRoundtrip = snapshot();
+console.log(JSON.stringify({ afterRefresh, whileHidden, hiddenIndex, afterRoundtrip }));
+""",
+            ]
+        )
+    )
+
+    expected_visible = [
+        ["custom", "alpha"],
+        ["event_agent_system", generated],
+        ["custom", "beta"],
+        ["custom", "gamma"],
+    ]
+    assert result["afterRefresh"] == expected_visible
+    assert result["whileHidden"] == [
+        ["custom", "alpha"],
+        ["custom", "beta"],
+        ["custom", "gamma"],
+    ]
+    assert result["hiddenIndex"] == 1
+    assert result["afterRoundtrip"] == expected_visible
+
+
+def test_static_prompt_restore_derives_first_generated_event_prompt_position():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+
+    assert "function deriveEventPromptInsertionIndex(" in js
+    normalization = js[
+        js.index("function normalizePromptItems(") :
+        js.index("function buildAvailableEventsPrompt(")
+    ]
+    result = _run_node_json(
+        "\n".join(
+            [
+                normalization,
+                """
+const generated = [
+  "You may request EventAgent work using this text protocol.",
+  "Available events:",
+  "<agent_events>",
+  "handoff_note",
+  "</agent_events>",
+  "Use exact event names only, one per line. Do not include parameters.",
+].join("\\n");
+const prompts = [
+  { prompt_kind: "custom", content: "alpha" },
+  generated,
+  { prompt_kind: "custom", content: "beta" },
+  { prompt_kind: "event_agent_system", content: generated },
+  { prompt_kind: "custom", content: "gamma" },
+];
+console.log(JSON.stringify({
+  insertionIndex: deriveEventPromptInsertionIndex(prompts),
+  normalized: normalizePromptItems(prompts),
+}));
+""",
+            ]
+        )
+    )
+
+    assert result["insertionIndex"] == 1
+    assert result["normalized"] == [
+        {"prompt_kind": "custom", "role": "system", "content": "alpha"},
+        {"prompt_kind": "custom", "role": "system", "content": "beta"},
+        {"prompt_kind": "custom", "role": "system", "content": "gamma"},
+    ]
+
 
 def test_static_prompt_restore_discards_generated_event_prompt_derived_state():
     js = Path("src/agent_lab/presentation/static/app.js").read_text()