From 6cb37956b7407ee0e6640d5888eee3f837dbd70d Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Thu, 10 Sep 2026 23:50:27 +0800 Subject: [PATCH 1/5] fix(chat): replay attached completion after close Signed-off-by: duanjialing.777 --- loopx/attached_session.py | 4 ++- tests/test_attached_session_broker.py | 37 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/loopx/attached_session.py b/loopx/attached_session.py index 9a5715e7a9..cc77c6f776 100644 --- a/loopx/attached_session.py +++ b/loopx/attached_session.py @@ -181,9 +181,10 @@ def _require_attached_host( session_id: str, host_surface: str, host_session_id: str, + allow_closed: bool = False, ) -> dict[str, Any]: session = store.load_session(session_id) - if session is None or session.get("status") == "closed": + if session is None or (session.get("status") == "closed" and not allow_closed): raise KeyError("attached Agent session was not found") if session.get("session_mode") != CHAT_SESSION_MODE_ATTACHED: raise ValueError("the selected Session is not an attached host session") @@ -273,6 +274,7 @@ def complete_attached_agent_turn( session_id=session_id, host_surface=host_surface, host_session_id=host_session_id, + allow_closed=True, ) normalized_response = normalize_agent_response( response, diff --git a/tests/test_attached_session_broker.py b/tests/test_attached_session_broker.py index 29b429d2cf..46b3873eaa 100644 --- a/tests/test_attached_session_broker.py +++ b/tests/test_attached_session_broker.py @@ -608,6 +608,43 @@ def test_attached_close_rejects_active_claim_and_preserves_completion( assert runtime.wait_for_turn(session_id=session_id, turn_id=turn_id)["status"] == "completed" assert runtime.close_session(session_id) is True assert store.load_session(session_id)["status"] == "closed" # type: ignore[index] + with pytest.raises(KeyError, match="attached Agent session was not found"): + claim_attached_agent_turn( + store=store, + session_id=session_id, + host_surface=HOST_SURFACE, + host_session_id=HOST_SESSION_ID, + claim_id="claim-after-close", + ) + + replay = complete_attached_agent_turn( + store=store, + session_id=session_id, + turn_id=turn_id, + host_surface=HOST_SURFACE, + host_session_id=HOST_SESSION_ID, + claim_id="close-active-claim", + completion_id="close-active-completion", + response={"message": "retry payload"}, + ) + + assert replay["created"] is False + assert [ + message["text"] + for message in store.messages(session_id) + if message["role"] == "agent" + ] == ["completed before close"] + with pytest.raises(ValueError, match="already completed by another receipt"): + complete_attached_agent_turn( + store=store, + session_id=session_id, + turn_id=turn_id, + host_surface=HOST_SURFACE, + host_session_id=HOST_SESSION_ID, + claim_id="close-active-claim", + completion_id="different-receipt", + response={"message": "conflicting retry"}, + ) def test_attached_close_rejects_pending_queue_and_preserves_claimability( From cef5f24933b6cf9e539c52af4ad214facfd13ef4 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Fri, 11 Sep 2026 22:24:55 +0800 Subject: [PATCH 2/5] test(dashboard): disambiguate recovery pending state Signed-off-by: duanjialing.777 --- examples/personal-workspace-browser-smoke.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index d84c3244d2..9366ddf261 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -2907,14 +2907,14 @@ async function main() { await page.locator(".personal-goal-link").first().click(); await goalNavigation.getByRole("button", { name: "Chat" }).click(); await page.getByText("保持运行,用于验证刷新恢复。").waitFor({ state: "visible", timeout: 10_000 }); - await page.getByText("正在整理…").waitFor({ state: "hidden", timeout: 10_000 }); + await page.getByText("正在整理…", { exact: true }).waitFor({ state: "hidden", timeout: 10_000 }); const recovered = page.__loopxRuntime.sessions.get(recoveryTurn.sessionId); if (recovered?.active_turn_id !== null && recovered?.active_turn_id !== recoveryTurn.turnId) { throw new Error("Recovered Session points at a different active Turn"); } pass(6, "Reload restored visible Goal history and resumed the active Turn SSE stream."); } catch (error) { - fail(6, "Reload did not restore the active Goal conversation and reconnect its active Turn within 10 seconds."); + fail(6, `Reload did not restore the active Goal conversation and reconnect its active Turn within 10 seconds: ${error.message}`); await page.screenshot({ path: resolve(outputDir, "refresh-recovery-failed.png"), fullPage: true, animations: "disabled" }); observations.push(`Refresh recovery failure: ${error.message}`); } From d16b4987ac385a4c55488c25a70cf822a558216c Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Fri, 11 Sep 2026 23:08:54 +0800 Subject: [PATCH 3/5] test(dashboard): replay completed mock turn after reload Signed-off-by: duanjialing.777 (cherry picked from commit 31209395b98bc14f84f5b4a82d246e6ea2e4a085) Signed-off-by: duanjialing.777 --- examples/personal-workspace-browser-smoke.mjs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 9366ddf261..ee9350e3f1 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -847,18 +847,19 @@ async function installApi(page, { goalSubagentConfigurationEnabled = true } = {} const answer = "已沿用当前 Goal 与 Agent Session。接下来会先核对状态,再继续推进。"; await new Promise((resolveWait) => setTimeout(resolveWait, /(中断控制|刷新恢复)/u.test(turnMessages.get(turnId) ?? "") ? 5000 : 1200)); const activeSession = sessions.get(sessionId); + const visible = messages.get(sessionId) ?? []; + const completed = visible.some((message) => message.message_id === `${turnId}-assistant`); + const event = (id, kind, payload) => `id: ${id}\nevent: ${kind}\ndata: ${JSON.stringify({ event_id: id, sequence: Number(id), kind, created_at: "2026-08-13T01:00:02Z", payload })}\n\n`; if (!activeSession || activeSession.active_turn_id !== turnId) { - await route.fulfill({ contentType: "text/event-stream", body: "", status: 200 }); + await route.fulfill({ contentType: "text/event-stream", body: completed ? event("1", "assistant.delta", { text: answer }) + event("2", "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], gate: null } }) : "", status: 200 }); return; } - const visible = messages.get(sessionId) ?? []; - if (!visible.some((message) => message.message_id === `${turnId}-assistant`)) { + if (!completed) { visible.push({ message_id: `${turnId}-assistant`, turn_id: turnId, role: "assistant", text: answer, created_at: "2026-08-13T01:00:02Z" }); } messages.set(sessionId, visible); - const event = (id, kind, payload) => `id: ${id}\nevent: ${kind}\ndata: ${JSON.stringify({ event_id: id, sequence: Number(id), kind, created_at: "2026-08-13T01:00:02Z", payload })}\n\n`; - await route.fulfill({ contentType: "text/event-stream", body: event("1", "assistant.delta", { text: answer }) + event("2", "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], gate: null } }), status: 200 }); sessions.set(sessionId, { ...activeSession, active_turn_id: null, status: "ready", updated_at: "2026-08-13T01:00:02Z" }); + await route.fulfill({ contentType: "text/event-stream", body: event("1", "assistant.delta", { text: answer }) + event("2", "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], gate: null } }), status: 200 }); return; } if (url.pathname === "/api/chat/goals/contexts") { @@ -1090,20 +1091,21 @@ async function installApi(page, { goalSubagentConfigurationEnabled = true } = {} : "已沿用当前 Goal 与 Agent Session。接下来会先核对状态,再继续推进。"; await new Promise((resolveWait) => setTimeout(resolveWait, /(中断控制|刷新恢复)/u.test(operatorMessage) ? 5000 : 1200)); const activeSession = sessions.get(sessionId); + const visible = messages.get(sessionId) ?? []; + const completed = visible.some((message) => message.message_id === `${turnId}-assistant`); + const event = (id, kind, payload) => `id: ${id}\nevent: ${kind}\ndata: ${JSON.stringify({ event_id: id, sequence: Number(id), kind, created_at: "2026-08-13T01:00:02Z", payload })}\n\n`; if (!activeSession || activeSession.active_turn_id !== turnId) { - await route.fulfill({ contentType: "text/event-stream", body: "", status: 200 }); + await route.fulfill({ contentType: "text/event-stream", body: completed ? event("1", "assistant.delta", { text: answer }) + event("2", "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], protected_action: protectedAction, gate: null } }) : "", status: 200 }); return; } if (sessionId && messages.has(sessionId)) { - const visible = messages.get(sessionId); - if (!visible.some((message) => message.message_id === `${turnId}-assistant`)) { + if (!completed) { visible.push({ message_id: `${turnId}-assistant`, turn_id: turnId, role: "assistant", text: answer, created_at: "2026-08-13T01:00:02Z" }); } } - const event = (id, kind, payload) => `id: ${id}\nevent: ${kind}\ndata: ${JSON.stringify({ event_id: id, sequence: Number(id), kind, created_at: "2026-08-13T01:00:02Z", payload })}\n\n`; - await route.fulfill({ contentType: "text/event-stream", body: event("1", "assistant.delta", { text: answer }) + event("2", "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], protected_action: protectedAction, gate: null } }), status: 200 }); const current = sessions.get(sessionId); if (current?.active_turn_id === turnId) sessions.set(sessionId, { ...current, active_turn_id: null, status: "ready", updated_at: "2026-08-13T01:00:02Z" }); + await route.fulfill({ contentType: "text/event-stream", body: event("1", "assistant.delta", { text: answer }) + event("2", "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], protected_action: protectedAction, gate: null } }), status: 200 }); }); await page.route("**/api/actions?**", async (route) => { const url = new URL(route.request().url()); From 8e2262a63063b4684fdd86c75c741d6d6ad4a9c4 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Fri, 11 Sep 2026 23:35:02 +0800 Subject: [PATCH 4/5] test(dashboard): target exact recovery pending state Signed-off-by: duanjialing.777 --- examples/personal-workspace-browser-smoke.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 4490c0de68..c814a67619 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -2918,7 +2918,7 @@ async function main() { await page.locator(".personal-goal-link").first().click(); await goalNavigation.getByRole("button", { name: "Chat" }).click(); await page.getByText("保持运行,用于验证刷新恢复。").waitFor({ state: "visible", timeout: 10_000 }); - await page.getByText("正在整理…").waitFor({ state: "hidden", timeout: 10_000 }); + await page.getByText("正在整理…", { exact: true }).waitFor({ state: "hidden", timeout: 10_000 }); const recovered = page.__loopxRuntime.sessions.get(recoveryTurn.sessionId); if (recovered?.active_turn_id !== null && recovered?.active_turn_id !== recoveryTurn.turnId) { throw new Error("Recovered Session points at a different active Turn"); From f4f2b9bbcc51f28cf4a3dc216ba19a4efb3fb461 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Fri, 11 Sep 2026 23:44:28 +0800 Subject: [PATCH 5/5] fix(todo): type effect decision results Signed-off-by: duanjialing.777 --- loopx/control_plane/todos/decision_scope.py | 30 ++++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/loopx/control_plane/todos/decision_scope.py b/loopx/control_plane/todos/decision_scope.py index 07cf551777..ebc763a9fb 100644 --- a/loopx/control_plane/todos/decision_scope.py +++ b/loopx/control_plane/todos/decision_scope.py @@ -1,7 +1,7 @@ """Legacy input codec for the single typed decision-dependency rule owner.""" from __future__ import annotations -from typing import Any +from typing import Any, cast from ..effect_runtime import effect_runtime_result from .contract import ( @@ -90,8 +90,10 @@ def standing_decision_authority_for_agent(authority: dict[str, Any] | None, *, agent_id: str | None) -> dict[str, Any] | None: if _authority(authority) is None: return None - return _evaluate("standing", authority=_authority(authority), - agent_id=normalize_todo_claimed_by(agent_id)) + return cast(dict[str, Any] | None, _evaluate( + "standing", authority=_authority(authority), + agent_id=normalize_todo_claimed_by(agent_id), + )) def build_required_decision_scope_consistency( @@ -101,14 +103,14 @@ def build_required_decision_scope_consistency( user_source_items: list[dict[str, Any]] | None = None, standing_decision_authority: dict[str, Any] | None = None, ) -> dict[str, Any]: - return _evaluate("consistency", + return cast(dict[str, Any], _evaluate("consistency", agent_items=_source(agent_todo_summary, agent_source_items, _AGENT_SUMMARY_ITEM_KEYS), user_items=_source(user_todo_summary, user_source_items, _USER_SUMMARY_ITEM_KEYS), agent_id=normalize_todo_claimed_by(agent_id), registered_agents=sorted({value for raw in registered_agent_ids or [] if (value := normalize_todo_claimed_by(raw))}), standing_authority=_authority(standing_decision_authority), - ) + )) def build_required_decision_scope_repair_hint( @@ -193,26 +195,34 @@ def decision_scope_covers(gate_scope: Any, required_scope: Any) -> bool: required = normalize_todo_decision_scope(required_scope) if not gate or not required: return False - return _evaluate("covers", gate_scope=gate, required_scope=required) + return cast(bool, _evaluate("covers", gate_scope=gate, required_scope=required)) def decision_scope_gate_relation(gate: dict[str, Any], agent_item: dict[str, Any]) -> dict[str, Any] | None: - return _evaluate("scope_relation", gate=_facts(gate), item=_facts(agent_item)) + return cast(dict[str, Any] | None, _evaluate( + "scope_relation", gate=_facts(gate), item=_facts(agent_item), + )) def exact_todo_gate_relation(gate: dict[str, Any], agent_item: dict[str, Any]) -> dict[str, Any] | None: - return _evaluate("exact_relation", gate=_facts(gate), item=_facts(agent_item)) + return cast(dict[str, Any] | None, _evaluate( + "exact_relation", gate=_facts(gate), item=_facts(agent_item), + )) def todo_gate_relation(gate: dict[str, Any], agent_item: dict[str, Any]) -> dict[str, Any] | None: - return _evaluate("relation", gate=_facts(gate), item=_facts(agent_item)) + return cast(dict[str, Any] | None, _evaluate( + "relation", gate=_facts(gate), item=_facts(agent_item), + )) def todo_gate_relations(gates: list[dict[str, Any]], items: list[dict[str, Any]]) -> list[list[dict[str, Any] | None]]: """Evaluate a consumer's candidate set in one RPC, retaining positional identity.""" if not gates or not items: return [[] for _ in gates] - return _evaluate("relations", gates=[_facts(gate) for gate in gates], items=[_facts(item) for item in items]) + return cast(list[list[dict[str, Any] | None]], _evaluate( + "relations", gates=[_facts(gate) for gate in gates], items=[_facts(item) for item in items], + )) def todo_gate_relation_blocks_agent(relation: dict[str, Any] | None) -> bool: