Prechádzať zdrojové kódy

feat: restore tool mode sessions

Problem:
- Persisted sessions lost assistant tool calls and direct tool replies, while the UI did not restore tool mode or batch configuration.

Risk:
- SQLite migration and restored provider transcripts affect existing session startup; migration, reconnect, static, focused, and full-suite tests cover the compatibility boundary.
zhenyu.hu 2 týždňov pred
rodič
commit
a2a2a434db

+ 29 - 1
src/agent_lab/application/runtime.py

@@ -464,6 +464,7 @@ class DebugRuntime:
     ) -> None:
         session_id = self._ensure_persisted_session(request)
         messages = self._build_initial_messages(request)
+        messages.extend(self._restored_session_messages(session_id))
         initial_turn_started_at = self.clock()
         session_message = {"type": "session_started"}
         if session_id is not None:
@@ -701,7 +702,9 @@ class DebugRuntime:
             if events and request.tool_invocation_mode == "chat_agent_tools":
                 self._validate_provider_tool_call_ids(messages, events)
 
-            if assistant_content or events:
+            if assistant_content or (
+                events and request.tool_invocation_mode == "chat_agent_tools"
+            ):
                 assistant_message = ChatMessage(
                     role="assistant",
                     content="".join(assistant_content),
@@ -748,6 +751,12 @@ class DebugRuntime:
                         scope=batch_scope,
                     )
                     messages.extend(tool_replies)
