From 2e94a3889159641bf8c3c7b30e224d13f0363aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B6rje?= Date: Thu, 16 Jul 2026 09:49:30 +0200 Subject: [PATCH 1/5] fix: isolate desktop project routing per session --- .../chat/sidebar/projects/overview-row.tsx | 8 +- apps/desktop/src/app/contrib/wiring.tsx | 39 ++------ tests/tools/test_project_tools.py | 97 +++++++++++++++++++ tools/project_tools.py | 22 +++-- 4 files changed, 130 insertions(+), 36 deletions(-) create mode 100644 tests/tools/test_project_tools.py diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index c4aeefb2d55b5..2b06083392af5 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -118,7 +118,13 @@ export function ProjectOverviewRow({ actions={ <> {onNewSession && ( - onNewSession(project.path)} /> + { + onEnter?.(project.id) + onNewSession(project.path) + }} + /> )} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index b2d0c03a98816..d6d1f901ba82d 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -31,7 +31,7 @@ import { setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile' -import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects' +import { $startWorkSessionRequest } from '@/store/projects' import { $activeSessionId, $connection, @@ -48,8 +48,6 @@ import { sessionPinId, setAwaitingResponse, setBusy, - setCurrentBranch, - setCurrentCwd, setCurrentModel, setCurrentModelSource, setCurrentProvider, @@ -89,6 +87,7 @@ import { useRouteResume } from '../session/hooks/use-route-resume' import { useSessionActions } from '../session/hooks/use-session-actions' import { useSessionListActions } from '../session/hooks/use-session-list-actions' import { useSessionStateCache } from '../session/hooks/use-session-state-cache' +import { startWorkspaceSession } from '../session/workspace-session-target' import { useOverlayRouting } from '../shell/hooks/use-overlay-routing' import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width' import { titlebarControlsPosition } from '../shell/titlebar' @@ -450,33 +449,15 @@ export function ContribWiring({ children }: { children: ReactNode }) { // also drills the sidebar into that project so the new lane is visible. const startSessionInWorkspace = useCallback( (path: null | string) => { - startFreshSessionDraft() - - // A worktree lane carries its own path; the trunk "+" can be path-less - // (the main checkout is implicit), so fall back to the active project's - // root instead of no-op'ing on null. - const target = path?.trim() || resolveNewSessionCwd() - - if (!target) { - return - } - - setCurrentCwd(target) - void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) - .then(info => { - const resolved = info.cwd || target - - setCurrentCwd(resolved) - setCurrentBranch(info.branch || '') - - if (path?.trim()) { - restoreWorktree(resolved) - void followActiveSessionCwd(resolved) - } - }) - .catch(() => undefined) + startWorkspaceSession({ + activeSessionIdRef, + onExplicitWorkspace: restoreWorktree, + path, + requestGateway, + startFreshSessionDraft + }) }, - [requestGateway, startFreshSessionDraft] + [activeSessionIdRef, requestGateway, startFreshSessionDraft] ) // Composer "branch off into a new worktree": open a fresh session anchored diff --git a/tests/tools/test_project_tools.py b/tests/tools/test_project_tools.py new file mode 100644 index 0000000000000..2f8b47f36cd95 --- /dev/null +++ b/tests/tools/test_project_tools.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json + +from hermes_cli import projects_db as pdb +from tools import project_tools + + +def _use_projects_db(monkeypatch, tmp_path): + db_path = tmp_path / "projects.db" + original_connect_closing = pdb.connect_closing + monkeypatch.setattr(pdb, "connect_closing", lambda: original_connect_closing(db_path=db_path)) + return db_path, original_connect_closing + + +def test_agent_session_project_create_keeps_profile_active_pointer_and_moves_session(monkeypatch, tmp_path): + db_path, connect_closing = _use_projects_db(monkeypatch, tmp_path) + with connect_closing(db_path=db_path) as conn: + active_id = pdb.create_project(conn, name="Foreground", folders=["/work/foreground"]) + pdb.set_active(conn, active_id) + + workspace_moves: list[tuple[str, str, str]] = [] + project_tools.set_project_workspace_callback(lambda *args: workspace_moves.append(args)) + + try: + result = json.loads( + project_tools.project_create( + "Background", path="/work/background", task_id="session-background" + ) + ) + finally: + project_tools.set_project_workspace_callback(None) + + with connect_closing(db_path=db_path) as conn: + assert pdb.get_active_id(conn) == active_id + created = pdb.get_project(conn, result["id"]) + + assert result["success"] is True + assert created is not None + assert created.name == "Background" + assert workspace_moves == [("session-background", "/work/background", "Background")] + + +def test_legacy_project_create_sets_profile_active_pointer(monkeypatch, tmp_path): + db_path, connect_closing = _use_projects_db(monkeypatch, tmp_path) + with connect_closing(db_path=db_path) as conn: + previous_id = pdb.create_project(conn, name="Previous", folders=["/work/previous"]) + pdb.set_active(conn, previous_id) + + result = json.loads(project_tools.project_create("Created", path="/work/created")) + + with connect_closing(db_path=db_path) as conn: + assert pdb.get_active_id(conn) == result["id"] + + assert result["success"] is True + + +def test_legacy_project_switch_sets_profile_active_pointer(monkeypatch, tmp_path): + db_path, connect_closing = _use_projects_db(monkeypatch, tmp_path) + with connect_closing(db_path=db_path) as conn: + active_id = pdb.create_project(conn, name="Active", folders=["/work/active"]) + target_id = pdb.create_project(conn, name="Target", folders=["/work/target"]) + pdb.set_active(conn, active_id) + + result = json.loads(project_tools.project_switch(target_id)) + + with connect_closing(db_path=db_path) as conn: + assert pdb.get_active_id(conn) == target_id + + assert result["success"] is True + assert result["id"] == target_id + + +def test_agent_session_project_switch_keeps_profile_active_pointer(monkeypatch, tmp_path): + db_path = tmp_path / "projects.db" + original_connect_closing = pdb.connect_closing + + with original_connect_closing(db_path=db_path) as conn: + active_id = pdb.create_project(conn, name="Foreground", folders=["/work/foreground"]) + background_id = pdb.create_project(conn, name="Background", folders=["/work/background"]) + pdb.set_active(conn, active_id) + + monkeypatch.setattr(pdb, "connect_closing", lambda: original_connect_closing(db_path=db_path)) + workspace_moves: list[tuple[str, str, str]] = [] + project_tools.set_project_workspace_callback(lambda *args: workspace_moves.append(args)) + + try: + result = json.loads(project_tools.project_switch(background_id, task_id="session-background")) + finally: + project_tools.set_project_workspace_callback(None) + + with original_connect_closing(db_path=db_path) as conn: + assert pdb.get_active_id(conn) == active_id + + assert result["success"] is True + assert result["id"] == background_id + assert workspace_moves == [("session-background", "/work/background", "Background")] diff --git a/tools/project_tools.py b/tools/project_tools.py index 2b52e3144d613..8ad8866faa8a8 100644 --- a/tools/project_tools.py +++ b/tools/project_tools.py @@ -3,13 +3,16 @@ Projects (per-profile ``projects.db``) are the named workspaces the desktop sidebar groups sessions into. Creating / switching a project is a deliberate act -expressed as explicit tools — never a side effect of a terminal ``cd``. +expressed as explicit tools — never a side effect of a terminal ``cd``. A live +session create/switch re-anchors only that session; it must not move the +profile-global Desktop selection shared by concurrent chats. Exposed only on GUI sessions: the tools live in the `project` toolset (kept off ``_HERMES_CORE_TOOLS``) which the desktop/TUI gateway folds into its resolved toolsets, so no CLI/messaging/cron schema carries them. The GUI also wires ``set_project_workspace_callback`` so a create/switch re-anchors the live -session's cwd and the sidebar follows the move; the DB write is the durable part. +session's cwd and the sidebar follows the move. Project creation remains durable; +a create/switch without a live task id retains the legacy active-pointer behavior. """ import json @@ -20,8 +23,7 @@ # Set by the GUI gateway (tui_gateway) at session wiring. Receives # ``(task_id, primary_path, project_name)`` and re-anchors that session's -# workspace + refreshes the sidebar. ``None`` in CLI / messaging contexts — the -# DB write still happens; there's just no live GUI session to move. +# workspace + refreshes the sidebar. ``None`` in CLI / messaging contexts. _workspace_callback: Optional[Callable[[str, str, str], None]] = None @@ -102,7 +104,11 @@ def project_create(name: str, path: Optional[str] = None, task_id: Optional[str] try: with pdb.connect_closing() as conn: pid = pdb.create_project(conn, name=name, folders=[folder] if folder else [], primary_path=folder or None) - pdb.set_active(conn, pid) + # A live agent session owns its workspace independently. Creating a + # project must not let a background chat redirect the profile-global + # Desktop selection used by unrelated and newly created sessions. + if not task_id: + pdb.set_active(conn, pid) proj = pdb.get_project(conn, pid) except ValueError as exc: return json.dumps({"success": False, "error": str(exc)}) @@ -123,7 +129,11 @@ def project_switch(project: str, task_id: Optional[str] = None) -> str: proj = _resolve(conn, project) if proj is None: return json.dumps({"success": False, "error": f"no project matching '{project}'"}) - pdb.set_active(conn, proj.id) + # A live agent session owns its workspace independently. Mutating the + # profile-global pointer here lets a background chat redirect unrelated + # Desktop windows and newly created sessions into this project. + if not task_id: + pdb.set_active(conn, proj.id) primary = _primary_path(proj) _apply_workspace(task_id, primary, proj.name) From 3766ff9dbc4e788126e716a9e949c36a3b15162b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B6rje?= Date: Wed, 22 Jul 2026 18:32:07 +0200 Subject: [PATCH 2/5] fix(desktop): bind prompt submits to durable sessions --- .../hooks/use-prompt-actions/index.test.tsx | 54 ++++++++++++++--- .../hooks/use-prompt-actions/submit.ts | 18 +++++- apps/desktop/src/store/updates.test.ts | 6 +- apps/desktop/src/store/updates.ts | 3 +- tests/test_tui_gateway_server.py | 58 +++++++++++++++++++ tui_gateway/server.py | 19 +++++- 6 files changed, 142 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index a06ee1294c08d..6bf48adf13070 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -321,6 +321,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { session_id: RUNTIME_SESSION_ID }) expect(calls[1]?.params).toEqual({ + expected_stored_session_id: RUNTIME_SESSION_ID, session_id: RUNTIME_SESSION_ID, text: 'write the implementation plan' }) @@ -524,6 +525,7 @@ describe('usePromptActions submit / queue drain semantics', () => { expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', { + expected_stored_session_id: RUNTIME_SESSION_ID, session_id: RUNTIME_SESSION_ID, text: 'hello after a stop' }, @@ -553,6 +555,7 @@ describe('usePromptActions submit / queue drain semantics', () => { expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', { + expected_stored_session_id: RUNTIME_SESSION_ID, session_id: RUNTIME_SESSION_ID, text: 'queued message' }, @@ -588,6 +591,7 @@ describe('usePromptActions submit / queue drain semantics', () => { expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', { + expected_stored_session_id: 'stored-session-a', session_id: 'rt-session-a', text: 'queued for background session' }, @@ -604,8 +608,8 @@ describe('usePromptActions submit / queue drain semantics', () => { it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => { // A stale-session 404 must not strand the queued entry: submitPrompt returns // false on failure so the composer keeps it, and the edge-independent - // auto-drain re-attempts once the session is idle again. storedSessionId is - // null so the session.resume recovery path is skipped and the error surfaces. + // auto-drain re-attempts once the session is idle again while the durable + // session target remains pinned across both attempts. let attempt = 0 const requestGateway = vi.fn(async (method: string) => { @@ -626,7 +630,7 @@ describe('usePromptActions submit / queue drain semantics', () => { onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} - storedSessionId={null} + storedSessionId={RUNTIME_SESSION_ID} /> ) @@ -638,6 +642,7 @@ describe('usePromptActions submit / queue drain semantics', () => { expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', { + expected_stored_session_id: RUNTIME_SESSION_ID, session_id: RUNTIME_SESSION_ID, text: 'please send me' }, @@ -988,6 +993,7 @@ describe('usePromptActions file attachment sync', () => { data_url: 'data:text/plain;base64,aGVsbG8=' }) expect(calls[1]?.params).toEqual({ + expected_stored_session_id: RUNTIME_SESSION_ID, session_id: RUNTIME_SESSION_ID, text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub' }) @@ -1068,7 +1074,11 @@ describe('usePromptActions file attachment sync', () => { expect(calls[0]?.params).not.toHaveProperty('data_url') expect(calls[1]).toEqual({ method: 'prompt.submit', - params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' } + params: { + expected_stored_session_id: RUNTIME_SESSION_ID, + session_id: RUNTIME_SESSION_ID, + text: '@file:data/report.txt\n\nsummarize' + } }) }) }) @@ -1190,7 +1200,11 @@ describe('usePromptActions sleep/wake session recovery', () => { // First submit (stale id) → session.resume (stored id) → retry submit (fresh id). expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) - expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) + expect(calls[2]?.params).toEqual({ + expected_stored_session_id: STORED_SESSION_ID, + session_id: RECOVERED_SESSION_ID, + text: 'message after wake' + }) }) it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => { @@ -1237,11 +1251,13 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) expect(calls[0]?.params).toEqual({ + expected_stored_session_id: STORED_SESSION_ID, session_id: 'rt-background-stale', text: 'queued background message after wake' }) expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) expect(calls[2]?.params).toEqual({ + expected_stored_session_id: STORED_SESSION_ID, session_id: RECOVERED_SESSION_ID, text: 'queued background message after wake' }) @@ -1421,6 +1437,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) expect(calls[2]?.params).toEqual({ + expected_stored_session_id: STORED_SESSION_ID, session_id: RECOVERED_SESSION_ID, text: 'message during starved loop' }) @@ -1539,7 +1556,11 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(createBackendSessionForSend).not.toHaveBeenCalled() expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', - { session_id: RECOVERED_SESSION_ID, text: 'follow-up while the profile route is rebinding' }, + { + expected_stored_session_id: STORED_SESSION_ID, + session_id: RECOVERED_SESSION_ID, + text: 'follow-up while the profile route is rebinding' + }, 1_800_000 ) }) @@ -1576,7 +1597,11 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(resumeStoredSession).toHaveBeenCalledWith(STORED_SESSION_ID) expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', - { session_id: RECOVERED_SESSION_ID, text: 'stay in the routed profile session' }, + { + expected_stored_session_id: STORED_SESSION_ID, + session_id: RECOVERED_SESSION_ID, + text: 'stay in the routed profile session' + }, 1_800_000 ) }) @@ -1607,7 +1632,11 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(resumeStoredSession).not.toHaveBeenCalled() expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', - { session_id: RECOVERED_SESSION_ID, text: 'normal follow-up' }, + { + expected_stored_session_id: STORED_SESSION_ID, + session_id: RECOVERED_SESSION_ID, + text: 'normal follow-up' + }, 1_800_000 ) }) @@ -1664,13 +1693,18 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(await handle!.submitText('retry after recovery')).toBe(true) expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', - { session_id: RECOVERED_SESSION_ID, text: 'retry after recovery' }, + { + expected_stored_session_id: STORED_SESSION_ID, + session_id: RECOVERED_SESSION_ID, + text: 'retry after recovery' + }, 1_800_000 ) }) it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => { const activeSessionIdRef: MutableRefObject = { current: null } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } // Mirror the real createBackendSessionForSend: a successful create // re-homes the active runtime ref to the session it minted BEFORE @@ -1678,6 +1712,7 @@ describe('usePromptActions sleep/wake session recovery', () => { // regression ship green. const createBackendSessionForSend = vi.fn(async () => { activeSessionIdRef.current = RUNTIME_SESSION_ID + selectedStoredSessionIdRef.current = RUNTIME_SESSION_ID return RUNTIME_SESSION_ID }) @@ -1699,6 +1734,7 @@ describe('usePromptActions sleep/wake session recovery', () => { onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} storedSessionId={null} /> ) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 709652dce7bf5..1c5d94daca3c0 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -449,6 +449,20 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) rewriteOptimistic(sessionId) const text = buildContextText(syncedAttachments) + const expectedStoredSessionId = options?.storedSessionId ?? startingStoredSessionId + + // Every Desktop submit is durably pinned as well as runtime-routed. The + // gateway rejects a stale runtime→stored binding before it can queue, + // truncate, persist, or start a turn (#54527). + if (!expectedStoredSessionId) { + return abortForSessionSwitch(sessionId) + } + + const submitParams = (runtimeSessionId: string) => ({ + expected_stored_session_id: expectedStoredSessionId, + session_id: runtimeSessionId, + text + }) // On sleep/wake the gateway's in-memory session may have been cleared // while the desktop app still holds the old session ID. Detect this, @@ -457,7 +471,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { try { await withSessionBusyRetry(() => - requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + requestGateway('prompt.submit', submitParams(sessionId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } catch (firstErr) { const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current @@ -485,7 +499,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } await withSessionBusyRetry(() => - requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + requestGateway('prompt.submit', submitParams(recoveredId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } else { submitErr = firstErr diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 1a0dc7f07923c..26c65cc257586 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -120,7 +120,7 @@ describe('reportBackendContract', () => { }) it('dismisses the toast when the backend meets the contract', () => { - reportBackendContract(4) + reportBackendContract(Number.MAX_SAFE_INTEGER) expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew') expect(notifySpy).not.toHaveBeenCalled() }) @@ -160,8 +160,8 @@ describe('reportBackendContract', () => { lastToast().onDismiss() notifySpy.mockClear() - reportBackendContract(4) // backend updated → satisfied, snooze cleared - reportBackendContract(3) // a later regression must warn immediately + reportBackendContract(Number.MAX_SAFE_INTEGER) // backend updated → satisfied, snooze clears + reportBackendContract(0) // a later regression must warn immediately expect(notifySpy).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 6c7e1483cf938..afb12bf4c8fd7 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -93,7 +93,8 @@ function isUpdateToastSnoozed(): boolean { // v2: requires the file.attach RPC (remote-gateway non-image file upload). // v3: requires approvals.mode config RPCs and session.info reconciliation. // v4: requires explicit Fast-off session creation and session-scoped Fast edits. -const REQUIRED_BACKEND_CONTRACT = 4 +// v5: requires durable stored-session binding on every Desktop prompt.submit. +const REQUIRED_BACKEND_CONTRACT = 5 const SKEW_TOAST_ID = 'backend-contract-skew' // The contract check runs on every session.resume (applyRuntimeInfo), so // without a snooze the warning re-popped on every thread the user opened, even diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 125d3eb584e1e..a835ab67de69b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -262,6 +262,64 @@ def submit_turn(self, frame, *, on_complete=None): server._sessions.pop("iso-sid", None) +def test_desktop_prompt_submit_rejects_mismatched_durable_target_before_mutation(monkeypatch): + session = _session(source="desktop", session_key="stored-session-b", running=True) + server._sessions["runtime-session-b"] = session + mutations = [] + monkeypatch.setattr(server, "_ensure_session_db_row", lambda _session: mutations.append("persist")) + try: + response = server.handle_request({ + "id": "cross-wired-submit", "method": "prompt.submit", + "params": {"session_id": "runtime-session-b", "expected_stored_session_id": "stored-session-a", "text": "must stay in session A"}, + }) + finally: + server._sessions.pop("runtime-session-b", None) + assert response["error"]["code"] == 4091 + assert session["running"] is True + assert session.get("queued_prompt") is None + assert session["history"] == [] + assert mutations == [] + + +def test_desktop_prompt_submit_requires_durable_target_from_current_clients(): + session = _session(source="desktop", session_key="stored-session") + server._sessions["runtime-session"] = session + try: + response = server.handle_request({ + "id": "legacy-desktop-submit", "method": "prompt.submit", + "params": {"session_id": "runtime-session", "text": "unsafe legacy send"}, + }) + finally: + server._sessions.pop("runtime-session", None) + assert response["error"]["code"] == 4260 + assert "upgrade" in response["error"]["message"].lower() + assert session["history"] == [] + + +def test_non_desktop_prompt_submit_remains_compatible_without_durable_target(monkeypatch): + class _ImmediateThread: + def __init__(self, target=None, **_kwargs): self._target = target + def start(self): self._target() + session = _session(source="tui", session_key="stored-session") + server._sessions["runtime-session"] = session + calls = [] + monkeypatch.setattr(server, "_ensure_session_db_row", lambda _session: None) + monkeypatch.setattr(server, "_persist_branch_seed", lambda _session: None) + monkeypatch.setattr(server, "_start_agent_build", lambda _sid, _session: None) + monkeypatch.setattr(server, "_wait_agent", lambda _session, _rid: None) + monkeypatch.setattr(server, "_run_prompt_submit", lambda rid, sid, _session, text: calls.append((rid, sid, text))) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + try: + response = server.handle_request({ + "id": "tui-submit", "method": "prompt.submit", + "params": {"session_id": "runtime-session", "text": "compatible send"}, + }) + finally: + server._sessions.pop("runtime-session", None) + assert response["result"]["status"] == "streaming" + assert calls == [("tui-submit", "runtime-session", "compatible send")] + + def test_prompt_submit_fails_open_inline_when_compute_host_dispatch_breaks(monkeypatch): class _BrokenSupervisor: def submit_turn(self, frame, *, on_complete=None): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03d8492d932c5..8a0f31e81f856 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3832,7 +3832,8 @@ def _current_profile_name() -> str: # v2: adds the file.attach RPC (remote-gateway non-image file upload). # v3: adds approvals.mode config RPCs and session.info reconciliation. # v4: session.create fast=false is an explicit per-session normal-tier override. -DESKTOP_BACKEND_CONTRACT = 4 +# v5: every Desktop prompt.submit is bound to its durable stored session. +DESKTOP_BACKEND_CONTRACT = 5 def _session_usage_snapshot(session: dict | None) -> dict: @@ -9608,6 +9609,22 @@ def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) if err: return err + # A Desktop runtime id is only a volatile transport handle. Bind every + # submit to the durable conversation the composer intended before touching + # transport, busy queues, history, or persistence. This is the server-side + # fence for stale/cross-wired Desktop runtime caches (#54527). + expected_key_present = "expected_stored_session_id" in params + expected_key = str(params.get("expected_stored_session_id") or "").strip() + actual_key = str(session.get("session_key") or "").strip() + if not expected_key_present or not expected_key: + if _session_source(session) == "desktop": + return _err( + rid, + 4260, + "Desktop upgrade required: prompt.submit must include its durable session target", + ) + elif expected_key != actual_key: + return _err(rid, 4091, "prompt target mismatch; reload the conversation and retry") isolation_cfg = _load_dashboard_process_isolation_config() turn_isolation = _session_uses_compute_host(session, isolation_cfg) # Re-bind to the current client transport for this request. This keeps From 997505ba2d883beab41e80b9be63fc9a123bd2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B6rje?= Date: Wed, 22 Jul 2026 20:02:05 +0200 Subject: [PATCH 3/5] fix(desktop): allow legacy prompt submit omission --- tests/test_tui_gateway_server.py | 11 ++++++----- tui_gateway/server.py | 26 ++++++++++---------------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a835ab67de69b..1f20cee6741cd 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -281,18 +281,19 @@ def test_desktop_prompt_submit_rejects_mismatched_durable_target_before_mutation assert mutations == [] -def test_desktop_prompt_submit_requires_durable_target_from_current_clients(): - session = _session(source="desktop", session_key="stored-session") +def test_legacy_desktop_prompt_submit_accepts_omitted_durable_target(monkeypatch): + session = _session(source="desktop", session_key="stored-session", running=True) server._sessions["runtime-session"] = session + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "queue") try: response = server.handle_request({ "id": "legacy-desktop-submit", "method": "prompt.submit", - "params": {"session_id": "runtime-session", "text": "unsafe legacy send"}, + "params": {"session_id": "runtime-session", "text": "compatible legacy send"}, }) finally: server._sessions.pop("runtime-session", None) - assert response["error"]["code"] == 4260 - assert "upgrade" in response["error"]["message"].lower() + assert response["result"]["status"] == "queued" + assert session["queued_prompt"]["text"] == "compatible legacy send" assert session["history"] == [] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a3215da8b540f..4febb4c164ec4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9609,22 +9609,16 @@ def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) if err: return err - # A Desktop runtime id is only a volatile transport handle. Bind every - # submit to the durable conversation the composer intended before touching - # transport, busy queues, history, or persistence. This is the server-side - # fence for stale/cross-wired Desktop runtime caches (#54527). - expected_key_present = "expected_stored_session_id" in params - expected_key = str(params.get("expected_stored_session_id") or "").strip() - actual_key = str(session.get("session_key") or "").strip() - if not expected_key_present or not expected_key: - if _session_source(session) == "desktop": - return _err( - rid, - 4260, - "Desktop upgrade required: prompt.submit must include its durable session target", - ) - elif expected_key != actual_key: - return _err(rid, 4091, "prompt target mismatch; reload the conversation and retry") + # A Desktop runtime id is only a volatile transport handle. New Desktop + # clients bind each submit to the durable conversation the composer + # intended; reject a stale/cross-wired binding before touching transport, + # busy queues, history, or persistence (#54527). Omission remains accepted + # for expand-compatible rollout with Desktop clients predating this field. + if "expected_stored_session_id" in params: + expected_key = str(params.get("expected_stored_session_id") or "").strip() + actual_key = str(session.get("session_key") or "").strip() + if expected_key != actual_key: + return _err(rid, 4091, "prompt target mismatch; reload the conversation and retry") isolation_cfg = _load_dashboard_process_isolation_config() turn_isolation = _session_uses_compute_host(session, isolation_cfg) # Re-bind to the current client transport for this request. This keeps From e1712e0a0897ab729217042ae673d11174a932de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B6rje?= Date: Thu, 23 Jul 2026 00:17:54 +0200 Subject: [PATCH 4/5] fix(desktop): bind all turn mutations to durable sessions --- .../app/chat/session-tile-actions.test.tsx | 133 ++++++++++++++++++ .../src/app/chat/session-tile-actions.ts | 14 +- .../hooks/use-session-tile-delegate.test.tsx | 61 ++++++++ .../hooks/use-session-tile-delegate.ts | 15 +- .../hooks/use-prompt-actions/index.test.tsx | 96 ++++++++++++- .../session/hooks/use-prompt-actions/index.ts | 86 ++++++++--- .../hooks/use-prompt-actions/rewind.ts | 7 +- apps/desktop/src/store/session-states.ts | 4 +- tests/test_tui_gateway_server.py | 38 +++++ tui_gateway/server.py | 20 ++- 10 files changed, 439 insertions(+), 35 deletions(-) create mode 100644 apps/desktop/src/app/chat/session-tile-actions.test.tsx create mode 100644 apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx diff --git a/apps/desktop/src/app/chat/session-tile-actions.test.tsx b/apps/desktop/src/app/chat/session-tile-actions.test.tsx new file mode 100644 index 0000000000000..3f91d2a0607d8 --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-actions.test.tsx @@ -0,0 +1,133 @@ +import type { AppendMessage } from '@assistant-ui/react' +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { textPart } from '@/lib/chat-messages' +import { createClientSessionState } from '@/lib/chat-runtime' +import { clearAllSessionStates, publishSessionState, setSessionTileDelegate } from '@/store/session-states' + +import { MAIN_COMPOSER_SCOPE } from './composer/scope' +import { useSessionTileActions } from './session-tile-actions' + +const requestGateway = vi.hoisted(() => vi.fn(async () => ({}) as never)) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway }) +})) + +vi.mock('@/hermes', () => ({ + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000, + setApiRequestProfile: vi.fn() +})) + +const RUNTIME_ID = 'tile-runtime' +const STORED_ID = 'tile-stored' + +const messages = [ + { id: 'u1', role: 'user' as const, parts: [textPart('first prompt')] }, + { id: 'a1', role: 'assistant' as const, parts: [textPart('first answer')] }, + { id: 'u2', role: 'user' as const, parts: [textPart('second prompt')] }, + { id: 'a2', role: 'assistant' as const, parts: [textPart('second answer')] } +] + +function renderActions() { + let state = createClientSessionState(STORED_ID, messages) + publishSessionState(RUNTIME_ID, state) + + setSessionTileDelegate({ + archiveSession: async () => undefined, + branchSession: async () => undefined, + deleteSession: async () => undefined, + executeSlash: async () => undefined, + interruptSession: async () => undefined, + resumeTile: async () => RUNTIME_ID, + submitToSession: async () => undefined, + updateSession: (_runtimeId, updater) => { + state = updater(state) + publishSessionState(RUNTIME_ID, state) + + return state + } + }) + + return renderHook(() => + useSessionTileActions({ runtimeId: RUNTIME_ID, scope: MAIN_COMPOSER_SCOPE, storedSessionId: STORED_ID }) + ) +} + +afterEach(() => { + cleanup() + clearAllSessionStates() + requestGateway.mockClear() +}) + +describe('session tile durable prompt binding', () => { + it('binds stop to the tile stored session id', async () => { + const { result } = renderActions() + + await act(async () => result.current.cancelRun()) + + expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { + expected_stored_session_id: STORED_ID, + session_id: RUNTIME_ID + }) + }) + + it('binds reload to the tile stored session id', async () => { + const { result } = renderActions() + + await act(async () => result.current.reloadFromMessage(null)) + + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + expected_stored_session_id: STORED_ID, + session_id: RUNTIME_ID, + text: 'second prompt', + truncate_before_user_ordinal: 1 + }, + 1_800_000 + ) + }) + + it('binds restore rewind to the tile stored session id', async () => { + const { result } = renderActions() + + await act(async () => result.current.restoreToMessage('u1')) + + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + expected_stored_session_id: STORED_ID, + session_id: RUNTIME_ID, + text: 'first prompt', + truncate_before_user_ordinal: 0 + }, + 1_800_000 + ) + }) + + it('binds edit rewind to the tile stored session id', async () => { + const { result } = renderActions() + + await act(async () => + result.current.editMessage({ + parentId: null, + role: 'user', + sourceId: 'u2', + content: [{ type: 'text', text: 'edited second prompt' }] + } as unknown as AppendMessage) + ) + + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + expected_stored_session_id: STORED_ID, + session_id: RUNTIME_ID, + text: 'edited second prompt', + truncate_before_user_ordinal: 1 + }, + 1_800_000 + ) + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 4eca47f5c094a..edfb0d3c85d3c 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -212,7 +212,10 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses clearClarifyRequest(undefined, sessionId) try { - await requestGateway('session.interrupt', { session_id: sessionId }) + await requestGateway('session.interrupt', { + expected_stored_session_id: storedIdRef.current, + session_id: sessionId + }) } catch (err) { notifyError(err, copy.stopFailed) } @@ -251,7 +254,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses // the primary chat so the two can't diverge. const submitRewind = useCallback( (text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => - runRewindSubmit(requestGateway, runtimeIdRef.current, text, truncateOrdinal, interruptFirst), + runRewindSubmit(requestGateway, runtimeIdRef.current, storedIdRef.current, text, truncateOrdinal, interruptFirst), [requestGateway] ) @@ -274,7 +277,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses try { await requestGateway( 'prompt.submit', - { session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal }, + { + expected_stored_session_id: storedIdRef.current, + session_id: runtimeIdRef.current, + text: plan.text, + truncate_before_user_ordinal: plan.truncateOrdinal + }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS ) } catch (err) { diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx new file mode 100644 index 0000000000000..25ce78828b8ef --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { sessionTileDelegate } from '@/store/session-states' + +import { useSessionTileDelegate } from './use-session-tile-delegate' + +vi.mock('@/hermes', () => ({ + getSessionMessages: vi.fn(async () => ({ messages: [] })), + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000, + setApiRequestProfile: vi.fn() +})) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('useSessionTileDelegate prompt binding', () => { + it('sends the caller-provided durable tile id on direct delegate submits', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + + renderHook(() => + useSessionTileDelegate({ + archiveSession: async () => undefined, + branchStoredSession: async () => undefined, + executeSlashCommand: async () => undefined, + removeSession: async () => undefined, + requestGateway, + runtimeIdByStoredSessionIdRef: { current: new Map() }, + sessionStateByRuntimeIdRef: { current: new Map() }, + updateSessionState: vi.fn() + } as unknown as Parameters[0]) + ) + + const submit = sessionTileDelegate()!.submitToSession as unknown as ( + runtimeId: string, + storedSessionId: string, + text: string + ) => Promise + + await submit('tile-runtime', 'tile-stored', 'delegate prompt') + + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + expected_stored_session_id: 'tile-stored', + session_id: 'tile-runtime', + text: 'delegate prompt' + }, + 1_800_000 + ) + + await sessionTileDelegate()!.interruptSession('tile-runtime', 'tile-stored') + + expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { + expected_stored_session_id: 'tile-stored', + session_id: 'tile-runtime' + }) + }) +}) diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index d7a7e0467272f..da3a450d87ede 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -53,8 +53,11 @@ export function useSessionTileDelegate({ executeSlash: async (rawCommand, sessionId) => { await executeSlashCommand(rawCommand, { sessionId }) }, - interruptSession: async runtimeId => { - await requestGateway('session.interrupt', { session_id: runtimeId }) + interruptSession: async (runtimeId, storedSessionId) => { + await requestGateway('session.interrupt', { + expected_stored_session_id: storedSessionId, + session_id: runtimeId + }) }, resumeTile: async storedSessionId => { const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) @@ -90,8 +93,12 @@ export function useSessionTileDelegate({ return runtimeId }, - submitToSession: async (runtimeId, text) => { - await requestGateway('prompt.submit', { session_id: runtimeId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + submitToSession: async (runtimeId, storedSessionId, text) => { + await requestGateway( + 'prompt.submit', + { expected_stored_session_id: storedSessionId, session_id: runtimeId, text }, + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS + ) }, updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 6bf48adf13070..1e7bad46209f0 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1,3 +1,4 @@ +import type { AppendMessage } from '@assistant-ui/react' import { act, cleanup, render, waitFor } from '@testing-library/react' import type { MutableRefObject } from 'react' import { useEffect, useRef } from 'react' @@ -60,6 +61,8 @@ async function actRender(ui: React.ReactElement) { interface HarnessHandle { activeSessionIdRef: MutableRefObject cancelRun: () => Promise + editMessage: (message: AppendMessage) => Promise + reloadFromMessage: (parentId: string | null) => Promise restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise steerPrompt: (text: string) => Promise submitText: (text: string, options?: SubmitTextOptions) => Promise @@ -158,6 +161,10 @@ function Harness({ activeSessionIdRef, cancelRun: (...args: Parameters) => act(async () => actions.cancelRun(...args)) as Promise, + editMessage: (...args: Parameters) => + act(async () => actions.editMessage(...args)) as Promise, + reloadFromMessage: (...args: Parameters) => + act(async () => actions.reloadFromMessage(...args)) as Promise, restoreToMessage: (...args: Parameters) => act(async () => actions.restoreToMessage(...args)) as Promise, steerPrompt: (...args: Parameters) => @@ -167,6 +174,8 @@ function Harness({ }) }, [ actions.cancelRun, + actions.editMessage, + actions.reloadFromMessage, actions.restoreToMessage, actions.steerPrompt, actions.submitText, @@ -800,6 +809,7 @@ describe('usePromptActions restoreToMessage', () => { refreshSessions={async () => undefined} requestGateway={requestGateway} seedMessages={$messages.get()} + storedSessionId="stored-main-session" /> ) @@ -810,6 +820,7 @@ describe('usePromptActions restoreToMessage', () => { expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', { + expected_stored_session_id: 'stored-main-session', session_id: RUNTIME_SESSION_ID, text: 'first prompt', truncate_before_user_ordinal: 0 @@ -820,6 +831,67 @@ describe('usePromptActions restoreToMessage', () => { expect(lastState.busy).toBe(true) }) + it('binds regenerate to the selected durable session id', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + let handle: HarnessHandle | null = null + + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={$messages.get()} + storedSessionId="stored-main-session" + /> + ) + + await handle!.reloadFromMessage(null) + + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + expected_stored_session_id: 'stored-main-session', + session_id: RUNTIME_SESSION_ID, + text: 'second prompt', + truncate_before_user_ordinal: 1 + }, + 1_800_000 + ) + }) + + it('binds edit rewind to the selected durable session id', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + let handle: HarnessHandle | null = null + + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={$messages.get()} + storedSessionId="stored-main-session" + /> + ) + + await handle!.editMessage({ + parentId: null, + role: 'user', + sourceId: 'u2', + content: [{ type: 'text', text: 'edited second prompt' }] + } as unknown as AppendMessage) + + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + expected_stored_session_id: 'stored-main-session', + session_id: RUNTIME_SESSION_ID, + text: 'edited second prompt', + truncate_before_user_ordinal: 1 + }, + 1_800_000 + ) + }) + it('rethrows gateway failures and clears the busy flags for the dialog to surface', async () => { const requestGateway = vi.fn(async () => { throw new Error('gateway exploded') @@ -872,11 +944,15 @@ describe('usePromptActions restoreToMessage', () => { await handle!.restoreToMessage('u1') - expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID }) + expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { + expected_stored_session_id: RUNTIME_SESSION_ID, + session_id: RUNTIME_SESSION_ID + }) expect(submitAttempts).toBe(2) expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', { + expected_stored_session_id: RUNTIME_SESSION_ID, session_id: RUNTIME_SESSION_ID, text: 'first prompt', truncate_before_user_ordinal: 0 @@ -922,6 +998,7 @@ describe('usePromptActions restoreToMessage', () => { expect(requestGateway).toHaveBeenCalledWith( 'prompt.submit', { + expected_stored_session_id: RUNTIME_SESSION_ID, session_id: RUNTIME_SESSION_ID, text: 'first prompt', truncate_before_user_ordinal: 0 @@ -1267,6 +1344,8 @@ describe('usePromptActions sleep/wake session recovery', () => { it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => { const calls: { method: string; params?: Record }[] = [] let interruptAttempts = 0 + const selectedStoredSessionIdRef: MutableRefObject = { current: STORED_SESSION_ID } + const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } const requestGateway = vi.fn(async (method: string, params?: Record) => { calls.push({ method, params }) @@ -1275,6 +1354,8 @@ describe('usePromptActions sleep/wake session recovery', () => { interruptAttempts += 1 if (interruptAttempts === 1) { + selectedStoredSessionIdRef.current = 'newly-selected-session' + activeSessionIdRef.current = 'newly-selected-runtime' throw new Error('session not found') } @@ -1291,9 +1372,11 @@ describe('usePromptActions sleep/wake session recovery', () => { let handle: HarnessHandle | null = null await actRender( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} storedSessionId={STORED_SESSION_ID} /> ) @@ -1302,9 +1385,16 @@ describe('usePromptActions sleep/wake session recovery', () => { await handle!.cancelRun() expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt']) - expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID }) + expect(calls[0]?.params).toEqual({ + expected_stored_session_id: STORED_SESSION_ID, + session_id: RUNTIME_SESSION_ID + }) expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) - expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) + expect(calls[2]?.params).toEqual({ + expected_stored_session_id: STORED_SESSION_ID, + session_id: RECOVERED_SESSION_ID + }) + expect(activeSessionIdRef.current).toBe('newly-selected-runtime') }) it('clears the active and cached turn clocks when stopping a turn', async () => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1fde9070d4a31..ce50d6d5bbeb3 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -524,6 +524,9 @@ export function usePromptActions({ // The ref is updated via useEffect on every activeSessionId change, so it // always reflects the current session — same pattern submitText uses. const sessionId = activeSessionIdRef.current + // Pin the durable target with the runtime id. A session switch while the + // first interrupt is in flight must not retarget the recovery retry. + const storedSessionId = selectedStoredSessionIdRef.current const releaseBusy = () => { setMutableRef(busyRef, false) @@ -568,23 +571,36 @@ export function usePromptActions({ clearClarifyRequest(undefined, sessionId) try { - await requestGateway('session.interrupt', { session_id: sessionId }) + await requestGateway('session.interrupt', { + expected_stored_session_id: storedSessionId, + session_id: sessionId + }) releaseBusy() } catch (err) { let stopError = err - if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { + if (isSessionNotFoundError(err) && storedSessionId) { try { const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current, + session_id: storedSessionId, source: 'desktop' }) const recoveredId = resumed?.session_id if (recoveredId) { - activeSessionIdRef.current = recoveredId - await requestGateway('session.interrupt', { session_id: recoveredId }) + // Recovery belongs to the entry-pinned pair. Keep stopping it, but + // do not replace a newer foreground selection with its runtime id. + if ( + activeSessionIdRef.current === sessionId && + selectedStoredSessionIdRef.current === storedSessionId + ) { + activeSessionIdRef.current = recoveredId + } + await requestGateway('session.interrupt', { + expected_stored_session_id: storedSessionId, + session_id: recoveredId + }) releaseBusy() return @@ -635,7 +651,9 @@ export function usePromptActions({ const reloadFromMessage = useCallback( async (parentId: string | null) => { - if (!activeSessionId || $busy.get()) { + const storedSessionId = selectedStoredSessionIdRef.current + + if (!activeSessionId || !storedSessionId || $busy.get()) { return } @@ -651,7 +669,12 @@ export function usePromptActions({ try { await requestGateway( 'prompt.submit', - { session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal }, + { + expected_stored_session_id: storedSessionId, + session_id: activeSessionId, + text: plan.text, + truncate_before_user_ordinal: plan.truncateOrdinal + }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS ) } catch (err) { @@ -663,7 +686,7 @@ export function usePromptActions({ notifyError(err, copy.regenerateFailed) } }, - [activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState] + [activeSessionId, copy.regenerateFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState] ) // Cursor-style "restore checkpoint": rewind the conversation to a past user @@ -676,16 +699,17 @@ export function usePromptActions({ // fresh turn. Live/stuck turns interrupt first, and a raced "session busy" // response interrupts + retries through the shared busy gate. const submitRewindPrompt = useCallback( - (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => - runRewindSubmit(requestGateway, sessionId, text, truncateOrdinal, interruptFirst), + (sessionId: string, storedSessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => + runRewindSubmit(requestGateway, sessionId, storedSessionId, text, truncateOrdinal, interruptFirst), [requestGateway] ) const restoreToMessage = useCallback( async (messageId: string, target?: RestoreMessageTarget) => { const sessionId = activeSessionId || activeSessionIdRef.current + const storedSessionId = selectedStoredSessionIdRef.current - if (!sessionId) { + if (!sessionId || !storedSessionId) { throw new Error('No active session to restore.') } @@ -706,7 +730,13 @@ export function usePromptActions({ updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex)) try { - await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get()) + await submitRewindPrompt( + sessionId, + storedSessionId, + plan.text, + plan.truncateOrdinal, + busyRef.current || $busy.get() + ) } catch (err) { // The rewind never landed (e.g. the gateway stayed busy past the retry // deadline). Roll the optimistic truncation back to the full original @@ -724,16 +754,24 @@ export function usePromptActions({ throw err } }, - [activeSessionId, activeSessionIdRef, busyRef, submitRewindPrompt, updateSessionState] + [ + activeSessionId, + activeSessionIdRef, + busyRef, + selectedStoredSessionIdRef, + submitRewindPrompt, + updateSessionState + ] ) const editMessage = useCallback( async (edited: AppendMessage) => { const sessionId = activeSessionId || activeSessionIdRef.current + const storedSessionId = selectedStoredSessionIdRef.current const messages = $messages.get() const plan = sessionId ? planEdit(messages, edited) : null - if (!sessionId || !plan) { + if (!sessionId || !storedSessionId || !plan) { return } @@ -755,14 +793,20 @@ export function usePromptActions({ /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) try { - await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get()) + await submitRewindPrompt( + sessionId, + storedSessionId, + plan.text, + plan.truncateOrdinal, + busyRef.current || $busy.get() + ) } catch (err) { let surfaced = err if (!plan.isFailedTurn && isStaleTargetError(err)) { try { // Already interrupted on the first attempt — submit as a plain resend. - await submitRewindPrompt(sessionId, plan.text, undefined, false) + await submitRewindPrompt(sessionId, storedSessionId, plan.text, undefined, false) return } catch (retryErr) { @@ -780,7 +824,15 @@ export function usePromptActions({ notifyError(surfaced, copy.editFailed) } }, - [activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState] + [ + activeSessionId, + activeSessionIdRef, + busyRef, + copy.editFailed, + selectedStoredSessionIdRef, + submitRewindPrompt, + updateSessionState + ] ) const handleThreadMessagesChange = useCallback( diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts index 3f511f004ebd1..bbb56e3636616 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -35,13 +35,17 @@ type RequestGateway = (method: string, params?: Record { const interrupt = async () => { try { - await requestGateway('session.interrupt', { session_id: sessionId }) + await requestGateway('session.interrupt', { + expected_stored_session_id: storedSessionId, + session_id: sessionId + }) } catch { // Best-effort. The submit path still gates on the gateway state. } @@ -51,6 +55,7 @@ export async function runRewindSubmit( requestGateway( 'prompt.submit', { + expected_stored_session_id: storedSessionId, session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 7041d26b3be79..6d9ac7a9a6dbb 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -431,12 +431,12 @@ export interface SessionTileDelegate { * branch/handoff — act on the main surface, as they should). */ executeSlash(rawCommand: string, sessionId: string): Promise /** Interrupt a tile's running turn. */ - interruptSession(runtimeId: string): Promise + interruptSession(runtimeId: string, storedSessionId: string): Promise /** Bind a live runtime id for a stored session (resume without touching * the main view). Returns the runtime id, or throws. */ resumeTile(storedSessionId: string): Promise /** Submit a prompt to a tile's live session. */ - submitToSession(runtimeId: string, text: string): Promise + submitToSession(runtimeId: string, storedSessionId: string, text: string): Promise /** THE session-state write path — routes through the wiring cache so the * cache, the primary view (when active), and every tile mirror agree. */ updateSession(runtimeId: string, updater: (state: ClientSessionState) => ClientSessionState): ClientSessionState diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 1f20cee6741cd..9e841be6c3179 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -281,6 +281,44 @@ def test_desktop_prompt_submit_rejects_mismatched_durable_target_before_mutation assert mutations == [] +def test_desktop_interrupt_rejects_mismatched_durable_target_before_mutation(): + class _Agent: + def __init__(self): + self.interrupts = 0 + + def interrupt(self): + self.interrupts += 1 + + agent = _Agent() + session = _session(agent=agent, source="desktop", session_key="stored-session-b", running=True) + server._sessions["runtime-session-b"] = session + try: + response = server.handle_request({ + "id": "cross-wired-interrupt", "method": "session.interrupt", + "params": {"session_id": "runtime-session-b", "expected_stored_session_id": "stored-session-a"}, + }) + finally: + server._sessions.pop("runtime-session-b", None) + assert response["error"]["code"] == 4091 + assert agent.interrupts == 0 + assert session["running"] is True + assert session.get("_turn_cancel_requested") is not True + + +def test_legacy_desktop_interrupt_accepts_omitted_durable_target(): + session = _session(source="desktop", session_key="stored-session", running=False) + server._sessions["runtime-session"] = session + try: + response = server.handle_request({ + "id": "legacy-desktop-interrupt", "method": "session.interrupt", + "params": {"session_id": "runtime-session"}, + }) + finally: + server._sessions.pop("runtime-session", None) + assert response["result"]["status"] == "interrupted" + assert session["_turn_cancel_requested"] is True + + def test_legacy_desktop_prompt_submit_accepts_omitted_durable_target(monkeypatch): session = _session(source="desktop", session_key="stored-session", running=True) server._sessions["runtime-session"] = session diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4febb4c164ec4..11b153d1a9cf5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1737,6 +1737,17 @@ def _sess_nowait(params, rid): return (s, None) if s else (None, _err(rid, 4001, "session not found")) +def _validate_expected_stored_session(session: dict, params: dict, rid, *, action: str): + """Expand-compatible durable binding for Desktop session mutations.""" + if "expected_stored_session_id" not in params: + return None + expected_key = str(params.get("expected_stored_session_id") or "").strip() + actual_key = str(session.get("session_key") or "").strip() + if expected_key != actual_key: + return _err(rid, 4091, f"{action} target mismatch; reload the conversation and retry") + return None + + def _sess(params, rid): s, err = _sess_nowait(params, rid) if err: @@ -9276,6 +9287,8 @@ def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) if err: return err + if binding_err := _validate_expected_stored_session(session, params, rid, action="interrupt"): + return binding_err if _session_uses_compute_host(session): sid = str(params.get("session_id") or "") if session.get("running"): @@ -9614,11 +9627,8 @@ def _(rid, params: dict) -> dict: # intended; reject a stale/cross-wired binding before touching transport, # busy queues, history, or persistence (#54527). Omission remains accepted # for expand-compatible rollout with Desktop clients predating this field. - if "expected_stored_session_id" in params: - expected_key = str(params.get("expected_stored_session_id") or "").strip() - actual_key = str(session.get("session_key") or "").strip() - if expected_key != actual_key: - return _err(rid, 4091, "prompt target mismatch; reload the conversation and retry") + if binding_err := _validate_expected_stored_session(session, params, rid, action="prompt"): + return binding_err isolation_cfg = _load_dashboard_process_isolation_config() turn_isolation = _session_uses_compute_host(session, isolation_cfg) # Re-bind to the current client transport for this request. This keeps From 09d26f8cb807ec7df09c76c8034e1bb0ccf18d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B6rje?= Date: Thu, 23 Jul 2026 00:19:52 +0200 Subject: [PATCH 5/5] style(desktop): satisfy prompt action lint --- apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 59b701d78df12..a461570c1f026 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -599,6 +599,7 @@ export function usePromptActions({ ) { activeSessionIdRef.current = recoveredId } + await requestGateway('session.interrupt', { expected_stored_session_id: storedSessionId, session_id: recoveredId