+                    for reply in tool_replies:
+                        self._append_persisted_message(
+                            session_id,
+                            turn_index,
+                            reply,
+                        )
                 else:
                     response = asyncio.get_running_loop().create_future()
                     await queues.events.put(
@@ -1263,6 +1272,25 @@ class DebugRuntime:
             return 1
         return self.session_store.next_turn_index(session_id)
 
+    def _restored_session_messages(
+        self,
+        session_id: str | None,
+    ) -> list[ChatMessage]:
+        if self.session_store is None or session_id is None:
+            return []
+        return [
+            ChatMessage.model_validate(
+                {
+                    "role": message["role"],
+                    "content": message["content"],
+                    "name": message.get("name"),
+                    "tool_call_id": message.get("tool_call_id"),
+                    "tool_calls": message.get("tool_calls", []),
+                }
+            )
+            for message in self.session_store.list_messages(session_id)
+        ]
+
     def _start_persisted_turn(
         self,
         session_id: str | None,

+ 30 - 3
src/agent_lab/infrastructure/sqlite_store.py

@@ -173,8 +173,11 @@ class SQLiteSessionStore:
             self._connect().execute(
                 """
                 INSERT INTO messages
-                    (session_id, turn_index, role, content, name, tool_call_id, created_at)
-                VALUES (?, ?, ?, ?, ?, ?, ?)
+                    (
+                        session_id, turn_index, role, content, name,
+                        tool_call_id, tool_calls_json, created_at
+                    )
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                 """,
                 (
                     session_id,
@@ -183,6 +186,11 @@ class SQLiteSessionStore:
                     message.content,
                     message.name,
                     message.tool_call_id,
+                    json.dumps(
+                        [tool_call.model_dump() for tool_call in message.tool_calls],
+                        ensure_ascii=False,
+                        separators=(",", ":"),
+                    ),
                     now,
                 ),
             )
@@ -259,7 +267,9 @@ class SQLiteSessionStore:
     def list_messages(self, session_id: str) -> list[dict[str, Any]]:
         rows = self._fetchall(
             """
-            SELECT id, turn_index, role, content, name, tool_call_id, created_at
+            SELECT
+                id, turn_index, role, content, name, tool_call_id,
+                tool_calls_json, created_at
             FROM messages
             WHERE session_id = ?
             ORDER BY id ASC
@@ -274,6 +284,7 @@ class SQLiteSessionStore:
                 "content": row["content"],
                 "name": row["name"],
                 "tool_call_id": row["tool_call_id"],
+                "tool_calls": self._load_list(row["tool_calls_json"]),
                 "created_at": row["created_at"],
             }
             for row in rows
@@ -437,6 +448,7 @@ class SQLiteSessionStore:
                 content TEXT NOT NULL,
                 name TEXT,
                 tool_call_id TEXT,
+                tool_calls_json TEXT NOT NULL DEFAULT '[]',
                 created_at TEXT NOT NULL
             );
 
@@ -465,6 +477,17 @@ class SQLiteSessionStore:
             );
             """
         )
+        message_columns = {
+            row[1]
+            for row in self._connection.execute("PRAGMA table_info(messages)")
+        }
+        if "tool_calls_json" not in message_columns:
+            self._connection.execute(
+                """
+                ALTER TABLE messages
+                ADD COLUMN tool_calls_json TEXT NOT NULL DEFAULT '[]'
+                """
+            )
         self._connection.commit()
 
     def _touch_session_locked(self, session_id: str, now: str) -> None:
@@ -482,3 +505,7 @@ class SQLiteSessionStore:
     def _load(self, value: str) -> dict[str, Any]:
         loaded = json.loads(value)
         return loaded if isinstance(loaded, dict) else {}
+
+    def _load_list(self, value: str) -> list[Any]:
+        loaded = json.loads(value)
+        return loaded if isinstance(loaded, list) else []

+ 32 - 6
src/agent_lab/presentation/static/app.js

@@ -23,6 +23,8 @@ const toolCount = document.querySelector("#tool-count");
 const chatConfigDialog = document.querySelector("#chat-config-dialog");
 const eventConfigDialog = document.querySelector("#event-config-dialog");
 const auditDialog = document.querySelector("#audit-dialog");
+const toolInvocationMode = document.querySelector("#tool-invocation-mode");
+const toolModeHelp = document.querySelector("#tool-mode-help");
 const WORKSPACE_SNAPSHOTS_STORAGE_KEY = "agent-lab.workspace-snapshots.v1";
 const LEGACY_PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
 
@@ -79,6 +81,7 @@ bindClick("#select-all-tools", () => {
 bindClick("#clear-tools", () => {
   setAllTools(false);
 });
+toolInvocationMode.addEventListener("change", updateToolModeHelp);
 
 chatForm.addEventListener("submit", (event) => {
   event.preventDefault();
@@ -86,6 +89,7 @@ chatForm.addEventListener("submit", (event) => {
 });
 
 initializePromptList();
+updateToolModeHelp();
 refreshWorkspaceSnapshotSelector();
 loadTools();
 loadSessions({ preserveStatus: true });
@@ -202,19 +206,18 @@ async function loadSelectedSession() {
 
 async function loadSessionReplay(sessionId, options = {}) {
   try {
-    const [messages, audit, usage] = await Promise.all([
+    const [session, messages, audit, usage] = await Promise.all([
+      fetchJson(`/api/sessions/${sessionId}`),
       fetchJson(`/api/sessions/${sessionId}/messages`),
       fetchJson(`/api/sessions/${sessionId}/audit`),
       fetchJson(`/api/sessions/${sessionId}/usage`),
     ]);
+    restoreWorkspaceSnapshot(session.config || {});
     renderReplayMessages(messages);
     renderAuditReplay(audit);
     renderSessionUsage(usage);
-    const session = sessions.find((item) => item.id === sessionId);
-    if (session) {
-      sessionTitle.value = session.title;
-      sessionList.value = sessionId;
-    }
+    sessionTitle.value = session.title;
+    sessionList.value = sessionId;
     if (!options.preserveStatus && !isSocketOpen()) {
       setSessionStatus("Session loaded");
     }
@@ -361,6 +364,7 @@ function deleteWorkspaceSnapshot() {
 
 function buildWorkspaceSnapshot() {
   return {
+    tool_invocation_mode: document.querySelector("#tool-invocation-mode").value,
     prompt_items: buildPromptItems(),
     system_prompts: buildCustomPromptItems()
       .map((item) => item.content)
@@ -386,11 +390,15 @@ function buildWorkspaceSnapshot() {
       extra_body: buildExtraBody("event"),
       enabled_tools: selectedTools(),
       max_event_loops: Number(document.querySelector("#max-event-loops").value),
+      max_parallel_events: Number(document.querySelector("#max-parallel-events").value),
+      batch_timeout_seconds: Number(document.querySelector("#batch-timeout-seconds").value),
     },
   };
 }
 
 function restoreWorkspaceSnapshot(snapshot) {
+  document.querySelector("#tool-invocation-mode").value =
+    snapshot.tool_invocation_mode || "dual_agent";
   restoreSystemPrompts(snapshot.prompt_items || snapshot.system_prompts || []);
   restorePreMessages(snapshot.pre_messages || []);
 
@@ -407,9 +415,24 @@ function restoreWorkspaceSnapshot(snapshot) {
   document.querySelector("#event-system-prompt").value = eventAgent.system_prompt || "";
   restoreExtraBody("event", eventAgent.extra_body);
   document.querySelector("#max-event-loops").value = eventAgent.max_event_loops ?? 1;
+  document.querySelector("#max-parallel-events").value =
+    eventAgent.max_parallel_events ?? 4;
+  document.querySelector("#batch-timeout-seconds").value =
+    eventAgent.batch_timeout_seconds ?? 15;
   const enabledTools = eventAgent.enabled_tools || [];
   pendingEnabledTools = applySelectedTools(enabledTools) ? null : enabledTools;
   updateEventPromptItem(availableTools);
+  updateToolModeHelp();
+}
+
+function updateToolModeHelp() {
+  if (toolInvocationMode.value === "chat_agent_tools") {
+    toolModeHelp.textContent =
+      "ChatAgent Tools uses provider-resolved arguments. EventAgent model, temperature, max tokens, system prompt, and extra body are inactive; enabled tools, loop budget, batch concurrency, and timeout remain active.";
+    return;
+  }
+  toolModeHelp.textContent =
+    "Dual Agent may use the EventAgent model, system prompt, and extra body for missing-argument fallback.";
 }
 
 function buildExtraBody(prefix) {
@@ -831,6 +854,7 @@ function buildRequest(submittedMessage = userMessage.value) {
   return {
     session_id: currentSessionId || undefined,
     user_message: submittedMessage,
+    tool_invocation_mode: document.querySelector("#tool-invocation-mode").value,
     system_prompts: collectSystemPrompts(),
     pre_messages: [...document.querySelectorAll(".pre-message")]
       .map((row) => ({
@@ -853,6 +877,8 @@ function buildRequest(submittedMessage = userMessage.value) {
       extra_body: buildExtraBody("event"),
       enabled_tools: selectedTools(),
       max_event_loops: Number(document.querySelector("#max-event-loops").value),
+      max_parallel_events: Number(document.querySelector("#max-parallel-events").value),
+      batch_timeout_seconds: Number(document.querySelector("#batch-timeout-seconds").value),
     },
   };
 }

+ 14 - 2
src/agent_lab/presentation/static/index.html

@@ -4,7 +4,7 @@
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <title>Agent Lab</title>
-    <link rel="stylesheet" href="/static/styles.css?v=20260707-session-switch-idle-socket" />
+    <link rel="stylesheet" href="/static/styles.css?v=20260713-tool-mode-session-restore" />
   </head>
   <body>
     <header class="topbar">
@@ -46,6 +46,16 @@
 
         <section>
           <h2>Agent Config</h2>
+          <label>
+            Tool Invocation Mode
+            <select id="tool-invocation-mode">
+              <option value="dual_agent" selected>Dual Agent — fast reply + EventAgent</option>
+              <option value="chat_agent_tools">ChatAgent Tools — native provider tools</option>
+            </select>
+          </label>
+          <p id="tool-mode-help">
+            Dual Agent may use the EventAgent model, system prompt, and extra body for missing-argument fallback.
+          </p>
           <div class="button-row two">
             <button id="open-chat-config-panel" type="button">Chat Agent</button>
             <button id="open-event-config-panel" type="button">Event Agent</button>
@@ -211,6 +221,8 @@
           <label>Max Tokens <input id="event-max-tokens" type="number" min="1" step="1" value="800" /></label>
           <label>System Prompt <textarea id="event-system-prompt" rows="5" placeholder="Optional EventAgent system prompt"></textarea></label>
           <label>Max Loops <input id="max-event-loops" type="number" min="1" step="1" value="1" /></label>
+          <label>Max Parallel <input id="max-parallel-events" type="number" min="1" max="16" step="1" value="4" /></label>
+          <label>Batch Timeout (seconds) <input id="batch-timeout-seconds" type="number" min="0.1" max="120" step="0.1" value="15" /></label>
           <fieldset>
             <legend>Extra Body</legend>
             <label class="checkbox"><input id="event-thinking-disabled" type="checkbox" checked /> Disable thinking</label>
@@ -232,6 +244,6 @@
       </div>
     </template>
 
-    <script src="/static/app.js?v=20260707-session-switch-idle-socket"></script>
+    <script src="/static/app.js?v=20260713-tool-mode-session-restore"></script>
   </body>
 </html>

+ 173 - 0
tests/test_debug_runtime.py

@@ -14,6 +14,7 @@ from agent_lab.application.runtime import DebugRuntime
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
+from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
 from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
 
 
@@ -1610,6 +1611,24 @@ class ScriptedChatClient:
             yield item
 
 
+class TranscriptValidatingScriptedChatClient(ScriptedChatClient):
+    async def stream_chat(
+        self,
+        messages: list[ChatMessage],
+        tools: list[dict],
+        params: AgentParams,
+        tool_choice: dict[str, Any] | None = None,
+    ) -> AsyncIterator[StreamItem]:
+        OpenAICompatibleChatClient._validate_tool_transcript(self, messages)
+        async for item in super().stream_chat(
+            messages,
+            tools,
+            params,
+            tool_choice,
+        ):
+            yield item
+
+
 def _direct_request(
     *,
     enabled_tools: list[str],
@@ -1969,6 +1988,27 @@ async def test_direct_mode_session_turns_reset_budget_and_snapshot_mode(tmp_path
     session = store.get_session("direct-session")
     assert session is not None
     assert session["config"]["tool_invocation_mode"] == "chat_agent_tools"
+    persisted_messages = store.list_messages("direct-session")
+    assert [message["role"] for message in persisted_messages] == [
+        "user",
+        "assistant",
+        "tool",
+        "assistant",
+        "user",
+        "assistant",
+        "tool",
+        "assistant",
+    ]
+    assert [
+        call["id"]
+        for message in persisted_messages
+        for call in message["tool_calls"]
+    ] == ["turn-1-call", "turn-2-call"]
+    assert [
+        message["tool_call_id"]
+        for message in persisted_messages
+        if message["role"] == "tool"
+    ] == ["turn-1-call", "turn-2-call"]
     request_audits = [
         audit
         for audit in store.list_audit_logs("direct-session")
@@ -1981,6 +2021,139 @@ async def test_direct_mode_session_turns_reset_budget_and_snapshot_mode(tmp_path
     )
 
 
+@pytest.mark.asyncio
+async def test_direct_mode_reconnect_restores_provider_valid_session_transcript(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+    session_id = store.create_session(
+        title="restored direct",
+        config={"tool_invocation_mode": "chat_agent_tools"},
+        session_id="restored-direct",
+    )
+    store.start_turn(session_id, turn_index=1, user_message="past user")
+    previous_call = ToolCallEvent(
+        id="past-call",
+        name="safe_tool",
+        arguments={"value": 1},
+        raw_arguments='{"value":1}',
+    )
+    for message in [
+        ChatMessage(role="user", content="past user"),
+        ChatMessage(role="assistant", content="", tool_calls=[previous_call]),
+        ChatMessage(
+            role="tool",
+            content='{"ok":true}',
+            name="safe_tool",
+            tool_call_id="past-call",
+        ),
+        ChatMessage(role="assistant", content="past answer"),
+    ]:
+        store.append_message(session_id, turn_index=1, message=message)
+    store.complete_turn(session_id, turn_index=1)
+    client = TranscriptValidatingScriptedChatClient(
+        [[StreamItem.message_delta("current answer")]]
+    )
+    runtime = DebugRuntime(
+        client,
+        registry=_single_tool_registry(lambda event: {"ok": True}),
+        session_store=store,
+    )
+    request = _direct_request(
+        enabled_tools=["safe_tool"],
+        session_id=session_id,
+    ).model_copy(
+        update={
+            "user_message": "current user",
+            "pre_messages": [ChatMessage(role="user", content="configured pre")],
+        }
+    )
+
+    queues = runtime.start_session(request)
+    outputs: list[dict[str, Any]] = []
+    while not any(message["type"] == "turn_completed" for message in outputs):
+        outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
+    await runtime.aclose()
+
+    transcript = client.messages_by_call[0]
+    assert [(message.role, message.content) for message in transcript] == [
+        ("system", "You are a debugger."),
+        ("user", "configured pre"),
+        ("user", "past user"),
+        ("assistant", ""),
+        ("tool", '{"ok":true}'),
+        ("assistant", "past answer"),
+        ("user", "current user"),
+    ]
+    assert transcript[3].tool_calls == [previous_call]
+    assert sum(
+        message.role == "user" and message.content == "current user"
+        for message in transcript
+    ) == 1
+
+
+@pytest.mark.asyncio
+async def test_dual_mode_reconnect_restores_visible_history_without_tool_protocol(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+    session_id = store.create_session(
+        title="restored dual",
+        config={"tool_invocation_mode": "dual_agent"},
+        session_id="restored-dual",
+    )
+    store.start_turn(session_id, turn_index=1, user_message="past user")
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(role="user", content="past user"),
+    )
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(role="assistant", content="past answer"),
+    )
+    store.complete_turn(session_id, turn_index=1)
+    registry = _single_tool_registry(lambda event: {"ok": True})
+    client = ScriptedChatClient(
+        [
+            [StreamItem.text_event(_tool_call("dual-call"))],
+            [StreamItem.message_delta("current answer")],
+        ]
+    )
+    runtime = DebugRuntime(client, registry=registry, session_store=store)
+    request = DebugRunRequest(
+        session_id=session_id,
+        user_message="current user",
+        system_prompts=["system context"],
+        pre_messages=[ChatMessage(role="assistant", content="configured pre")],
+        chat_agent=AgentParams(model="chat-model"),
+        event_agent=EventAgentParams(
+            enabled_tools=["safe_tool"],
+            max_event_loops=1,
+        ),
+        tool_invocation_mode="dual_agent",
+    )
+
+    queues = runtime.start_session(request)
+    outputs: list[dict[str, Any]] = []
+    while not any(message["type"] == "turn_completed" for message in outputs):
+        outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
+    await runtime.aclose()
+
+    first_request = client.messages_by_call[0]
+    assert [(message.role, message.content) for message in first_request if message.role != "system"] == [
+        ("assistant", "configured pre"),
+        ("user", "past user"),
+        ("assistant", "past answer"),
+        ("user", "current user"),
+    ]
+    assert sum(
+        message.role == "user" and message.content == "current user"
+        for message in first_request
+    ) == 1
+    persisted_messages = store.list_messages(session_id)
+    assert not any(message["role"] == "tool" for message in persisted_messages)
+    assert not any(message["tool_calls"] for message in persisted_messages)
+    assert not any(message["content"] == "" for message in persisted_messages)
+
+
 def _single_tool_registry(
     handler: Any,
     *,

+ 87 - 0
tests/test_sqlite_store.py

@@ -1,3 +1,6 @@
+import sqlite3
+
+from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.messages import ChatMessage, TokenUsage
 from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
 
@@ -95,3 +98,87 @@ def test_sqlite_store_session_list_totals_are_not_multiplied_by_turns(tmp_path):
 
     assert store.list_sessions()[0]["turn_count"] == 2
     assert store.list_sessions()[0]["total_tokens"] == 6
+
+
+def test_sqlite_store_roundtrips_assistant_tool_calls_and_tool_replies(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+    session_id = store.create_session(title="direct tools", config={})
+    store.start_turn(session_id, turn_index=1, user_message="search")
+    tool_calls = [
+        ToolCallEvent(
+            id="call-1",
+            name="mock_search",
+            arguments={"query": "agent lab"},
+            raw_arguments='{"query":"agent lab"}',
+        ),
+        ToolCallEvent(
+            id="call-2",
+            name="mock_ticket",
+            arguments={"title": "follow up"},
+            raw_arguments='{"title":"follow up"}',
+        ),
+    ]
+
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(
+            role="assistant",
+            content="",
+            tool_calls=tool_calls,
+        ),
+    )
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(
+            role="tool",
+            content='{"ok":true}',
+            name="mock_search",
+            tool_call_id="call-1",
+        ),
+    )
+
+    messages = store.list_messages(session_id)
+
+    assert messages[0]["tool_calls"] == [call.model_dump() for call in tool_calls]
+    assert messages[1]["tool_calls"] == []
+    assert messages[1]["tool_call_id"] == "call-1"
+
+
+def test_sqlite_store_migrates_legacy_messages_with_empty_tool_calls(tmp_path):
+    database_path = tmp_path / "legacy.sqlite3"
+    connection = sqlite3.connect(database_path)
+    connection.execute(
+        """
+        CREATE TABLE messages (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            session_id TEXT NOT NULL,
+            turn_index INTEGER NOT NULL,
+            role TEXT NOT NULL,
+            content TEXT NOT NULL,
+            name TEXT,
+            tool_call_id TEXT,
+            created_at TEXT NOT NULL
+        )
+        """
+    )
+    connection.execute(
+        """
+        INSERT INTO messages
+            (session_id, turn_index, role, content, name, tool_call_id, created_at)
+        VALUES ('legacy', 1, 'assistant', 'old answer', NULL, NULL, 'now')
+        """
+    )
+    connection.commit()
+    connection.close()
+
+    store = SQLiteSessionStore(database_path)
+
+    assert store.list_messages("legacy")[0]["tool_calls"] == []
+    migrated = sqlite3.connect(database_path).execute(
+        "PRAGMA table_info(messages)"
+    ).fetchall()
+    tool_calls_column = next(column for column in migrated if column[1] == "tool_calls_json")
+    assert tool_calls_column[3] == 1
+    assert tool_calls_column[4] == "'[]'"

+ 96 - 5
tests/test_websocket_api.py

@@ -290,7 +290,28 @@ def test_session_api_returns_persisted_replay_data(tmp_path):
     store.append_message(
         session_id,
         turn_index=1,
-        message=ChatMessage(role="assistant", content="answer"),
+        message=ChatMessage(
+            role="assistant",
+            content="answer",
+            tool_calls=[
+                ToolCallEvent(
+                    id="call-1",
+                    name="mock_search",
+                    arguments={"query": "docs"},
+                    raw_arguments='{"query":"docs"}',
+                )
+            ],
+        ),
+    )
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(
+            role="tool",
+            content='{"results":[]}',
+            name="mock_search",
+            tool_call_id="call-1",
+        ),
     )
     store.append_audit(
         session_id,
@@ -308,9 +329,23 @@ def test_session_api_returns_persisted_replay_data(tmp_path):
         elapsed_ms=20,
     )
 
-    assert [message["content"] for message in client.get(
-        f"/api/sessions/{session_id}/messages"
-    ).json()] == ["debug this", "answer"]
+    messages = client.get(f"/api/sessions/{session_id}/messages").json()
+    assert [message["content"] for message in messages] == [
+        "debug this",
+        "answer",
+        '{"results":[]}',
+    ]
+    assert messages[0]["tool_calls"] == []
+    assert messages[1]["tool_calls"] == [
+        {
+            "id": "call-1",
+            "name": "mock_search",
+            "arguments": {"query": "docs"},
+            "raw_arguments": '{"query":"docs"}',
+        }
+    ]
+    assert messages[2]["tool_call_id"] == "call-1"
+    assert messages[2]["tool_calls"] == []
     assert client.get(f"/api/sessions/{session_id}/audit").json()[0]["details"] == {
         "model": "chat-model"
     }
@@ -1327,6 +1362,62 @@ def test_static_agent_config_controls_are_aligned_for_chat_and_event_agents():
     assert 'system_prompt: document.querySelector("#event-system-prompt")' in js
 
 
+def test_static_tool_mode_and_batch_controls_explain_active_configuration():
+    html = Path("src/agent_lab/presentation/static/index.html").read_text()
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+
+    assert 'id="tool-invocation-mode"' in html
+    assert '<option value="dual_agent" selected>' in html
+    assert '<option value="chat_agent_tools">' in html
+    assert 'id="tool-mode-help"' in html
+    assert 'id="max-parallel-events"' in html
+    assert 'value="4"' in html
+    assert 'id="batch-timeout-seconds"' in html
+    assert 'value="15"' in html
+    assert "missing-argument fallback" in js
+    assert "EventAgent model, temperature, max tokens, system prompt, and extra body are inactive" in js
+    assert "enabled tools, loop budget, batch concurrency, and timeout remain active" in js
+    assert "function updateToolModeHelp()" in js
+
+
+def test_static_tool_mode_config_roundtrips_request_snapshot_and_session_restore():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    build_snapshot = js[
+        js.index("function buildWorkspaceSnapshot()") :
+        js.index("function restoreWorkspaceSnapshot(")
+    ]
+    restore_snapshot = js[
+        js.index("function restoreWorkspaceSnapshot(") :
+        js.index("function buildExtraBody(")
+    ]
+    build_request = js[
+        js.index("function buildRequest(") :
+        js.index("function selectedTools(")
+    ]
+    load_replay = js[
+        js.index("async function loadSessionReplay(") :
+        js.index("function setSessionStatus(")
+    ]
+    create_session = js[
+        js.index("async function createSession()") :
+        js.index("async function loadSelectedSession()")
+    ]
+
+    for source in [build_snapshot, build_request]:
+        assert 'tool_invocation_mode: document.querySelector("#tool-invocation-mode").value' in source
+        assert 'max_parallel_events: Number(document.querySelector("#max-parallel-events").value)' in source
+        assert 'batch_timeout_seconds: Number(document.querySelector("#batch-timeout-seconds").value)' in source
+
+    assert 'snapshot.tool_invocation_mode || "dual_agent"' in restore_snapshot
+    assert 'eventAgent.max_parallel_events ?? 4' in restore_snapshot
+    assert 'eventAgent.batch_timeout_seconds ?? 15' in restore_snapshot
+    assert "updateToolModeHelp();" in restore_snapshot
+    assert '`/api/sessions/${sessionId}`' in load_replay
+    assert "const [session, messages, audit, usage] = await Promise.all([" in load_replay
+    assert "restoreWorkspaceSnapshot(session.config || {});" in load_replay
+    assert "config: buildWorkspaceSnapshot()" in create_session
+
+
 def test_static_tools_ui_shows_tool_metadata_and_selection_actions():
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
     js = Path("src/agent_lab/presentation/static/app.js").read_text()
@@ -1362,7 +1453,7 @@ def test_static_audit_replay_uses_event_stream_and_detail_panel():
     css = Path("src/agent_lab/presentation/static/styles.css").read_text()
 
     assert '<link rel="stylesheet" href="/static/styles.css?v=' in html
-    assert "session-switch-idle-socket" in html
+    assert "tool-mode-session-restore" in html
     assert 'id="audit-dialog"' in html
     assert 'id="open-audit-dialog"' in html
     assert 'id="close-audit-dialog"' in html