From c903ab46b09d98d005ec1a475f7a5d98d0cc0f96 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 21:23:48 +0800 Subject: [PATCH 01/26] fix(desktop): bind side conversation events to host admission Generated-by: Codex --- .../quote-companion-disposal.test.ts | 8 +- .../__tests__/quote-companion-retry.test.ts | 244 ++++++++++++++++++ ...me-host-session-execution-ipc-main.test.ts | 1 + ...runtime-host-session-execution-ipc-main.ts | 1 + apps/desktop/src/preload/bridge-contract.d.ts | 13 +- .../src/renderer/features/workbar/ports.ts | 3 +- .../tools/side-chat/quote-companion-core.ts | 10 +- .../tools/side-chat/use-quote-companion.ts | 98 +++++-- 8 files changed, 350 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts index 8553509e2d..262600e919 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts @@ -121,7 +121,7 @@ describe('quote companion disposal fencing', () => { ...defaults.sideChat, send: async () => { sends += 1; - return { ok: true as const }; + return { ok: true as const, turnId: 'side-chat-turn' }; }, }; @@ -140,7 +140,7 @@ describe('quote companion disposal fencing', () => { }); it('does not consume quotes or report success when disposal wins the send race', async () => { - const pendingSend = deferred<{ ok: true }>(); + const pendingSend = deferred<{ ok: true; turnId: string }>(); let disposed = false; let consumed = 0; const defaults = createFakeWorkbarServices(); @@ -158,7 +158,7 @@ describe('quote companion disposal fencing', () => { ); disposed = true; - pendingSend.resolve({ ok: true }); + pendingSend.resolve({ ok: true, turnId: 'side-chat-turn' }); assert.deepEqual(await turn, { status: 'disposed' }); assert.equal(consumed, 0); @@ -179,7 +179,7 @@ describe('quote companion disposal fencing', () => { }, send: async () => { sends += 1; - return { ok: true as const }; + return { ok: true as const, turnId: 'side-chat-turn' }; }, }; const turn = performCompanionTurn( diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 4070741470..75d27e0e21 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -22,6 +22,7 @@ import { afterEach, test } from 'node:test'; import { parseHTML } from 'linkedom'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import type { SessionEvent } from '@maka/core/events'; import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; import { createFakeWorkbarServices, @@ -44,6 +45,14 @@ const originalGlobals = { let mountedRoot: Root | undefined; const SOURCE_SESSION = session('source-session'); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + afterEach(async () => { if (mountedRoot) { await act(async () => { @@ -212,6 +221,221 @@ test('does not restart foreground setup when the source Session object refreshes assert.equal(probe.getAttribute('data-preparing'), 'false'); }); +test('keeps Side Conversation events owned by the Host-admitted turn across an admission race', async () => { + const parsed = parseHTML('
'); + const { document, window } = parsed; + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + let eventHandler: ((event: SessionEvent) => void) | undefined; + let send: ((text: string) => Promise) | undefined; + const pendingSend = deferred<{ ok: true; turnId: string }>(); + const defaults = createFakeWorkbarServices(); + const services: WorkbarServices = { + ...defaults, + sideChat: { + ...defaults.sideChat, + listTurns: async () => [settledTurn('source-turn')], + branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + subscribeEvents: (_sessionId, handler) => { + eventHandler = handler; + return () => undefined; + }, + send: async () => pendingSend.promise, + }, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + await act(async () => { + root.render( + createElement(WorkbarServicesProvider, { + services, + children: createElement(QuoteCompanionOwnershipProbe, { + onSend: (value) => { + send = value; + }, + }), + }), + ); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation'); + assert.ok(send); + assert.ok(eventHandler); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = send?.('new prompt'); + await Promise.resolve(); + }); + + await act(async () => { + eventHandler?.({ + type: 'complete', + id: 'late-old-terminal', + turnId: 'old-turn', + ts: 1, + stopReason: 'end_turn', + }); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + + await act(async () => { + eventHandler?.({ + type: 'text_delta', + id: 'new-text-before-response', + messageId: 'assistant-message', + turnId: 'host-admitted-turn', + ts: 2, + text: 'answer', + }); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + + await act(async () => { + pendingSend.resolve({ ok: true, turnId: 'host-admitted-turn' }); + assert.equal(await sendResult, true); + await Promise.resolve(); + }); + + const probe = container.firstElementChild; + assert.ok(probe); + assert.equal(probe.getAttribute('data-live-turn-id'), 'host-admitted-turn'); + assert.equal(probe.getAttribute('data-live-text'), 'answer'); + assert.equal(probe.getAttribute('data-streaming'), 'true'); + assert.equal(probe.getAttribute('data-processing'), 'false'); +}); + +test('binds a busy-raced Side Conversation send through its Host-admitted message identity', async () => { + const parsed = parseHTML('
'); + const { document, window } = parsed; + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + let eventHandler: ((event: SessionEvent) => void) | undefined; + let send: ((text: string) => Promise) | undefined; + const pendingSend = deferred<{ ok: true; steered: true; turnId: string; messageId: string }>(); + const defaults = createFakeWorkbarServices(); + const services: WorkbarServices = { + ...defaults, + sideChat: { + ...defaults.sideChat, + listTurns: async () => [settledTurn('source-turn')], + branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + subscribeEvents: (_sessionId, handler) => { + eventHandler = handler; + return () => undefined; + }, + send: async () => pendingSend.promise, + }, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + await act(async () => { + root.render( + createElement(WorkbarServicesProvider, { + services, + children: createElement(QuoteCompanionOwnershipProbe, { + onSend: (value) => { + send = value; + }, + }), + }), + ); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation'); + assert.ok(send); + assert.ok(eventHandler); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = send?.('steer the active turn'); + await Promise.resolve(); + }); + await act(async () => { + eventHandler?.({ + type: 'complete', + id: 'late-old-terminal', + turnId: 'old-turn', + ts: 1, + stopReason: 'end_turn', + }); + eventHandler?.({ + type: 'queue_update', + id: 'accepted-queue', + turnId: 'host-active-turn', + ts: 2, + queueRevision: 1, + steering: ['steer the active turn'], + followup: [], + steeringEntries: [ + { + entryId: 'accepted-entry', + messageId: 'accepted-message', + content: { text: 'steer the active turn' }, + placement: 'current_turn', + state: 'queued', + }, + ], + followupEntries: [], + }); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + + await act(async () => { + pendingSend.resolve({ + ok: true, + steered: true, + turnId: 'requested-turn-is-not-the-owner', + messageId: 'accepted-message', + }); + assert.equal(await sendResult, true); + await Promise.resolve(); + }); + await act(async () => { + eventHandler?.({ + type: 'text_delta', + id: 'accepted-text', + messageId: 'assistant-message', + turnId: 'host-active-turn', + ts: 3, + text: 'answer after steering', + }); + await Promise.resolve(); + }); + + const probe = container.firstElementChild; + assert.ok(probe); + assert.equal(probe.getAttribute('data-live-turn-id'), 'host-active-turn'); + assert.equal(probe.getAttribute('data-live-text'), 'answer after steering'); + assert.equal(probe.getAttribute('data-streaming'), 'true'); + assert.equal(probe.getAttribute('data-processing'), 'false'); +}); + function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) { const companion = useQuoteCompanion({ panelId: 'retry-panel', @@ -227,6 +451,26 @@ function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) { }, companion.error); } +function QuoteCompanionOwnershipProbe(props: { + onSend: (send: (text: string) => Promise) => void; +}) { + const companion = useQuoteCompanion({ + panelId: 'ownership-panel', + pendingQuotes: [], + sourceSession: SOURCE_SESSION, + locale: 'en', + onQuotesConsumed: () => undefined, + }); + props.onSend(companion.send); + return createElement('div', { + 'data-companion-id': companion.companionSession?.id ?? '', + 'data-live-turn-id': companion.liveTurn?.turnId ?? '', + 'data-live-text': companion.liveTurn?.steps.find((step) => step.text)?.text?.text ?? '', + 'data-streaming': String(companion.streaming), + 'data-processing': String(companion.processing), + }); +} + function session(id: string): SessionSummary { return { id, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 4514ea326b..fedb24ad1c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -618,6 +618,7 @@ test("queues a mid-turn send as steering when the Host reports the session busy" ok: true, steered: true, turnId: "turn-1", + messageId: "id-1", attachments: [], inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] }, diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index bd961f7fdd..88b27c744f 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -325,6 +325,7 @@ export function registerRuntimeHostSessionExecutionIpc( ok: true as const, steered: true as const, turnId, + messageId, attachments, inlineReferences, skillInvocation: emptySkillInvocation, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d478543721..13343644b0 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -743,7 +743,18 @@ export interface MakaBridge { * The send raced a root Turn another client opened first and was * queued into it as steering instead of starting `turnId`. */ - steered?: true; + steered?: never; + messageId?: never; + attachments: import('@maka/core/events').AttachmentRef[]; + inlineReferences: import('@maka/core/events').InlineReference[]; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } + | { + ok: true; + turnId: string; + steered: true; + /** Host admission identity for the message queued as steering. */ + messageId: string; attachments: import('@maka/core/events').AttachmentRef[]; inlineReferences: import('@maka/core/events').InlineReference[]; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index fb14676742..00bbbfd50f 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -197,7 +197,8 @@ export interface WorkbarAttachmentsService { } export type SideChatSendResult = - | { ok: true } + | { ok: true; turnId: string; steered?: false } + | { ok: true; turnId: string; steered: true; messageId: string } | { ok: false; reason?: string }; export interface SideChatSessionPort { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index dceb846301..4b107e39de 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -31,6 +31,7 @@ import type { SessionSummary, TurnRecord } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { SideChatSessionPort, + SideChatSendResult, WorkbarIngestInput, } from '../../ports.js'; import { @@ -283,7 +284,8 @@ export async function ensureCompanionFork( } export type CompanionTurnResult = - | { status: 'sent'; forkId: string } + | { status: 'sent'; forkId: string; turnId: string; steered?: false } + | { status: 'sent'; forkId: string; turnId: string; steered: true; messageId: string } | { status: 'disposed' } | { status: 'error'; code: CompanionErrorCode }; @@ -335,7 +337,7 @@ export async function performCompanionTurn( if (createdForkId) scheduleCompanionCleanup(deps, createdForkId); return { status: 'disposed' }; } - let result: { ok: true } | { ok: false; reason?: string }; + let result: SideChatSendResult; try { result = await deps.api.send(forkId, { type: 'send', @@ -356,7 +358,9 @@ export async function performCompanionTurn( return { status: 'error', code: 'send_rejected' }; } deps.onQuotesConsumed(); - return { status: 'sent', forkId }; + return result.steered + ? { status: 'sent', forkId, turnId: result.turnId, steered: true, messageId: result.messageId } + : { status: 'sent', forkId, turnId: result.turnId }; } export function isCompanionTurnTerminal(event: SessionEvent): boolean { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 7c4bcb5be8..e76573fc00 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -159,6 +159,10 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const forkSetupPromiseRef = useRef | null>(null); const stopRequestedRef = useRef(false); const activeTurnIdRef = useRef(null); + const pendingAdmissionRef = useRef<{ + messageId: string | null; + events: SessionEvent[]; + } | null>(null); const turnInFlightRef = useRef(false); const settlingTurnIdsRef = useRef>(new Set()); const onForkVisibilityChangeRef = useRef(onForkVisibilityChange); @@ -192,21 +196,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const mountedRef = useMountedRef(); const dismissalGuardRef = useRef(createCompanionDismissalGuard()); - // Subscribe to the fork's event stream + load its transcript. Called - // synchronously the moment the fork is committed, BEFORE the run starts, so - // no boundary request / complete can be missed (the stream has no replay). - const subscribeToFork = useCallback((forkId: string) => { - void sideChat.readSettledMessages(forkId) - .then(({ messages }) => { - if (mountedRef.current) { - setAllMessages((current) => mergeSettledMessages(current, messages)); - } - }) - .catch(() => { - if (mountedRef.current) setError(copyRef.current.errors.settlementFailed); - }); - unsubscribeRef.current = sideChat.subscribeEvents(forkId, (event: SessionEvent) => { - if (!mountedRef.current) return; + const applyOwnedEvent = useCallback( + (forkId: string, event: SessionEvent) => { const effect = companionRunEventEffect( event, activeTurnIdRef.current, @@ -253,8 +244,60 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan settlingTurnIdsRef.current.delete(settledTurnId); }); } + }, + [mountedRef, sideChat], + ); + + const bindAdmittedTurn = useCallback( + (forkId: string, turnId: string) => { + const admission = pendingAdmissionRef.current; + if (!admission) return; + pendingAdmissionRef.current = null; + activeTurnIdRef.current = turnId; + ownTurnIdsRef.current.add(turnId); + setOwnTurnTick((tick) => tick + 1); + setLiveTurn(armLiveTurn(turnId)); + for (const event of admission.events) { + if (event.turnId === turnId) applyOwnedEvent(forkId, event); + } + }, + [applyOwnedEvent], + ); + + const eventAdmitsMessage = useCallback( + (event: SessionEvent, messageId: string): boolean => + (event.type === 'steering_message' && event.messageId === messageId) || + (event.type === 'queue_update' && + event.steeringEntries?.some((entry) => entry.messageId === messageId) === true), + [], + ); + + // Subscribe to the fork's event stream + load its transcript. Called + // synchronously the moment the fork is committed, BEFORE the run starts, so + // no boundary request / complete can be missed (the stream has no replay). + const subscribeToFork = useCallback((forkId: string) => { + void sideChat.readSettledMessages(forkId) + .then(({ messages }) => { + if (mountedRef.current) { + setAllMessages((current) => mergeSettledMessages(current, messages)); + } + }) + .catch(() => { + if (mountedRef.current) setError(copyRef.current.errors.settlementFailed); + }); + unsubscribeRef.current = sideChat.subscribeEvents(forkId, (event: SessionEvent) => { + if (!mountedRef.current) return; + const admission = pendingAdmissionRef.current; + if (admission) { + admission.events.push(event); + if (admission.messageId && eventAdmitsMessage(event, admission.messageId)) { + bindAdmittedTurn(forkId, event.turnId); + } + return; + } + applyOwnedEvent(forkId, event); }); - }, [mountedRef, sideChat]); + }, [applyOwnedEvent, bindAdmittedTurn, eventAdmitsMessage, mountedRef, sideChat]); const commitFork = useCallback( (session: SessionSummary) => { @@ -442,16 +485,30 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // Arm the optimistic live turn right before the send. onBeforeSend: () => { stopRequestedRef.current = false; - activeTurnIdRef.current = turnId; + activeTurnIdRef.current = null; + pendingAdmissionRef.current = { + messageId: null, + events: [], + }; turnInFlightRef.current = true; setTurnInFlight(true); setLiveTurn(armLiveTurn(turnId)); - ownTurnIdsRef.current.add(turnId); - setOwnTurnTick((tick) => tick + 1); }, onQuotesConsumed: () => onQuotesConsumed(quoteSnapshot), }); if (result.status === 'sent') { + const admission = pendingAdmissionRef.current; + if (turnInFlightRef.current && admission) { + if (result.steered) { + admission.messageId = result.messageId; + const admitted = admission.events.find((event) => + eventAdmitsMessage(event, result.messageId), + ); + if (admitted) bindAdmittedTurn(result.forkId, admitted.turnId); + } else { + bindAdmittedTurn(result.forkId, result.turnId); + } + } setHasContent(true); // Surface the just-sent user message immediately, and reflect any // automatic connection/model rebound in the read-only model label. @@ -485,6 +542,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }; setError(byCode[result.code]); activeTurnIdRef.current = null; + pendingAdmissionRef.current = null; turnInFlightRef.current = false; setTurnInFlight(false); setLiveTurn(undefined); @@ -501,6 +559,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ensureFork, mountedRef, sideChat, + bindAdmittedTurn, + eventAdmitsMessage, ], ); From e385ebe1f2f43bacc3bf798fc892f53b2e4ab734 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 22:07:29 +0800 Subject: [PATCH 02/26] fix(desktop): update side chat story send result Generated-by: Codex --- apps/desktop/stories/session-workbar.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 600fd6ebfa..3e09eb8301 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -709,7 +709,7 @@ function bridge(options: { branchFromTurn: async () => ({ ok: true, session: SIDE_CHAT_SESSION }), cleanupSessionCopy: async () => undefined, abandonSessionCopy: async () => undefined, - send: async () => ({ ok: true }), + send: async () => ({ ok: true, turnId: 'story-side-chat-turn' }), stop: async () => undefined, steer: async () => ({ kind: 'queued' }), setPermissionMode: async (_sessionId, mode) => ({ From 387de6d46b5380a648448a322e5adba3ae858e94 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 23:02:59 +0800 Subject: [PATCH 03/26] fix(desktop): preserve side conversation admission identity Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 160 +++++++++++++++- ...me-host-session-execution-ipc-main.test.ts | 179 +++++++++++++++++- ...runtime-host-session-execution-ipc-main.ts | 69 +++++-- apps/desktop/src/preload/bridge-contract.d.ts | 6 +- apps/desktop/src/preload/preload.ts | 6 +- .../src/renderer/features/workbar/ports.ts | 8 +- .../src/renderer/features/workbar/testing.ts | 5 +- .../tools/side-chat/use-quote-companion.ts | 132 +++++++++++-- .../desktop/create-workbar-services.ts | 4 +- .../stories/session-workbar.stories.tsx | 2 +- 10 files changed, 520 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 75d27e0e21..2664571b1e 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -244,8 +244,9 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a ...defaults.sideChat, listTurns: async () => [settledTurn('source-turn')], branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), - subscribeEvents: (_sessionId, handler) => { + subscribeEvents: (_sessionId, handler, onSeeded) => { eventHandler = handler; + onSeeded?.(); return () => undefined; }, send: async () => pendingSend.promise, @@ -341,8 +342,9 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag ...defaults.sideChat, listTurns: async () => [settledTurn('source-turn')], branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), - subscribeEvents: (_sessionId, handler) => { + subscribeEvents: (_sessionId, handler, onSeeded) => { eventHandler = handler; + onSeeded?.(); return () => undefined; }, send: async () => pendingSend.promise, @@ -405,6 +407,10 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + assert.notEqual( + container.firstElementChild?.getAttribute('data-live-turn-id'), + 'host-active-turn', + ); await act(async () => { pendingSend.resolve({ @@ -416,7 +422,19 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag assert.equal(await sendResult, true); await Promise.resolve(); }); + assert.notEqual( + container.firstElementChild?.getAttribute('data-live-turn-id'), + 'host-active-turn', + ); await act(async () => { + eventHandler?.({ + type: 'steering_message', + id: 'accepted-steering-message', + messageId: 'accepted-message', + turnId: 'host-active-turn', + ts: 2.5, + content: { text: 'steer the active turn' }, + }); eventHandler?.({ type: 'text_delta', id: 'accepted-text', @@ -436,6 +454,144 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag assert.equal(probe.getAttribute('data-processing'), 'false'); }); +test('waits for Side Conversation observation readiness before sending', async () => { + const parsed = parseHTML('
'); + const { document, window } = parsed; + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + let send: ((text: string) => Promise) | undefined; + let sendCalls = 0; + let markSeeded: (() => void) | undefined; + const defaults = createFakeWorkbarServices(); + const services: WorkbarServices = { + ...defaults, + sideChat: { + ...defaults.sideChat, + listTurns: async () => [settledTurn('source-turn')], + branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + subscribeEvents: (_sessionId, _handler, onSeeded) => { + markSeeded = onSeeded; + return () => undefined; + }, + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'seeded-turn' }; + }, + }, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + await act(async () => { + root.render( + createElement(WorkbarServicesProvider, { + services, + children: createElement(QuoteCompanionOwnershipProbe, { + onSend: (value) => { + send = value; + }, + }), + }), + ); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation'); + assert.ok(send); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = send?.('wait for the observer'); + await Promise.resolve(); + }); + assert.equal(sendCalls, 0); + + await act(async () => { + markSeeded?.(); + await Promise.resolve(); + }); + await waitUntil(() => sendCalls === 1); + assert.equal(await sendResult, true); +}); + +test('releases a send waiting for observation when the Side Conversation is disposed', async () => { + const parsed = parseHTML('
'); + const { document, window } = parsed; + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + let send: ((text: string) => Promise) | undefined; + let sendCalls = 0; + let unsubscribed = false; + const defaults = createFakeWorkbarServices(); + const services: WorkbarServices = { + ...defaults, + sideChat: { + ...defaults.sideChat, + listTurns: async () => [settledTurn('source-turn')], + branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + subscribeEvents: () => () => { + unsubscribed = true; + }, + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'disposed-turn' }; + }, + }, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + await act(async () => { + root.render( + createElement(WorkbarServicesProvider, { + services, + children: createElement(QuoteCompanionOwnershipProbe, { + onSend: (value) => { + send = value; + }, + }), + }), + ); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation'); + assert.ok(send); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = send?.('dispose while observing'); + await Promise.resolve(); + }); + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + + assert.equal(await sendResult, false); + assert.equal(sendCalls, 0); + assert.equal(unsubscribed, true); + mountedRoot = undefined; +}); + function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) { const companion = useQuoteCompanion({ panelId: 'retry-panel', diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index fedb24ad1c..f9c21e1d44 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -30,7 +30,10 @@ import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionCatalogProjection, } from "@maka/runtime-host/protocol"; -import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; import { createAttachmentApprovalRegistry } from "../attachment-approval.js"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; import { @@ -628,6 +631,179 @@ test("queues a mid-turn send as steering when the Host reports the session busy" ]); }); +test("retries a dispatched normal send with its original Turn identity", async () => { + const starts: unknown[] = []; + let reconnectQueries = 0; + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => { + reconnectQueries += 1; + return session(); + }, + startTurn: async (input) => { + starts.push(input); + if (starts.length === 1) { + throw new RuntimeHostRequestInterruptedError( + "turn.start", + "command", + "dispatched", + "connection_lost", + ); + } + return { + kind: "started", + turn: { + sessionId: input.sessionId, + turnId: input.turnId, + runId: "run-1", + status: "running", + }, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + newId: () => "turn-1", + }, + ipc, + ); + + const result = await ipc.invoke("sessions:send", "session-1", { + type: "send", + text: "keep this Turn identity", + }); + + assert.equal(reconnectQueries, 2, 'initial Session lookup plus reconnect probe'); + assert.deepEqual(starts, [ + { + sessionId: "session-1", + turnId: "turn-1", + content: { text: "keep this Turn identity", inlineReferences: [] }, + }, + { + sessionId: "session-1", + turnId: "turn-1", + content: { text: "keep this Turn identity", inlineReferences: [] }, + }, + ]); + assert.deepEqual(result, { + ok: true, + turnId: "turn-1", + attachments: [], + inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }); +}); + +test("retries a dispatched busy fallback with its original message identity", async () => { + const submits: unknown[] = []; + let reconnectQueries = 0; + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => { + reconnectQueries += 1; + return session(); + }, + startTurn: async () => { + throw new RuntimeHostOperationError( + "turn.start", + "session_busy", + "Session already has an active root Turn", + ); + }, + submitMessage: async (input) => { + submits.push(input); + if (submits.length === 1) { + throw new RuntimeHostRequestInterruptedError( + "turn.message.submit", + "command", + "dispatched", + "connection_lost", + ); + } + return { disposition: "steering", queueRevision: 1 }; + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + newId: () => "id-1", + }, + ipc, + ); + + const result = await ipc.invoke("sessions:send", "session-1", { + type: "send", + turnId: "turn-1", + text: "keep this message identity", + }); + + assert.equal(reconnectQueries, 2, 'initial Session lookup plus reconnect probe'); + assert.deepEqual(submits, [ + { + sessionId: "session-1", + messageId: "id-1", + content: { text: "keep this message identity", inlineReferences: [] }, + placement: "current_turn", + }, + { + sessionId: "session-1", + messageId: "id-1", + content: { text: "keep this message identity", inlineReferences: [] }, + placement: "current_turn", + }, + ]); + assert.deepEqual(result, { + ok: true, + steered: true, + turnId: "turn-1", + messageId: "id-1", + attachments: [], + inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }); +}); + +test("returns the Host-started Turn identity when a direct steer races idle", async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => session(), + submitMessage: async () => ({ + disposition: "turn_started", + turnId: "host-started-turn", + }), + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + newId: () => "steer-message-id", + }, + ipc, + ); + + assert.deepEqual(await ipc.invoke("sessions:steer", "session-1", "continue now"), { + kind: "started", + turnId: "host-started-turn", + }); +}); + test("starts the turn from the queued message when the busy race resolves idle", async () => { const changes: unknown[] = []; const submits: unknown[] = []; @@ -967,6 +1143,7 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn await ipc.invoke("sessions:steer", "session-1", " Continue "), { kind: "queued", + messageId: "id-1", }, ); await ipc.invoke("sessions:stop", "session-1", { diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 88b27c744f..c0e9efec9d 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -20,7 +20,10 @@ import { randomUUID } from "node:crypto"; import type { IpcMainInvokeEvent } from "electron"; import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; -import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { type SessionChangedEvent, @@ -63,6 +66,24 @@ type SideConversationBranchResult = | { readonly ok: true; readonly session: ReturnType } | { readonly ok: false; readonly reason: 'session_busy' | 'operation_unavailable' }; +async function retryDispatchedCommand( + command: () => Promise, + waitForReconnect: () => Promise, +): Promise { + try { + return await command(); + } catch (error) { + if ( + !(error instanceof RuntimeHostRequestInterruptedError) || + error.dispatch !== 'dispatched' + ) { + throw error; + } + await waitForReconnect(); + return command(); + } +} + type RuntimeHostSessionExecutionClient = Pick< DesktopRuntimeHostClient, | "answerInteraction" @@ -276,7 +297,10 @@ export function registerRuntimeHostSessionExecutionIpc( }; let startResult; try { - startResult = await deps.client.startTurn(startInput); + startResult = await retryDispatchedCommand( + () => deps.client.startTurn(startInput), + () => deps.client.getSession(sessionId), + ); } catch (error) { // The renderer routes text at a session it sees as running to // `sessions:steer`, but its view can lag the Host: another window, a @@ -297,14 +321,18 @@ export function registerRuntimeHostSessionExecutionIpc( ) { throw error; } - const submitted = await deps.client.submitMessage({ - sessionId, - // Preserve the renderer's command identity in the durable message so - // a lost IPC reply can be reconciled as root-vs-steering later. - messageId: turnId, - content: startInput.content, - placement: "current_turn", - }); + const submitted = await retryDispatchedCommand( + () => + deps.client.submitMessage({ + sessionId, + // Preserve the renderer's command identity in the durable message so + // a lost IPC reply can be reconciled as root-vs-steering later. + messageId: turnId, + content: startInput.content, + placement: "current_turn", + }), + () => deps.client.getSession(sessionId), + ); const emptySkillInvocation = { loaded: [], failed: [], receipts: [] }; if (submitted.disposition === "turn_started") { deps.emitSessionsChanged("status-change", sessionId, { @@ -354,13 +382,20 @@ export function registerRuntimeHostSessionExecutionIpc( "sessions:steer", async (_event, sessionId: string, text: unknown) => { const content = steeringContent(text); - await deps.client.submitMessage({ - sessionId, - messageId: newId(), - content: { text: content }, - placement: "current_turn", - }); - return { kind: "queued" as const }; + const messageId = newId(); + const submitted = await retryDispatchedCommand( + () => + deps.client.submitMessage({ + sessionId, + messageId, + content: { text: content }, + placement: "current_turn", + }), + () => deps.client.getSession(sessionId), + ); + return submitted.disposition === "turn_started" + ? { kind: "started" as const, turnId: submitted.turnId } + : { kind: "queued" as const, messageId }; }, ); ipcMain.handle( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 13343644b0..5f091a2b3d 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -48,7 +48,6 @@ import type { SessionCommand, SessionEvent, ShellRunUpdate, - QueueEnqueueOutcome, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; @@ -769,7 +768,10 @@ export interface MakaBridge { sessionId: string, input?: { source?: 'stop_button'; expectedTurnId?: string }, ): Promise; - steer(sessionId: string, text: string): Promise; + steer( + sessionId: string, + text: string, + ): Promise<{ kind: 'queued'; messageId: string } | { kind: 'started'; turnId: string }>; enqueue( sessionId: string, placement: 'current_turn' | 'next_turn', diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 656472bf20..5be0e7a4c1 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -116,7 +116,6 @@ import type { SessionCommand, SessionEvent, ShellRunUpdate, - QueueEnqueueOutcome, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; @@ -1593,7 +1592,10 @@ const makaBridge = { ): Promise { return invokeSessionRuntimeHost('sessions:stop', sessionId, input); }, - steer(sessionId: string, text: string): Promise { + steer( + sessionId: string, + text: string, + ): Promise<{ kind: 'queued'; messageId: string } | { kind: 'started'; turnId: string }> { return invokeSessionRuntimeHost('sessions:steer', sessionId, text); }, async enqueue( diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 00bbbfd50f..122cd19c91 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -18,7 +18,6 @@ */ import type { - QueueEnqueueOutcome, QuoteRef, SessionEvent, ShellRunUpdate, @@ -201,6 +200,10 @@ export type SideChatSendResult = | { ok: true; turnId: string; steered: true; messageId: string } | { ok: false; reason?: string }; +export type SideChatSteerResult = + | { kind: 'queued'; messageId: string } + | { kind: 'started'; turnId: string }; + export interface SideChatSessionPort { listSessions(): Promise; listTurns(sessionId: string): Promise; @@ -233,7 +236,7 @@ export interface SideChatSessionPort { }, ): Promise; stop(sessionId: string): Promise; - steer(sessionId: string, text: string): Promise; + steer(sessionId: string, text: string): Promise; setPermissionMode( sessionId: string, mode: PermissionMode, @@ -250,6 +253,7 @@ export interface SideChatSessionPort { subscribeEvents( sessionId: string, handler: (event: SessionEvent) => void, + onSeeded?: () => void, ): WorkbarUnsubscribe; subscribeSessionChanges(handler: (event: SessionChangedEvent) => void): WorkbarUnsubscribe; } diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index f2e3e940b5..da788175e0 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -136,7 +136,10 @@ export function createFakeWorkbarServices( regenerateTurn: async () => undefined, respondToSandboxBoundary: async () => undefined, respondToUserQuestion: async () => undefined, - subscribeEvents: noopSubscription, + subscribeEvents: (_sessionId, _handler, onSeeded) => { + onSeeded?.(); + return noopSubscription(); + }, subscribeSessionChanges: noopSubscription, }, ...overrides, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index e76573fc00..3e3ae43f40 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -162,7 +162,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const pendingAdmissionRef = useRef<{ messageId: string | null; events: SessionEvent[]; + kind?: 'steer'; + restoreTurnId?: string | null; + cancelled?: boolean; } | null>(null); + const subscriptionReadyRef = useRef>(Promise.resolve()); const turnInFlightRef = useRef(false); const settlingTurnIdsRef = useRef>(new Set()); const onForkVisibilityChangeRef = useRef(onForkVisibilityChange); @@ -249,14 +253,20 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ); const bindAdmittedTurn = useCallback( - (forkId: string, turnId: string) => { + ( + forkId: string, + turnId: string, + options: { readonly preserveLiveTurn?: boolean } = {}, + ) => { const admission = pendingAdmissionRef.current; if (!admission) return; pendingAdmissionRef.current = null; activeTurnIdRef.current = turnId; ownTurnIdsRef.current.add(turnId); setOwnTurnTick((tick) => tick + 1); - setLiveTurn(armLiveTurn(turnId)); + if (!(options.preserveLiveTurn && liveTurnRef.current?.turnId === turnId)) { + setLiveTurn(armLiveTurn(turnId)); + } for (const event of admission.events) { if (event.turnId === turnId) applyOwnedEvent(forkId, event); } @@ -268,14 +278,25 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan (event: SessionEvent, messageId: string): boolean => (event.type === 'steering_message' && event.messageId === messageId) || (event.type === 'queue_update' && - event.steeringEntries?.some((entry) => entry.messageId === messageId) === true), + event.steeringEntries?.some( + (entry) => entry.messageId === messageId && entry.state === 'in_flight', + ) === true), [], ); // Subscribe to the fork's event stream + load its transcript. Called // synchronously the moment the fork is committed, BEFORE the run starts, so // no boundary request / complete can be missed (the stream has no replay). - const subscribeToFork = useCallback((forkId: string) => { + const subscribeToFork = useCallback((forkId: string): Promise => { + let resolveReady!: () => void; + let readySettled = false; + const ready = new Promise((resolve) => { + resolveReady = () => { + if (readySettled) return; + readySettled = true; + resolve(); + }; + }); void sideChat.readSettledMessages(forkId) .then(({ messages }) => { if (mountedRef.current) { @@ -285,18 +306,32 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan .catch(() => { if (mountedRef.current) setError(copyRef.current.errors.settlementFailed); }); - unsubscribeRef.current = sideChat.subscribeEvents(forkId, (event: SessionEvent) => { - if (!mountedRef.current) return; - const admission = pendingAdmissionRef.current; - if (admission) { - admission.events.push(event); - if (admission.messageId && eventAdmitsMessage(event, admission.messageId)) { - bindAdmittedTurn(forkId, event.turnId); + const unsubscribe = sideChat.subscribeEvents( + forkId, + (event: SessionEvent) => { + if (!mountedRef.current) return; + const admission = pendingAdmissionRef.current; + if (admission) { + admission.events.push(event); + if (admission.messageId && eventAdmitsMessage(event, admission.messageId)) { + bindAdmittedTurn(forkId, event.turnId, { preserveLiveTurn: true }); + } + return; } - return; - } - applyOwnedEvent(forkId, event); - }); + applyOwnedEvent(forkId, event); + }, + resolveReady, + ); + let disposed = false; + unsubscribeRef.current = () => { + if (disposed) return; + disposed = true; + unsubscribe(); + // A send waiting for observation readiness must finish when the panel is + // disposed; its mounted check below then turns this into a clean no-op. + resolveReady(); + }; + return ready; }, [applyOwnedEvent, bindAdmittedTurn, eventAdmitsMessage, mountedRef, sideChat]); const commitFork = useCallback( @@ -305,7 +340,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan companionIdRef.current = session.id; companionRef.current = session; setCompanion(session); - subscribeToFork(session.id); + subscriptionReadyRef.current = subscribeToFork(session.id); }, [subscribeToFork], ); @@ -464,6 +499,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan turnInFlightRef.current = false; return false; } + await subscriptionReadyRef.current; + if (!mountedRef.current) { + turnInFlightRef.current = false; + return false; + } const result = await performCompanionTurn({ api: sideChat, sourceSession, @@ -568,29 +608,79 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const id = companionIdRef.current; if (!id) return; stopRequestedRef.current = true; + const admission = pendingAdmissionRef.current; try { await sideChat.stop(id); + if (admission?.kind === 'steer' && pendingAdmissionRef.current === admission) { + admission.cancelled = true; + pendingAdmissionRef.current = null; + activeTurnIdRef.current = admission.restoreTurnId ?? null; + for (const event of admission.events) applyOwnedEvent(id, event); + } } catch { stopRequestedRef.current = false; // best-effort; the terminal event still reconciles state } - }, [sideChat]); + }, [applyOwnedEvent, sideChat]); const steer = useCallback(async (text: string): Promise => { const id = companionIdRef.current; const trimmed = text.trim(); - if (!mountedRef.current || !id || !trimmed || !turnInFlight) return false; + if ( + !mountedRef.current || + !id || + !trimmed || + !turnInFlight || + pendingAdmissionRef.current + ) { + return false; + } + const previousTurnId = activeTurnIdRef.current; + const admission: { + messageId: string | null; + events: SessionEvent[]; + kind: 'steer'; + restoreTurnId: string | null; + cancelled: boolean; + } = { + messageId: null, + events: [], + kind: 'steer', + restoreTurnId: previousTurnId, + cancelled: false, + }; + pendingAdmissionRef.current = admission; + activeTurnIdRef.current = null; try { const outcome = await sideChat.steer(id, trimmed); if (!mountedRef.current) return false; - if (outcome.kind !== 'queued') return false; + if (admission.cancelled) return false; + if (outcome.kind === 'started') { + bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); + } else { + admission.messageId = outcome.messageId; + const admitted = admission.events.find((event) => + eventAdmitsMessage(event, outcome.messageId), + ); + if (admitted) { + bindAdmittedTurn(id, admitted.turnId, { preserveLiveTurn: true }); + } + } setError(null); return true; } catch { - if (mountedRef.current) setError(copyRef.current.errors.sendFailed); + if (mountedRef.current) { + if (pendingAdmissionRef.current === admission) { + pendingAdmissionRef.current = null; + activeTurnIdRef.current = previousTurnId; + for (const event of admission.events) applyOwnedEvent(id, event); + } + if (admission.cancelled) return false; + setError(copyRef.current.errors.sendFailed); + } return false; } - }, [mountedRef, sideChat, turnInFlight]); + }, [applyOwnedEvent, bindAdmittedTurn, eventAdmitsMessage, mountedRef, sideChat, turnInFlight]); const setPermissionMode = useCallback( async (mode: PermissionMode): Promise => { diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 86c9d0ef00..60ba648b46 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -130,8 +130,8 @@ export function createDesktopWorkbarServices( bridge.sessions.respondToSandboxBoundary(sessionId, response), respondToUserQuestion: (sessionId, response) => bridge.sessions.respondToUserQuestion(sessionId, response), - subscribeEvents: (sessionId, handler) => - bridge.sessions.subscribeEvents(sessionId, handler), + subscribeEvents: (sessionId, handler, onSeeded) => + bridge.sessions.subscribeEvents(sessionId, handler, onSeeded), subscribeSessionChanges: (handler) => bridge.sessions.subscribeChanges(handler), }, }; diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 3e09eb8301..0c479f94a7 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -711,7 +711,7 @@ function bridge(options: { abandonSessionCopy: async () => undefined, send: async () => ({ ok: true, turnId: 'story-side-chat-turn' }), stop: async () => undefined, - steer: async () => ({ kind: 'queued' }), + steer: async () => ({ kind: 'queued', messageId: 'story-steer-message' }), setPermissionMode: async (_sessionId, mode) => ({ ...SIDE_CHAT_SESSION, permissionMode: mode, From 14ebd8ef765a7201531688692b91db95e7b04b86 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 00:17:12 +0800 Subject: [PATCH 04/26] fix(desktop): close side conversation admission races Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 685 +++++++++++------- .../main/__tests__/seed-completion.test.ts | 40 - apps/desktop/src/preload/bridge-contract.d.ts | 1 + apps/desktop/src/preload/preload.ts | 29 +- apps/desktop/src/preload/seed-completion.ts | 34 - .../src/renderer/features/workbar/ports.ts | 1 + .../tools/side-chat/use-quote-companion.ts | 223 ++++-- .../desktop/create-workbar-services.ts | 4 +- .../stories/session-workbar.stories.tsx | 7 +- 9 files changed, 601 insertions(+), 423 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/seed-completion.test.ts delete mode 100644 apps/desktop/src/preload/seed-completion.ts diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 2664571b1e..699be67a71 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -53,18 +53,54 @@ function deferred() { return { promise, resolve }; } -afterEach(async () => { - if (mountedRoot) { - await act(async () => { - mountedRoot?.unmount(); - await Promise.resolve(); - }); - } - mountedRoot = undefined; - Object.assign(globalThis, originalGlobals); -}); +type QueueUpdate = Extract; +type QueueEntry = NonNullable[number]; -test('retries a busy Side Conversation at the newest settled boundary and clears its banner', async () => { +function completeEvent(id: string, turnId: string, ts: number): SessionEvent { + return { type: 'complete', id, turnId, ts, stopReason: 'end_turn' }; +} + +function textDeltaEvent(id: string, turnId: string, ts: number, text: string): SessionEvent { + return { type: 'text_delta', id, messageId: 'assistant-message', turnId, ts, text }; +} + +function queueUpdateEvent( + id: string, + turnId: string, + ts: number, + steeringEntries: readonly QueueEntry[] = [], + followupEntries: readonly QueueEntry[] = [], +): QueueUpdate { + return { + type: 'queue_update', + id, + turnId, + ts, + queueRevision: 1, + steering: steeringEntries.map((entry) => entry.content.text), + followup: followupEntries.map((entry) => entry.content.text), + steeringEntries: [...steeringEntries], + followupEntries: [...followupEntries], + }; +} + +function steeringMessageEvent(id: string, turnId: string, ts: number, messageId: string): SessionEvent { + return { type: 'steering_message', id, messageId, turnId, ts, content: { text: 'steer the active turn' } }; +} + +function recoverableErrorEvent(id: string, turnId: string, ts: number): SessionEvent { + return { + type: 'error', + id, + turnId, + ts, + recoverable: true, + reason: 'connection_closed', + message: 'connection closed', + }; +} + +function installDom() { const parsed = parseHTML('
'); const { document, window } = parsed; Object.assign(globalThis, { @@ -76,16 +112,73 @@ test('retries a busy Side Conversation at the newest settled boundary and clears Node: window.Node, IS_REACT_ACT_ENVIRONMENT: true, }); + const container = document.querySelector('#root'); + assert.ok(container); + return container; +} - let listCount = 0; - let sessionChange: ((event: SessionChangedEvent) => void) | undefined; - let releaseRetry: (() => void) | undefined; - const branchInputs: Array<{ sourceTurnId: string; copyId: string }> = []; +async function renderProbe( + sideChat: Partial, + options: { + ownership?: boolean; + sourceSession?: SessionSummary; + ready?: (container: Element) => boolean; + onSend?: (send: (text: string) => Promise) => void; + onSteer?: (steer: (text: string) => Promise) => void; + onStop?: (stop: () => Promise) => void; + } = {}, +) { + const container = installDom(); const defaults = createFakeWorkbarServices(); const services: WorkbarServices = { ...defaults, sideChat: { ...defaults.sideChat, + listTurns: async () => [settledTurn('source-turn')], + branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + ...sideChat, + }, + }; + const root = createRoot(container); + mountedRoot = root; + const children = options.ownership + ? createElement(QuoteCompanionOwnershipProbe, { + onSend: options.onSend ?? (() => undefined), + onSteer: options.onSteer, + onStop: options.onStop, + }) + : createElement(QuoteCompanionProbe, { sourceSession: options.sourceSession }); + + await act(async () => { + root.render(createElement(WorkbarServicesProvider, { services, children })); + await Promise.resolve(); + }); + await waitUntil( + () => + options.ready?.(container) ?? + container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation', + ); + return { container, root, services }; +} + +afterEach(async () => { + if (mountedRoot) { + await act(async () => { + mountedRoot?.unmount(); + await Promise.resolve(); + }); + } + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('retries a busy Side Conversation at the newest settled boundary and clears its banner', async () => { + let listCount = 0; + let sessionChange: ((event: SessionChangedEvent) => void) | undefined; + let releaseRetry: (() => void) | undefined; + const branchInputs: Array<{ sourceTurnId: string; copyId: string }> = []; + const { container } = await renderProbe( + { listTurns: async () => { listCount += 1; return listCount === 1 @@ -109,22 +202,8 @@ test('retries a busy Side Conversation at the newest settled boundary and clears }; }, }, - }; - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - mountedRoot = root; - - await act(async () => { - root.render( - createElement(WorkbarServicesProvider, { - services, - children: createElement(QuoteCompanionProbe), - }), - ); - await Promise.resolve(); - }); - await waitUntil(() => branchInputs.length === 1 && sessionChange !== undefined); + { ready: () => branchInputs.length === 1 && sessionChange !== undefined }, + ); assert.match(container.textContent, /main conversation or a linked task is still running/i); const probe = container.firstElementChild; assert.ok(probe); @@ -161,24 +240,9 @@ test('retries a busy Side Conversation at the newest settled boundary and clears }); test('does not restart foreground setup when the source Session object refreshes', async () => { - const parsed = parseHTML('
'); - const { document, window } = parsed; - Object.assign(globalThis, { - document, - window, - HTMLElement: window.HTMLElement, - HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, - Event: window.Event, - Node: window.Node, - IS_REACT_ACT_ENVIRONMENT: true, - }); - let branchCount = 0; - const defaults = createFakeWorkbarServices(); - const services: WorkbarServices = { - ...defaults, - sideChat: { - ...defaults.sideChat, + const { container, root, services } = await renderProbe( + { listTurns: async () => [settledTurn('settled-turn')], branchFromTurn: async () => { branchCount += 1; @@ -188,24 +252,8 @@ test('does not restart foreground setup when the source Session object refreshes return await new Promise(() => undefined); }, }, - }; - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - mountedRoot = root; - - const render = (sourceSession: SessionSummary) => - root.render( - createElement(WorkbarServicesProvider, { - services, - children: createElement(QuoteCompanionProbe, { sourceSession }), - }), - ); - - await act(async () => { - render(session('source-session')); - await Promise.resolve(); - }); + { sourceSession: session('source-session'), ready: () => branchCount === 1 }, + ); const probe = container.firstElementChild; assert.ok(probe); await waitUntil( @@ -213,7 +261,14 @@ test('does not restart foreground setup when the source Session object refreshes ); await act(async () => { - render(session('source-session')); + root.render( + createElement(WorkbarServicesProvider, { + services, + children: createElement(QuoteCompanionProbe, { + sourceSession: session('source-session'), + }), + }), + ); await Promise.resolve(); }); @@ -222,28 +277,11 @@ test('does not restart foreground setup when the source Session object refreshes }); test('keeps Side Conversation events owned by the Host-admitted turn across an admission race', async () => { - const parsed = parseHTML('
'); - const { document, window } = parsed; - Object.assign(globalThis, { - document, - window, - HTMLElement: window.HTMLElement, - HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, - Event: window.Event, - Node: window.Node, - IS_REACT_ACT_ENVIRONMENT: true, - }); - let eventHandler: ((event: SessionEvent) => void) | undefined; let send: ((text: string) => Promise) | undefined; const pendingSend = deferred<{ ok: true; turnId: string }>(); - const defaults = createFakeWorkbarServices(); - const services: WorkbarServices = { - ...defaults, - sideChat: { - ...defaults.sideChat, - listTurns: async () => [settledTurn('source-turn')], - branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + const { container } = await renderProbe( + { subscribeEvents: (_sessionId, handler, onSeeded) => { eventHandler = handler; onSeeded?.(); @@ -251,26 +289,8 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a }, send: async () => pendingSend.promise, }, - }; - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - mountedRoot = root; - - await act(async () => { - root.render( - createElement(WorkbarServicesProvider, { - services, - children: createElement(QuoteCompanionOwnershipProbe, { - onSend: (value) => { - send = value; - }, - }), - }), - ); - await Promise.resolve(); - }); - await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation'); + { ownership: true, onSend: (value) => (send = value) }, + ); assert.ok(send); assert.ok(eventHandler); @@ -281,26 +301,13 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a }); await act(async () => { - eventHandler?.({ - type: 'complete', - id: 'late-old-terminal', - turnId: 'old-turn', - ts: 1, - stopReason: 'end_turn', - }); + eventHandler?.(completeEvent('late-old-terminal', 'old-turn', 1)); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { - eventHandler?.({ - type: 'text_delta', - id: 'new-text-before-response', - messageId: 'assistant-message', - turnId: 'host-admitted-turn', - ts: 2, - text: 'answer', - }); + eventHandler?.(textDeltaEvent('new-text-before-response', 'host-admitted-turn', 2, 'answer')); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); @@ -320,28 +327,11 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a }); test('binds a busy-raced Side Conversation send through its Host-admitted message identity', async () => { - const parsed = parseHTML('
'); - const { document, window } = parsed; - Object.assign(globalThis, { - document, - window, - HTMLElement: window.HTMLElement, - HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, - Event: window.Event, - Node: window.Node, - IS_REACT_ACT_ENVIRONMENT: true, - }); - let eventHandler: ((event: SessionEvent) => void) | undefined; let send: ((text: string) => Promise) | undefined; const pendingSend = deferred<{ ok: true; steered: true; turnId: string; messageId: string }>(); - const defaults = createFakeWorkbarServices(); - const services: WorkbarServices = { - ...defaults, - sideChat: { - ...defaults.sideChat, - listTurns: async () => [settledTurn('source-turn')], - branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + const { container } = await renderProbe( + { subscribeEvents: (_sessionId, handler, onSeeded) => { eventHandler = handler; onSeeded?.(); @@ -349,26 +339,8 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag }, send: async () => pendingSend.promise, }, - }; - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - mountedRoot = root; - - await act(async () => { - root.render( - createElement(WorkbarServicesProvider, { - services, - children: createElement(QuoteCompanionOwnershipProbe, { - onSend: (value) => { - send = value; - }, - }), - }), - ); - await Promise.resolve(); - }); - await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation'); + { ownership: true, onSend: (value) => (send = value) }, + ); assert.ok(send); assert.ok(eventHandler); @@ -378,22 +350,9 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag await Promise.resolve(); }); await act(async () => { - eventHandler?.({ - type: 'complete', - id: 'late-old-terminal', - turnId: 'old-turn', - ts: 1, - stopReason: 'end_turn', - }); - eventHandler?.({ - type: 'queue_update', - id: 'accepted-queue', - turnId: 'host-active-turn', - ts: 2, - queueRevision: 1, - steering: ['steer the active turn'], - followup: [], - steeringEntries: [ + eventHandler?.(completeEvent('late-old-terminal', 'old-turn', 1)); + eventHandler?.( + queueUpdateEvent('accepted-queue', 'host-active-turn', 2, [ { entryId: 'accepted-entry', messageId: 'accepted-message', @@ -401,9 +360,8 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag placement: 'current_turn', state: 'queued', }, - ], - followupEntries: [], - }); + ]), + ); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); @@ -427,22 +385,10 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag 'host-active-turn', ); await act(async () => { - eventHandler?.({ - type: 'steering_message', - id: 'accepted-steering-message', - messageId: 'accepted-message', - turnId: 'host-active-turn', - ts: 2.5, - content: { text: 'steer the active turn' }, - }); - eventHandler?.({ - type: 'text_delta', - id: 'accepted-text', - messageId: 'assistant-message', - turnId: 'host-active-turn', - ts: 3, - text: 'answer after steering', - }); + eventHandler?.( + steeringMessageEvent('accepted-steering-message', 'host-active-turn', 2.5, 'accepted-message'), + ); + eventHandler?.(textDeltaEvent('accepted-text', 'host-active-turn', 3, 'answer after steering')); await Promise.resolve(); }); @@ -454,98 +400,298 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag assert.equal(probe.getAttribute('data-processing'), 'false'); }); -test('waits for Side Conversation observation readiness before sending', async () => { - const parsed = parseHTML('
'); - const { document, window } = parsed; - Object.assign(globalThis, { - document, - window, - HTMLElement: window.HTMLElement, - HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, - Event: window.Event, - Node: window.Node, - IS_REACT_ACT_ENVIRONMENT: true, +test('clears a queued Side Conversation send when Host stop cancels the admission', async () => { + let send: ((text: string) => Promise) | undefined; + let stop: (() => Promise) | undefined; + const pendingStop = deferred(); + const pendingSend = deferred<{ + ok: true; + steered: true; + turnId: string; + messageId: string; + }>(); + const { container } = await renderProbe( + { + subscribeEvents: (_sessionId, _handler, onSeeded) => { + onSeeded?.(); + return () => undefined; + }, + send: async () => pendingSend.promise, + stop: async () => pendingStop.promise, + }, + { + ownership: true, + onSend: (value) => (send = value), + onStop: (value) => (stop = value), + }, + ); + assert.ok(send); + assert.ok(stop); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = send?.('stop this queued send'); + await Promise.resolve(); + }); + let stopResult: Promise | undefined; + await act(async () => { + stopResult = stop?.(); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); + + await act(async () => { + pendingStop.resolve(); + await stopResult; + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); + + await act(async () => { + pendingSend.resolve({ + ok: true, + steered: true, + turnId: 'old-turn', + messageId: 'retracted-message', + }); + assert.equal(await sendResult, false); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); + assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), ''); +}); + +test('releases a queued Side Conversation admission from the Host queue retract', async () => { + let eventHandler: ((event: SessionEvent) => void) | undefined; + let send: ((text: string) => Promise) | undefined; + const { container } = await renderProbe( + { + subscribeEvents: (_sessionId, handler, onSeeded) => { + eventHandler = handler; + onSeeded?.(); + return () => undefined; + }, + send: async () => ({ + ok: true as const, + steered: true as const, + turnId: 'not-the-owner', + messageId: 'retracted-message', + }), + }, + { + ownership: true, + onSend: (value) => (send = value), + }, + ); + assert.ok(send); + assert.ok(eventHandler); + + await act(async () => { + assert.equal(await send?.('retract this queued send'), true); + await Promise.resolve(); }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + await act(async () => { + eventHandler?.(queueUpdateEvent('retract-queue', 'old-turn', 1)); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); +}); + +test('fails and re-observes a Side Conversation after a recoverable Host subscription error', async () => { let send: ((text: string) => Promise) | undefined; + const handlers: Array<(event: SessionEvent) => void> = []; + let subscriptionCount = 0; let sendCalls = 0; - let markSeeded: (() => void) | undefined; - const defaults = createFakeWorkbarServices(); - const services: WorkbarServices = { - ...defaults, - sideChat: { - ...defaults.sideChat, - listTurns: async () => [settledTurn('source-turn')], - branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), - subscribeEvents: (_sessionId, _handler, onSeeded) => { - markSeeded = onSeeded; + const pendingSend = deferred<{ ok: true; turnId: string }>(); + const pendingRetry = deferred<{ ok: true; turnId: string }>(); + const { container } = await renderProbe( + { + subscribeEvents: (_sessionId, handler, onSeeded) => { + subscriptionCount += 1; + handlers.push(handler); + onSeeded?.(); return () => undefined; }, send: async () => { sendCalls += 1; - return { ok: true as const, turnId: 'seeded-turn' }; + return sendCalls === 1 + ? pendingSend.promise + : pendingRetry.promise; }, }, - }; - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - mountedRoot = root; + { ownership: true, onSend: (value) => (send = value) }, + ); + assert.ok(send); + assert.equal(subscriptionCount, 1); + let failedResult: Promise | undefined; await act(async () => { - root.render( - createElement(WorkbarServicesProvider, { - services, - children: createElement(QuoteCompanionOwnershipProbe, { - onSend: (value) => { - send = value; - }, - }), - }), - ); + failedResult = send?.('fail with a recoverable stream error'); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); + await act(async () => { + handlers[0]?.(recoverableErrorEvent('recoverable-subscription-error', 'old-turn', 1)); await Promise.resolve(); }); - await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation'); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); + assert.notEqual(container.firstElementChild?.getAttribute('data-error'), ''); + + let reobserveResult: Promise | undefined; + await act(async () => { + reobserveResult = send?.('re-observe before retrying'); + assert.equal(await reobserveResult, false); + await Promise.resolve(); + }); + assert.equal(subscriptionCount, 2); + + let retryResult: Promise | undefined; + await act(async () => { + retryResult = send?.('retry after re-observing'); + await Promise.resolve(); + }); + assert.equal(sendCalls, 2); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + + await act(async () => { + pendingSend.resolve({ ok: true, turnId: 'late-turn' }); + assert.equal(await failedResult, false); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + + await act(async () => { + pendingRetry.resolve({ ok: true, turnId: 'retry-after-resubscribe' }); + assert.equal(await retryResult, true); + await Promise.resolve(); + }); + await act(async () => { + handlers[1]?.(completeEvent('retry-complete', 'retry-after-resubscribe', 2)); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'false'); + + await act(async () => { + handlers[1]?.(recoverableErrorEvent('idle-recoverable-error', 'retry-after-resubscribe', 3)); + await Promise.resolve(); + }); + await act(async () => { + assert.equal(await send?.('re-observe while idle'), false); + await Promise.resolve(); + }); + assert.equal(subscriptionCount, 3); +}); + +test('cancels a pending Side Conversation steer after Host stop without losing the old Turn', async () => { + let send: ((text: string) => Promise) | undefined; + let steer: ((text: string) => Promise) | undefined; + let stop: (() => Promise) | undefined; + const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + let stopCalls = 0; + const { container } = await renderProbe( + { + subscribeEvents: (_sessionId, _handler, onSeeded) => { + onSeeded?.(); + return () => undefined; + }, + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async () => pendingSteer.promise, + stop: async () => { + stopCalls += 1; + }, + }, + { + ownership: true, + onSend: (value) => (send = value), + onSteer: (value) => (steer = value), + onStop: (value) => (stop = value), + }, + ); assert.ok(send); - let sendResult: Promise | undefined; await act(async () => { - sendResult = send?.('wait for the observer'); + assert.equal(await send?.('initial prompt'), true); + await Promise.resolve(); + }); + assert.ok(steer); + assert.ok(stop); + + let steerResult: Promise | undefined; + await act(async () => { + steerResult = steer?.('cancel this steer'); await Promise.resolve(); }); + let stopResult: Promise | undefined; + await act(async () => { + stopResult = stop?.(); + await stopResult; + await Promise.resolve(); + }); + assert.equal(stopCalls, 1); + assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'old-turn'); + + await act(async () => { + pendingSteer.resolve({ kind: 'queued', messageId: 'cancelled-steer' }); + assert.equal(await steerResult, false); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'old-turn'); +}); + +test('fails a send when observation seed rejects and resubscribes for retry', async () => { + let send: ((text: string) => Promise) | undefined; + let sendCalls = 0; + let subscriptionCount = 0; + let rejectSeed: ((error: unknown) => void) | undefined; + let markSeeded: (() => void) | undefined; + const { container } = await renderProbe( + { + subscribeEvents: (_sessionId, _handler, onSeeded, onSeedError) => { + subscriptionCount += 1; + if (subscriptionCount === 1) rejectSeed = onSeedError; + else markSeeded = onSeeded; + return () => undefined; + }, + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'retry-turn' }; + }, + }, + { ownership: true, onSend: (value) => (send = value) }, + ); + assert.ok(send); + assert.ok(rejectSeed); + + let failedResult: Promise | undefined; + await act(async () => { + failedResult = send?.('observer failure'); + rejectSeed?.(new Error('observer failed')); + assert.equal(await failedResult, false); + }); assert.equal(sendCalls, 0); + assert.equal(subscriptionCount, 2); + assert.ok(markSeeded); await act(async () => { markSeeded?.(); await Promise.resolve(); }); - await waitUntil(() => sendCalls === 1); - assert.equal(await sendResult, true); + let retryResult: Promise | undefined; + await act(async () => { + retryResult = send?.('retry after observer failure'); + assert.equal(await retryResult, true); + }); + assert.equal(sendCalls, 1); }); test('releases a send waiting for observation when the Side Conversation is disposed', async () => { - const parsed = parseHTML('
'); - const { document, window } = parsed; - Object.assign(globalThis, { - document, - window, - HTMLElement: window.HTMLElement, - HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, - Event: window.Event, - Node: window.Node, - IS_REACT_ACT_ENVIRONMENT: true, - }); - let send: ((text: string) => Promise) | undefined; let sendCalls = 0; let unsubscribed = false; - const defaults = createFakeWorkbarServices(); - const services: WorkbarServices = { - ...defaults, - sideChat: { - ...defaults.sideChat, - listTurns: async () => [settledTurn('source-turn')], - branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + const { container, root } = await renderProbe( + { subscribeEvents: () => () => { unsubscribed = true; }, @@ -554,26 +700,8 @@ test('releases a send waiting for observation when the Side Conversation is disp return { ok: true as const, turnId: 'disposed-turn' }; }, }, - }; - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - mountedRoot = root; - - await act(async () => { - root.render( - createElement(WorkbarServicesProvider, { - services, - children: createElement(QuoteCompanionOwnershipProbe, { - onSend: (value) => { - send = value; - }, - }), - }), - ); - await Promise.resolve(); - }); - await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation'); + { ownership: true, onSend: (value) => (send = value) }, + ); assert.ok(send); let sendResult: Promise | undefined; @@ -609,6 +737,8 @@ function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) { function QuoteCompanionOwnershipProbe(props: { onSend: (send: (text: string) => Promise) => void; + onSteer?: (steer: (text: string) => Promise) => void; + onStop?: (stop: () => Promise) => void; }) { const companion = useQuoteCompanion({ panelId: 'ownership-panel', @@ -618,8 +748,11 @@ function QuoteCompanionOwnershipProbe(props: { onQuotesConsumed: () => undefined, }); props.onSend(companion.send); + props.onSteer?.(companion.steer); + props.onStop?.(companion.stop); return createElement('div', { 'data-companion-id': companion.companionSession?.id ?? '', + 'data-error': companion.error ?? '', 'data-live-turn-id': companion.liveTurn?.turnId ?? '', 'data-live-text': companion.liveTurn?.steps.find((step) => step.text)?.text?.text ?? '', 'data-streaming': String(companion.streaming), diff --git a/apps/desktop/src/main/__tests__/seed-completion.test.ts b/apps/desktop/src/main/__tests__/seed-completion.test.ts deleted file mode 100644 index 37482395a8..0000000000 --- a/apps/desktop/src/main/__tests__/seed-completion.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { notifyWhenSeeded } from '../../preload/seed-completion.js'; - -test('does not notify after the subscription is disposed', async () => { - let resolveSeed!: () => void; - const seed = new Promise((resolve) => { - resolveSeed = resolve; - }); - let notifications = 0; - - const dispose = notifyWhenSeeded(seed, () => { - notifications++; - }); - dispose(); - resolveSeed(); - await seed; - await Promise.resolve(); - - assert.equal(notifications, 0); -}); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 5f091a2b3d..61f64ec9e7 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -838,6 +838,7 @@ export interface MakaBridge { handler: (event: SessionEvent) => void, onSeeded?: () => void, onObservationSeed?: (phase: 'pending' | 'ready') => void, + onSeedError?: (error: unknown) => void, ): () => void; subscribeChanges(handler: (event: SessionChangedEvent) => void): () => void; archive(sessionId: string, options?: { revisionFamily?: boolean }): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5be0e7a4c1..0f85224f20 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -20,7 +20,6 @@ import { contextBridge, ipcRenderer } from 'electron'; import { encodeIngestItems } from './attachment-ingest-payload.js'; import { collectThreadSearchResponses } from './multi-host-thread-search.js'; -import { notifyWhenSeeded } from './seed-completion.js'; import { releaseSessionObservation } from './session-observation-release.js'; import type { MakaBridge, @@ -1731,6 +1730,7 @@ const makaBridge = { handler: (event: SessionEvent) => void, onSeeded?: () => void, onObservationSeed?: (phase: 'pending' | 'ready') => void, + onSeedError?: (error: unknown) => void, ): () => void { const observerId = crypto.randomUUID(); let disposed = false; @@ -1738,15 +1738,20 @@ const makaBridge = { let unsubscribeObservationSeed = () => {}; const observeDispatch = runtimeHostSessionRef(sessionId).then((session) => { if (disposed) return { completion: Promise.resolve() }; - unsubscribeEvents = subscribeRuntimeHostEvent( + // Keep the renderer listener across Host target epochs. The observer + // registry restores this observer on the replacement target, while + // the dynamic subscription accepts the replacement scope. + unsubscribeEvents = subscribeEveryRuntimeHostEvent( `sessions:event:${session.sessionId}`, - session.scope, - (event: SessionEvent) => handler(projectDesktopSessionEvent(session.scope, event)), + (scope, event: SessionEvent) => { + if (scope.hostId !== session.scope.hostId) return; + handler(projectDesktopSessionEvent(scope, event)); + }, ); - unsubscribeObservationSeed = subscribeRuntimeHostEvent( + unsubscribeObservationSeed = subscribeEveryRuntimeHostEvent( 'sessions:observation-seed', - session.scope, - (payload: { sessionId?: string; phase?: string }) => { + (scope, payload: { sessionId?: string; phase?: string }) => { + if (scope.hostId !== session.scope.hostId) return; if (payload.sessionId !== session.sessionId) return; if (payload.phase === 'pending' || payload.phase === 'ready') { onObservationSeed?.(payload.phase); @@ -1763,10 +1768,16 @@ const makaBridge = { }; }); const observing = observeDispatch.then(({ completion }) => completion); - const disposeSeedNotification = notifyWhenSeeded(observing, onSeeded); + void observing.then( + () => { + if (!disposed) onSeeded?.(); + }, + (error: unknown) => { + if (!disposed) onSeedError?.(error); + }, + ); return () => { disposed = true; - disposeSeedNotification(); unsubscribeObservationSeed(); unsubscribeEvents(); void releaseSessionObservation(observeDispatch, () => diff --git a/apps/desktop/src/preload/seed-completion.ts b/apps/desktop/src/preload/seed-completion.ts deleted file mode 100644 index 464abe4b7b..0000000000 --- a/apps/desktop/src/preload/seed-completion.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export function notifyWhenSeeded( - seed: Promise, - notify: (() => void) | undefined, -): () => void { - let disposed = false; - void seed - .then(() => { - if (!disposed) notify?.(); - }) - .catch(() => undefined); - - return () => { - disposed = true; - }; -} diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 122cd19c91..b1a2727e5e 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -254,6 +254,7 @@ export interface SideChatSessionPort { sessionId: string, handler: (event: SessionEvent) => void, onSeeded?: () => void, + onSeedError?: (error: unknown) => void, ): WorkbarUnsubscribe; subscribeSessionChanges(handler: (event: SessionChangedEvent) => void): WorkbarUnsubscribe; } diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 3e3ae43f40..b58f093ea2 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -61,6 +61,29 @@ import { } from './quote-companion-panel-state.js'; import type { CompanionForkVisibilityEvent } from './quote-companion-visibility.js'; +type PendingAdmission = { + messageId?: string; + events: SessionEvent[]; + restoreTurnId: string | null; + restoreLiveTurn: LiveTurnProjection | undefined; + cancelled: boolean; + stopPromise?: Promise; +}; + +function admissionEventForMessage( + events: readonly SessionEvent[], + messageId: string, +): SessionEvent | undefined { + return events.find( + (event) => + (event.type === 'steering_message' && event.messageId === messageId) || + (event.type === 'queue_update' && + event.steeringEntries?.some( + (entry) => entry.messageId === messageId && entry.state === 'in_flight', + ) === true), + ); +} + export interface UseQuoteCompanionInput { /** Stable owner for the currently mounted panel generation. */ panelId: string; @@ -159,13 +182,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const forkSetupPromiseRef = useRef | null>(null); const stopRequestedRef = useRef(false); const activeTurnIdRef = useRef(null); - const pendingAdmissionRef = useRef<{ - messageId: string | null; - events: SessionEvent[]; - kind?: 'steer'; - restoreTurnId?: string | null; - cancelled?: boolean; - } | null>(null); + const pendingAdmissionRef = useRef(null); const subscriptionReadyRef = useRef>(Promise.resolve()); const turnInFlightRef = useRef(false); const settlingTurnIdsRef = useRef>(new Set()); @@ -274,14 +291,23 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan [applyOwnedEvent], ); - const eventAdmitsMessage = useCallback( - (event: SessionEvent, messageId: string): boolean => - (event.type === 'steering_message' && event.messageId === messageId) || - (event.type === 'queue_update' && - event.steeringEntries?.some( - (entry) => entry.messageId === messageId && entry.state === 'in_flight', - ) === true), - [], + const abandonAdmission = useCallback( + (forkId: string, admission: PendingAdmission, message?: string) => { + if (pendingAdmissionRef.current !== admission) return; + admission.cancelled = true; + pendingAdmissionRef.current = null; + activeTurnIdRef.current = admission.restoreTurnId; + setLiveTurn(admission.restoreLiveTurn); + turnInFlightRef.current = false; + setTurnInFlight(false); + if (message) setError(message); + if (admission.restoreTurnId) { + for (const event of admission.events) { + if (event.turnId === admission.restoreTurnId) applyOwnedEvent(forkId, event); + } + } + }, + [applyOwnedEvent], ); // Subscribe to the fork's event stream + load its transcript. Called @@ -289,14 +315,23 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // no boundary request / complete can be missed (the stream has no replay). const subscribeToFork = useCallback((forkId: string): Promise => { let resolveReady!: () => void; + let rejectReady!: (error: unknown) => void; let readySettled = false; - const ready = new Promise((resolve) => { + const ready = new Promise((resolve, reject) => { resolveReady = () => { if (readySettled) return; readySettled = true; resolve(); }; + rejectReady = (error: unknown) => { + if (readySettled) return; + readySettled = true; + reject(error); + }; }); + // A subscription can fail before the first send. Keep that failure + // observable to a later send without creating an unhandled rejection now. + void ready.catch(() => undefined); void sideChat.readSettledMessages(forkId) .then(({ messages }) => { if (mountedRef.current) { @@ -311,16 +346,40 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan (event: SessionEvent) => { if (!mountedRef.current) return; const admission = pendingAdmissionRef.current; + if (event.type === 'error' && event.recoverable) { + if (admission) { + abandonAdmission(forkId, admission, copyRef.current.errors.sendFailed); + } else { + setError(copyRef.current.errors.sendFailed); + } + const retry = Promise.reject(new Error(event.message)); + void retry.catch(() => undefined); + subscriptionReadyRef.current = retry; + return; + } if (admission) { admission.events.push(event); - if (admission.messageId && eventAdmitsMessage(event, admission.messageId)) { - bindAdmittedTurn(forkId, event.turnId, { preserveLiveTurn: true }); + if (admission.messageId) { + const admitted = admissionEventForMessage(admission.events, admission.messageId); + if (admitted && !admission.cancelled) { + bindAdmittedTurn(forkId, admitted.turnId, { preserveLiveTurn: true }); + } else if ( + event.type === 'queue_update' && + event.steeringEntries && + event.followupEntries && + ![...event.steeringEntries, ...event.followupEntries].some( + (entry) => entry.messageId === admission.messageId, + ) + ) { + abandonAdmission(forkId, admission); + } } return; } applyOwnedEvent(forkId, event); }, resolveReady, + rejectReady, ); let disposed = false; unsubscribeRef.current = () => { @@ -332,7 +391,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan resolveReady(); }; return ready; - }, [applyOwnedEvent, bindAdmittedTurn, eventAdmitsMessage, mountedRef, sideChat]); + }, [abandonAdmission, applyOwnedEvent, bindAdmittedTurn, mountedRef, sideChat]); const commitFork = useCallback( (session: SessionSummary) => { @@ -499,11 +558,22 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan turnInFlightRef.current = false; return false; } - await subscriptionReadyRef.current; + try { + await subscriptionReadyRef.current; + } catch { + if (mountedRef.current) { + unsubscribeRef.current?.(); + subscriptionReadyRef.current = subscribeToFork(fork.session.id); + setError(copyRef.current.errors.sendFailed); + } + turnInFlightRef.current = false; + return false; + } if (!mountedRef.current) { turnInFlightRef.current = false; return false; } + let sendAdmission: PendingAdmission | undefined; const result = await performCompanionTurn({ api: sideChat, sourceSession, @@ -525,11 +595,17 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // Arm the optimistic live turn right before the send. onBeforeSend: () => { stopRequestedRef.current = false; - activeTurnIdRef.current = null; - pendingAdmissionRef.current = { - messageId: null, + const restoreTurnId = activeTurnIdRef.current; + const restoreLiveTurn = liveTurnRef.current; + const admission: PendingAdmission = { events: [], + restoreTurnId, + restoreLiveTurn, + cancelled: false, }; + sendAdmission = admission; + activeTurnIdRef.current = null; + pendingAdmissionRef.current = admission; turnInFlightRef.current = true; setTurnInFlight(true); setLiveTurn(armLiveTurn(turnId)); @@ -537,14 +613,28 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan onQuotesConsumed: () => onQuotesConsumed(quoteSnapshot), }); if (result.status === 'sent') { - const admission = pendingAdmissionRef.current; - if (turnInFlightRef.current && admission) { + const admission = sendAdmission; + if (!admission || (admission.cancelled && pendingAdmissionRef.current !== admission)) { + if (!pendingAdmissionRef.current) { + turnInFlightRef.current = false; + setTurnInFlight(false); + } + return false; + } + if (admission.cancelled) { + await admission.stopPromise; + if (admission.cancelled || pendingAdmissionRef.current !== admission) { + abandonAdmission(result.forkId, admission); + return false; + } + } + if (admission) { if (result.steered) { admission.messageId = result.messageId; - const admitted = admission.events.find((event) => - eventAdmitsMessage(event, result.messageId), - ); - if (admitted) bindAdmittedTurn(result.forkId, admitted.turnId); + const admitted = admissionEventForMessage(admission.events, result.messageId); + if (admitted && !admission.cancelled) { + bindAdmittedTurn(result.forkId, admitted.turnId); + } } else { bindAdmittedTurn(result.forkId, result.turnId); } @@ -599,29 +689,47 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ensureFork, mountedRef, sideChat, + abandonAdmission, bindAdmittedTurn, - eventAdmitsMessage, ], ); const stop = useCallback(async (): Promise => { const id = companionIdRef.current; - if (!id) return; + if (!id || stopRequestedRef.current) return; stopRequestedRef.current = true; const admission = pendingAdmissionRef.current; + if (admission) { + admission.cancelled = true; + activeTurnIdRef.current = admission.restoreTurnId; + setLiveTurn(admission.restoreLiveTurn); + setTurnInFlight(false); + } + if (admission) { + const stopPromise = sideChat.stop(id).catch(() => { + if (pendingAdmissionRef.current === admission) { + admission.cancelled = false; + admission.stopPromise = undefined; + activeTurnIdRef.current = admission.restoreTurnId; + setLiveTurn(admission.restoreLiveTurn); + setTurnInFlight(true); + } + stopRequestedRef.current = false; + }); + admission.stopPromise = stopPromise; + await stopPromise; + if (pendingAdmissionRef.current === admission) { + abandonAdmission(id, admission); + } + return; + } try { await sideChat.stop(id); - if (admission?.kind === 'steer' && pendingAdmissionRef.current === admission) { - admission.cancelled = true; - pendingAdmissionRef.current = null; - activeTurnIdRef.current = admission.restoreTurnId ?? null; - for (const event of admission.events) applyOwnedEvent(id, event); - } } catch { stopRequestedRef.current = false; // best-effort; the terminal event still reconciles state } - }, [applyOwnedEvent, sideChat]); + }, [abandonAdmission, sideChat]); const steer = useCallback(async (text: string): Promise => { const id = companionIdRef.current; @@ -635,18 +743,10 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ) { return false; } - const previousTurnId = activeTurnIdRef.current; - const admission: { - messageId: string | null; - events: SessionEvent[]; - kind: 'steer'; - restoreTurnId: string | null; - cancelled: boolean; - } = { - messageId: null, + const admission: PendingAdmission = { events: [], - kind: 'steer', - restoreTurnId: previousTurnId, + restoreTurnId: activeTurnIdRef.current, + restoreLiveTurn: liveTurnRef.current, cancelled: false, }; pendingAdmissionRef.current = admission; @@ -654,15 +754,20 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan try { const outcome = await sideChat.steer(id, trimmed); if (!mountedRef.current) return false; - if (admission.cancelled) return false; + if (admission.cancelled && pendingAdmissionRef.current !== admission) return false; + if (admission.cancelled) { + await admission.stopPromise; + if (admission.cancelled || pendingAdmissionRef.current !== admission) { + abandonAdmission(id, admission); + return false; + } + } if (outcome.kind === 'started') { bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); } else { admission.messageId = outcome.messageId; - const admitted = admission.events.find((event) => - eventAdmitsMessage(event, outcome.messageId), - ); - if (admitted) { + const admitted = admissionEventForMessage(admission.events, outcome.messageId); + if (admitted && !admission.cancelled) { bindAdmittedTurn(id, admitted.turnId, { preserveLiveTurn: true }); } } @@ -671,16 +776,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } catch { if (mountedRef.current) { if (pendingAdmissionRef.current === admission) { - pendingAdmissionRef.current = null; - activeTurnIdRef.current = previousTurnId; - for (const event of admission.events) applyOwnedEvent(id, event); + abandonAdmission(id, admission, copyRef.current.errors.sendFailed); + } else if (!admission.cancelled) { + setError(copyRef.current.errors.sendFailed); } - if (admission.cancelled) return false; - setError(copyRef.current.errors.sendFailed); } return false; } - }, [applyOwnedEvent, bindAdmittedTurn, eventAdmitsMessage, mountedRef, sideChat, turnInFlight]); + }, [abandonAdmission, bindAdmittedTurn, mountedRef, sideChat, turnInFlight]); const setPermissionMode = useCallback( async (mode: PermissionMode): Promise => { diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 60ba648b46..c3c9f67654 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -130,8 +130,8 @@ export function createDesktopWorkbarServices( bridge.sessions.respondToSandboxBoundary(sessionId, response), respondToUserQuestion: (sessionId, response) => bridge.sessions.respondToUserQuestion(sessionId, response), - subscribeEvents: (sessionId, handler, onSeeded) => - bridge.sessions.subscribeEvents(sessionId, handler, onSeeded), + subscribeEvents: (sessionId, handler, onSeeded, onSeedError) => + bridge.sessions.subscribeEvents(sessionId, handler, onSeeded, undefined, onSeedError), subscribeSessionChanges: (handler) => bridge.sessions.subscribeChanges(handler), }, }; diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 0c479f94a7..8d80ea4879 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -711,7 +711,7 @@ function bridge(options: { abandonSessionCopy: async () => undefined, send: async () => ({ ok: true, turnId: 'story-side-chat-turn' }), stop: async () => undefined, - steer: async () => ({ kind: 'queued', messageId: 'story-steer-message' }), + steer: async () => ({ kind: 'started', turnId: 'story-side-chat-turn' }), setPermissionMode: async (_sessionId, mode) => ({ ...SIDE_CHAT_SESSION, permissionMode: mode, @@ -719,7 +719,10 @@ function bridge(options: { regenerateTurn: async () => undefined, respondToSandboxBoundary: async () => undefined, respondToUserQuestion: async () => undefined, - subscribeEvents: unsubscribe, + subscribeEvents: (_sessionId, _handler, onSeeded) => { + onSeeded?.(); + return unsubscribe(); + }, subscribeSessionChanges: unsubscribe, }, }); From 92b6ff1196814514f0c1baac50c08ead313370e5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 01:24:37 +0800 Subject: [PATCH 05/26] fix(side-chat): preserve admission on unknown stop Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 48 +++++++++++++++++++ .../tools/side-chat/use-quote-companion.ts | 33 ++++++++----- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 699be67a71..f364e55983 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -461,6 +461,54 @@ test('clears a queued Side Conversation send when Host stop cancels the admissio assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), ''); }); +test('keeps a Side Conversation admission when Host stop outcome is unknown', async () => { + let send: ((text: string) => Promise) | undefined; + let stop: (() => Promise) | undefined; + const pendingSend = deferred<{ ok: true; turnId: string }>(); + const { container } = await renderProbe( + { + subscribeEvents: (_sessionId, _handler, onSeeded) => { + onSeeded?.(); + return () => undefined; + }, + send: async () => pendingSend.promise, + stop: async () => { + throw new Error('Host stop result is unknown'); + }, + }, + { + ownership: true, + onSend: (value) => (send = value), + onStop: (value) => (stop = value), + }, + ); + assert.ok(send); + assert.ok(stop); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = send?.('keep this admission'); + await Promise.resolve(); + }); + let stopResult: Promise | undefined; + await act(async () => { + stopResult = stop?.(); + await stopResult; + await Promise.resolve(); + }); + + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + await act(async () => { + pendingSend.resolve({ ok: true, turnId: 'admitted-after-unknown-stop' }); + assert.equal(await sendResult, true); + await Promise.resolve(); + }); + assert.equal( + container.firstElementChild?.getAttribute('data-live-turn-id'), + 'admitted-after-unknown-stop', + ); +}); + test('releases a queued Side Conversation admission from the Host queue retract', async () => { let eventHandler: ((event: SessionEvent) => void) | undefined; let send: ((text: string) => Promise) | undefined; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index b58f093ea2..d40c36d783 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -67,7 +67,7 @@ type PendingAdmission = { restoreTurnId: string | null; restoreLiveTurn: LiveTurnProjection | undefined; cancelled: boolean; - stopPromise?: Promise; + stopPromise?: Promise<'confirmed' | 'unknown'>; }; function admissionEventForMessage( @@ -706,19 +706,26 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setTurnInFlight(false); } if (admission) { - const stopPromise = sideChat.stop(id).catch(() => { - if (pendingAdmissionRef.current === admission) { - admission.cancelled = false; - admission.stopPromise = undefined; - activeTurnIdRef.current = admission.restoreTurnId; - setLiveTurn(admission.restoreLiveTurn); - setTurnInFlight(true); - } - stopRequestedRef.current = false; - }); + const stopPromise = sideChat.stop(id).then( + () => 'confirmed' as const, + () => { + // A rejected Stop tells us nothing about whether the Host stopped + // the Turn. Keep the admission alive so a late Host outcome can + // still bind its own Turn; the user can retry Stop after this. + if (pendingAdmissionRef.current === admission) { + admission.cancelled = false; + admission.stopPromise = undefined; + activeTurnIdRef.current = admission.restoreTurnId; + setLiveTurn(admission.restoreLiveTurn); + setTurnInFlight(true); + } + stopRequestedRef.current = false; + return 'unknown' as const; + }, + ); admission.stopPromise = stopPromise; - await stopPromise; - if (pendingAdmissionRef.current === admission) { + const outcome = await stopPromise; + if (outcome === 'confirmed' && pendingAdmissionRef.current === admission) { abandonAdmission(id, admission); } return; From 99382276e34ef9563930a66123815ef699588f66 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 01:37:39 +0800 Subject: [PATCH 06/26] fix(side-chat): report Host admission ownership Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 63 +++++++++++++++++++ ...me-host-session-execution-ipc-main.test.ts | 16 ++--- ...runtime-host-session-execution-ipc-main.ts | 4 +- apps/desktop/src/preload/bridge-contract.d.ts | 1 + apps/desktop/src/preload/preload.ts | 3 +- .../src/renderer/features/workbar/ports.ts | 2 +- .../tools/side-chat/use-quote-companion.ts | 6 +- .../desktop/create-workbar-services.ts | 2 +- packages/core/src/backend-types.ts | 1 + packages/core/src/events.ts | 12 ++++ .../canonical-session-projection.test.ts | 45 +++++++++++++ .../src/__tests__/session-projector.test.ts | 61 ++++++++++++++++++ .../src/adapter/session-projector.ts | 44 ++++++++++++- packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/session-continuity.ts | 27 +++++++- .../server/canonical-session-projection.ts | 13 +++- .../session-event-runtime-mapper.test.ts | 17 +++++ .../src/session-event-runtime-mapper.ts | 16 ++--- 18 files changed, 310 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index f364e55983..e7b0b2d072 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -88,6 +88,15 @@ function steeringMessageEvent(id: string, turnId: string, ts: number, messageId: return { type: 'steering_message', id, messageId, turnId, ts, content: { text: 'steer the active turn' } }; } +function messageAdmittedEvent( + id: string, + turnId: string, + ts: number, + messageId: string, +): SessionEvent { + return { type: 'message_admitted', id, messageId, turnId, ts }; +} + function recoverableErrorEvent(id: string, turnId: string, ts: number): SessionEvent { return { type: 'error', @@ -400,6 +409,60 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag assert.equal(probe.getAttribute('data-processing'), 'false'); }); +test('replays queued Side Conversation text after Host assigns the ticket to a successor Turn', async () => { + let eventHandler: ((event: SessionEvent) => void) | undefined; + let send: ((text: string) => Promise) | undefined; + const pendingSend = deferred<{ + ok: true; + steered: true; + turnId: string; + messageId: string; + }>(); + const { container } = await renderProbe( + { + subscribeEvents: (_sessionId, handler, onSeeded) => { + eventHandler = handler; + onSeeded?.(); + return () => undefined; + }, + send: async () => pendingSend.promise, + }, + { ownership: true, onSend: (value) => (send = value) }, + ); + assert.ok(send); + assert.ok(eventHandler); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = send?.('continue in the successor turn'); + await Promise.resolve(); + }); + await act(async () => { + eventHandler?.(queueUpdateEvent('queued', 'old-root', 1)); + eventHandler?.(completeEvent('old-root-terminal', 'old-root', 2)); + eventHandler?.(messageAdmittedEvent('successor-admission', 'successor-root', 3, 'ticket-1')); + eventHandler?.(textDeltaEvent('successor-text', 'successor-root', 4, 'answer from successor')); + await Promise.resolve(); + }); + + await act(async () => { + pendingSend.resolve({ + ok: true, + steered: true, + turnId: 'requested-turn-is-not-the-owner', + messageId: 'ticket-1', + }); + assert.equal(await sendResult, true); + await Promise.resolve(); + }); + + const probe = container.firstElementChild; + assert.ok(probe); + assert.equal(probe.getAttribute('data-live-turn-id'), 'successor-root'); + assert.equal(probe.getAttribute('data-live-text'), 'answer from successor'); + assert.equal(probe.getAttribute('data-processing'), 'false'); +}); + test('clears a queued Side Conversation send when Host stop cancels the admission', async () => { let send: ((text: string) => Promise) | undefined; let stop: (() => Promise) | undefined; diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index f9c21e1d44..39987da97f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -621,7 +621,7 @@ test("queues a mid-turn send as steering when the Host reports the session busy" ok: true, steered: true, turnId: "turn-1", - messageId: "id-1", + messageId: "turn-1", attachments: [], inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] }, @@ -754,13 +754,13 @@ test("retries a dispatched busy fallback with its original message identity", as assert.deepEqual(submits, [ { sessionId: "session-1", - messageId: "id-1", + messageId: "turn-1", content: { text: "keep this message identity", inlineReferences: [] }, placement: "current_turn", }, { sessionId: "session-1", - messageId: "id-1", + messageId: "turn-1", content: { text: "keep this message identity", inlineReferences: [] }, placement: "current_turn", }, @@ -769,7 +769,7 @@ test("retries a dispatched busy fallback with its original message identity", as ok: true, steered: true, turnId: "turn-1", - messageId: "id-1", + messageId: "turn-1", attachments: [], inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] }, @@ -1140,10 +1140,10 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn ); assert.deepEqual( - await ipc.invoke("sessions:steer", "session-1", " Continue "), + await ipc.invoke("sessions:steer", "session-1", " Continue ", "steer-ticket-1"), { kind: "queued", - messageId: "id-1", + messageId: "steer-ticket-1", }, ); await ipc.invoke("sessions:stop", "session-1", { @@ -1160,7 +1160,7 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn assert.deepEqual(submits, [ { sessionId: "session-1", - messageId: "id-1", + messageId: "steer-ticket-1", content: { text: "Continue" }, placement: "current_turn", }, @@ -1168,7 +1168,7 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn assert.deepEqual(interrupts, [ { sessionId: "session-1", - interruptId: "id-2", + interruptId: "id-1", turnId: "turn-1", runId: "run-1", }, diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index c0e9efec9d..d8674d0c5c 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -380,9 +380,9 @@ export function registerRuntimeHostSessionExecutionIpc( ipcMain.handle( "sessions:steer", - async (_event, sessionId: string, text: unknown) => { + async (_event, sessionId: string, text: unknown, admissionId: unknown) => { const content = steeringContent(text); - const messageId = newId(); + const messageId = admissionId === undefined ? newId() : requiredId(admissionId, "Admission"); const submitted = await retryDispatchedCommand( () => deps.client.submitMessage({ diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 61f64ec9e7..40695c1f5d 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -771,6 +771,7 @@ export interface MakaBridge { steer( sessionId: string, text: string, + admissionId?: string, ): Promise<{ kind: 'queued'; messageId: string } | { kind: 'started'; turnId: string }>; enqueue( sessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0f85224f20..f3ef4026f5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1594,8 +1594,9 @@ const makaBridge = { steer( sessionId: string, text: string, + admissionId?: string, ): Promise<{ kind: 'queued'; messageId: string } | { kind: 'started'; turnId: string }> { - return invokeSessionRuntimeHost('sessions:steer', sessionId, text); + return invokeSessionRuntimeHost('sessions:steer', sessionId, text, admissionId); }, async enqueue( sessionId: string, diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index b1a2727e5e..312c9332ae 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -236,7 +236,7 @@ export interface SideChatSessionPort { }, ): Promise; stop(sessionId: string): Promise; - steer(sessionId: string, text: string): Promise; + steer(sessionId: string, text: string, admissionId?: string): Promise; setPermissionMode( sessionId: string, mode: PermissionMode, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index d40c36d783..b6bac92840 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -76,7 +76,8 @@ function admissionEventForMessage( ): SessionEvent | undefined { return events.find( (event) => - (event.type === 'steering_message' && event.messageId === messageId) || + ((event.type === 'steering_message' || event.type === 'message_admitted') && + event.messageId === messageId) || (event.type === 'queue_update' && event.steeringEntries?.some( (entry) => entry.messageId === messageId && entry.state === 'in_flight', @@ -756,10 +757,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan restoreLiveTurn: liveTurnRef.current, cancelled: false, }; + const admissionId = crypto.randomUUID(); pendingAdmissionRef.current = admission; activeTurnIdRef.current = null; try { - const outcome = await sideChat.steer(id, trimmed); + const outcome = await sideChat.steer(id, trimmed, admissionId); if (!mountedRef.current) return false; if (admission.cancelled && pendingAdmissionRef.current !== admission) return false; if (admission.cancelled) { diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index c3c9f67654..4cc58afaf8 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -121,7 +121,7 @@ export function createDesktopWorkbarServices( bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), send: (sessionId, command) => bridge.sessions.send(sessionId, command), stop: (sessionId) => bridge.sessions.stop(sessionId), - steer: (sessionId, text) => bridge.sessions.steer(sessionId, text), + steer: (sessionId, text, admissionId) => bridge.sessions.steer(sessionId, text, admissionId), setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), regenerateTurn: (sessionId, input) => diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 8990812e8b..46bd9fd93b 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -191,6 +191,7 @@ export type BackendSessionEvent = Exclude< { type: | 'queue_update' + | 'message_admitted' | 'permission_request' | 'permission_answer_ack' | 'permission_closure_ack' diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 4a58287e49..dfd5311c4d 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -486,6 +486,7 @@ export type SessionEvent = | PlanSubmittedEvent | TokenUsageEvent | SteeringMessageEvent + | MessageAdmittedEvent | QueueUpdateEvent | ProviderRetryEvent | ErrorEvent @@ -1064,6 +1065,17 @@ export interface SteeringMessageEvent extends BaseEvent { submittedContentDigest?: `sha256:${string}`; } +/** + * Transient Host projection fact: a submitted message now belongs to this + * Turn. It is emitted by the session projector, not by a backend or durable + * event ledger, so a client can bind a queued admission without guessing from + * timing or Turn ids returned by a stale command response. + */ +export interface MessageAdmittedEvent extends BaseEvent { + type: 'message_admitted'; + messageId: string; +} + /** * Result of enqueuing a steering / followup message. `fallback` means there was * no active run to attach to (the turn just ended) and the caller should open a diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index bb7a568554..e90e4518cb 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -154,6 +154,51 @@ test('projects the canonical root lifecycle and the attachment queue from real S }); }); +test('projects root admission source message tickets into continuity', async () => { + await withStores(async (root, stores) => { + const session = await stores.sessionStore.create(sessionInput(root)); + const rootAdmissions = new RootAdmissionOwner(stores.agentRunStore); + await rootAdmissions.recoverSession(session.id); + const messages = createMessages(session.id, stores); + const reader = new CanonicalSessionProjectionReader({ + stores, + rootAdmissions, + messages, + }); + await rootAdmissions.admitRootTurn({ + sessionId: session.id, + turnId: 'turn-successor', + proposedRunId: 'run-successor', + proposedUserMessageId: 'source-1', + execution: { kind: 'external_message' }, + normalizedInput: { text: 'first\n\nsecond' }, + sourceMessages: [ + { + messageId: 'source-1', + content: { text: 'first' }, + placement: 'current_turn', + disposition: 'steering', + }, + { + messageId: 'source-2', + content: { text: 'second' }, + placement: 'next_turn', + disposition: 'followup', + }, + ], + admittedAt: 10, + }); + + const canonical = await reader.read(session.id); + assert.deepEqual(canonical?.rootTurnSourceMessageIds, ['source-1', 'source-2']); + assert.deepEqual(createSessionContinuitySnapshot(canonical!, 1).rootTurnSourceMessageIds, [ + 'source-1', + 'source-2', + ]); + await messages.close(); + }); +}); + test('projects pending Interactions and preflights their combined snapshot capacity', async () => { await withStores(async (root, stores) => { const session = await stores.sessionStore.create(sessionInput(root)); diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index e2138d3f78..a68aa0c814 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -72,6 +72,60 @@ test('applies authoritative replacement once and does not complete it again at T ); }); +test('emits the Host admission fact when a queued message enters a successor Turn', () => { + const rejoined = new RuntimeHostSessionProjector( + withRootSourceMessageIds(snapshot(), ['rejoined-ticket']), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + assert.deepEqual( + rejoined + .seedActive(false) + .filter((event) => event.type === 'message_admitted') + .map((event) => ({ + turnId: event.turnId, + messageId: event.messageId, + })), + [{ turnId: 'turn-1', messageId: 'rejoined-ticket' }], + ); + + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + const next = withRootSourceMessageIds( + snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-2', + runId: 'run-2', + status: 'running', + }, + }), + ['ticket-1'], + ); + + const events = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: next, + }).events; + + assert.deepEqual( + events + .filter((event) => event.type === 'message_admitted') + .map((event) => ({ + turnId: event.turnId, + messageId: event.messageId, + })), + [{ turnId: 'turn-2', messageId: 'ticket-1' }], + ); +}); + test('reseeds the latest provider retry when the active Turn still carries one', () => { const retry = { phase: 'scheduled' as const, @@ -383,6 +437,13 @@ function snapshot(overrides: Partial = {}): SessionCo }; } +function withRootSourceMessageIds( + value: SessionContinuitySnapshot, + rootTurnSourceMessageIds: readonly string[], +): SessionContinuitySnapshot { + return { ...value, rootTurnSourceMessageIds } as unknown as SessionContinuitySnapshot; +} + function assistant(id: string, text: string): Extract { return { type: 'assistant', diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 09760af34a..bc3f510ccd 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -141,8 +141,12 @@ export class RuntimeHostSessionProjector { seedActive(includeAssistantText: boolean): SessionEvent[] { const root = this.#snapshot.rootTurn; - if (!root || isRuntimeHostTerminalTurn(root)) return []; + if (!root) return []; const events: SessionEvent[] = []; + events.push( + ...projectMessageAdmissionEvents(root, this.#snapshot.rootTurnSourceMessageIds, this.#now()), + ); + if (isRuntimeHostTerminalTurn(root)) return events; let seededAssistantText = false; if (includeAssistantText) { for (const accumulator of this.#accumulators.values()) { @@ -192,7 +196,10 @@ export class RuntimeHostSessionProjector { } seedTerminal(turn: RuntimeHostTerminalTurn): SessionEvent[] { - return this.#terminalEvents(turn, true); + return [ + ...projectMessageAdmissionEvents(turn, this.#snapshot.rootTurnSourceMessageIds, this.#now()), + ...this.#terminalEvents(turn, true), + ]; } seedStoredTerminal(turnId: string, transcript: readonly StoredMessage[]): SessionEvent[] { @@ -372,6 +379,7 @@ export class RuntimeHostSessionProjector { const previousRoot = previousSnapshot.rootTurn; const startedTurn = root && (!previousRoot || root.runId !== previousRoot.runId) ? root : undefined; + events.push(...projectNewMessageAdmissionEvents(previousSnapshot, next, this.#now())); if (startedTurn) this.#accumulators.clear(); const retry = liveProviderRetryEvent(previousRoot, root, this.#now()); if (retry) events.push(retry); @@ -437,6 +445,38 @@ function emptyUpdate(events: readonly SessionEvent[]): RuntimeHostProjectionUpda return { events, resolvedInteractions: [] }; } +function projectMessageAdmissionEvents( + root: TurnSnapshot, + messageIds: readonly string[] | undefined, + ts: number, +): SessionEvent[] { + return (messageIds ?? []).map((messageId) => ({ + type: 'message_admitted' as const, + id: `host-admission:${root.runId}:${messageId}`, + turnId: root.turnId, + ts, + messageId, + })); +} + +function projectNewMessageAdmissionEvents( + previous: SessionContinuitySnapshot, + next: SessionContinuitySnapshot, + ts: number, +): SessionEvent[] { + const root = next.rootTurn; + if (!root) return []; + const previousIds = + previous.rootTurn?.runId === root.runId + ? new Set(previous.rootTurnSourceMessageIds ?? []) + : new Set(); + return projectMessageAdmissionEvents( + root, + (next.rootTurnSourceMessageIds ?? []).filter((messageId) => !previousIds.has(messageId)), + ts, + ); +} + export function projectRuntimeHostInteractionRequest( interaction: InteractionPendingSnapshot, now: number, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 19d5dd1f5b..327dbfa347 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 48 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 49 as const; +// 49: Session continuity snapshots carry Host-owned root source message +// tickets so clients can bind queued admissions to successor Turns. // 48: Session branch creation accepts an explicit Side Conversation intent. // Older peers reject the strict input shape or cannot apply its snapshot semantics. // 47: Project registration can carry an explicit location preference. Epoch-46 diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index c8ccb5055a..d248b4841f 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -87,6 +87,8 @@ export interface SessionContinuitySnapshot { goal: GoalProjection | null; queue: SessionMessageQueueProjection; interactions: SessionInteractionProjection; + /** Host-owned source message tickets admitted into the root Turn. */ + rootTurnSourceMessageIds?: readonly string[]; } export interface SubscriptionOpenInput { @@ -515,7 +517,18 @@ export function decodeSessionContinuitySnapshot(value: unknown): SessionContinui 'Session continuity snapshot', SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES, ); - const record = requireExactRecord(value, 'Session continuity snapshot', [ + const record = requireRecord(value, 'Session continuity snapshot'); + assertAllowedKeys(record, 'Session continuity snapshot', [ + 'schemaVersion', + 'session', + 'projectionRevision', + 'rootTurn', + 'goal', + 'queue', + 'interactions', + 'rootTurnSourceMessageIds', + ]); + assertRequiredKeys(record, 'Session continuity snapshot', [ 'schemaVersion', 'session', 'projectionRevision', @@ -537,6 +550,10 @@ export function decodeSessionContinuitySnapshot(value: unknown): SessionContinui if (goal !== null && goal.sessionId !== session.sessionId) { throw invalidProtocolFrame('Session continuity Goal belongs to a different Session'); } + const rootTurnSourceMessageIds = + record.rootTurnSourceMessageIds === undefined + ? undefined + : decodeRootTurnSourceMessageIds(record.rootTurnSourceMessageIds); return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session, @@ -545,9 +562,17 @@ export function decodeSessionContinuitySnapshot(value: unknown): SessionContinui goal, queue: decodeSessionMessageQueueProjection(record.queue), interactions, + ...(rootTurnSourceMessageIds === undefined ? {} : { rootTurnSourceMessageIds }), }; } +function decodeRootTurnSourceMessageIds(value: unknown): string[] { + if (!Array.isArray(value)) throw invalidProtocolFrame('Invalid root Turn source message ids'); + return value.map((messageId, index) => + requireId(messageId, `root Turn source message id ${index}`), + ); +} + function decodeSubscriptionOpenInput(value: unknown): SubscriptionOpenInput { const record = requireExactRecord(value, 'subscription.open input', ['sessionId', 'transcript']); const transcript = requireRecord(record.transcript, 'subscription transcript policy'); diff --git a/packages/runtime-host/src/server/canonical-session-projection.ts b/packages/runtime-host/src/server/canonical-session-projection.ts index 2594acca4c..a5ad5613f6 100644 --- a/packages/runtime-host/src/server/canonical-session-projection.ts +++ b/packages/runtime-host/src/server/canonical-session-projection.ts @@ -54,6 +54,7 @@ export interface CanonicalSessionProjection { readonly goal: GoalProjection | null; readonly queue: SessionMessageQueueProjection; readonly interactions: SessionInteractionProjection; + readonly rootTurnSourceMessageIds?: readonly string[]; } export interface CanonicalSessionProjectionCandidate { @@ -98,6 +99,7 @@ export class CanonicalSessionProjectionReader { } let rootTurn: TurnSnapshot | null = null; + let rootTurnSourceMessageIds: readonly string[] = []; if (admission) { const durableAdmission = await this.#stores.agentRunStore.readRootTurnAdmission( sessionId, @@ -108,6 +110,7 @@ export class CanonicalSessionProjectionReader { } this.#rootAdmissions.assertKnownAdmission(durableAdmission); rootTurn = await readCanonicalTurnSnapshot(this.#stores, durableAdmission); + rootTurnSourceMessageIds = durableAdmission.sourceMessages.map(({ messageId }) => messageId); } const interactions = projectSessionInteractions( @@ -124,7 +127,14 @@ export class CanonicalSessionProjectionReader { createdAt: header.createdAt, isArchived: header.isArchived, }; - return { session, rootTurn, goal, queue, interactions }; + return { + session, + rootTurn, + goal, + queue, + interactions, + ...(admission ? { rootTurnSourceMessageIds } : {}), + }; } async fitsCandidate( @@ -192,6 +202,7 @@ function sessionContinuitySnapshotInput( goal: canonical.goal, queue: canonical.queue, interactions: canonical.interactions, + rootTurnSourceMessageIds: canonical.rootTurnSourceMessageIds ?? [], }; } diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index bef67c4f58..1e515bf7ab 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -624,6 +624,23 @@ const projectionRunHeader: AgentRunHeader = { }; describe('SessionEvent projection coverage', () => { + test('keeps Host admission facts out of durable Runtime events', () => { + assert.throws( + () => + mapSessionEventToRuntimeEvent( + { + type: 'message_admitted', + id: 'admission-1', + turnId: 'turn-1', + ts: 1, + messageId: 'message-1', + }, + ctx, + ), + /message_admitted is not a backend event/, + ); + }); + // The contract is over what a reader can actually meet: every mapped event // AgentRun admits to the ledger has to project. It asserts on the unclaimed // codes at either severity, not on the hard one alone — a control fact whose diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index e282d2e69e..ca564c6e1a 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -129,12 +129,10 @@ export function mapSessionEventToRuntimeEvent( ctx: RuntimeEventMapContext, memory: SessionEventMapMemory = createSessionEventMapMemory(), ): RuntimeEvent { - if (event.type === 'queue_update') { - // Not backend-mappable by design: the kernel is queue_update's only - // legal producer and pushes it directly into the turn stream. The flow - // drops a backend-yielded one at the ingress (see run()), so reaching - // this line means a caller bypassed that authority boundary. - throw new Error('queue_update is not a backend event: the kernel is its only legal producer'); + if (event.type === 'queue_update' || event.type === 'message_admitted') { + // These are Host/kernel projection facts, not backend events. The live + // ingress drops them, so reaching this line bypassed that authority boundary. + throw new Error(`${event.type} is not a backend event`); } if (isLegacyPermissionSessionEvent(event)) { throw new Error(`${event.type} is a legacy permission event and is not backend-mappable`); @@ -144,7 +142,11 @@ export function mapSessionEventToRuntimeEvent( } export function isLiveBackendSessionEvent(event: SessionEvent): event is BackendSessionEvent { - return event.type !== 'queue_update' && !isLegacyPermissionSessionEvent(event); + return ( + event.type !== 'queue_update' && + event.type !== 'message_admitted' && + !isLegacyPermissionSessionEvent(event) + ); } function isLegacyPermissionSessionEvent(event: SessionEvent): event is Extract< From 7813353a23d652c71998349926b6d97134a6997e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 02:18:15 +0800 Subject: [PATCH 07/26] fix(side-chat): preserve Host admission across recovery Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 75 +++++-------------- .../tools/side-chat/use-quote-companion.ts | 8 +- .../src/__tests__/session-projector.test.ts | 33 +++++++- .../src/adapter/session-projector.ts | 2 +- 4 files changed, 55 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index e7b0b2d072..709d7af663 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -437,14 +437,6 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s sendResult = send?.('continue in the successor turn'); await Promise.resolve(); }); - await act(async () => { - eventHandler?.(queueUpdateEvent('queued', 'old-root', 1)); - eventHandler?.(completeEvent('old-root-terminal', 'old-root', 2)); - eventHandler?.(messageAdmittedEvent('successor-admission', 'successor-root', 3, 'ticket-1')); - eventHandler?.(textDeltaEvent('successor-text', 'successor-root', 4, 'answer from successor')); - await Promise.resolve(); - }); - await act(async () => { pendingSend.resolve({ ok: true, @@ -456,6 +448,13 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s await Promise.resolve(); }); + await act(async () => { + eventHandler?.(messageAdmittedEvent('successor-admission', 'successor-root', 1, 'ticket-1')); + eventHandler?.(queueUpdateEvent('successor-queue', 'successor-root', 2)); + eventHandler?.(textDeltaEvent('successor-text', 'successor-root', 3, 'answer from successor')); + await Promise.resolve(); + }); + const probe = container.firstElementChild; assert.ok(probe); assert.equal(probe.getAttribute('data-live-turn-id'), 'successor-root'); @@ -610,89 +609,49 @@ test('releases a queued Side Conversation admission from the Host queue retract' assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); }); -test('fails and re-observes a Side Conversation after a recoverable Host subscription error', async () => { +test('keeps the same Side Conversation admission across a recoverable subscription error', async () => { let send: ((text: string) => Promise) | undefined; - const handlers: Array<(event: SessionEvent) => void> = []; + let eventHandler: ((event: SessionEvent) => void) | undefined; let subscriptionCount = 0; - let sendCalls = 0; const pendingSend = deferred<{ ok: true; turnId: string }>(); - const pendingRetry = deferred<{ ok: true; turnId: string }>(); const { container } = await renderProbe( { subscribeEvents: (_sessionId, handler, onSeeded) => { subscriptionCount += 1; - handlers.push(handler); + eventHandler = handler; onSeeded?.(); return () => undefined; }, - send: async () => { - sendCalls += 1; - return sendCalls === 1 - ? pendingSend.promise - : pendingRetry.promise; - }, + send: async () => pendingSend.promise, }, { ownership: true, onSend: (value) => (send = value) }, ); assert.ok(send); assert.equal(subscriptionCount, 1); - let failedResult: Promise | undefined; + let sendResult: Promise | undefined; await act(async () => { - failedResult = send?.('fail with a recoverable stream error'); + sendResult = send?.('survive a recoverable stream error'); await Promise.resolve(); }); await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); await act(async () => { - handlers[0]?.(recoverableErrorEvent('recoverable-subscription-error', 'old-turn', 1)); - await Promise.resolve(); - }); - assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); - assert.notEqual(container.firstElementChild?.getAttribute('data-error'), ''); - - let reobserveResult: Promise | undefined; - await act(async () => { - reobserveResult = send?.('re-observe before retrying'); - assert.equal(await reobserveResult, false); + eventHandler?.(recoverableErrorEvent('recoverable-subscription-error', 'old-turn', 1)); await Promise.resolve(); }); - assert.equal(subscriptionCount, 2); - - let retryResult: Promise | undefined; - await act(async () => { - retryResult = send?.('retry after re-observing'); - await Promise.resolve(); - }); - assert.equal(sendCalls, 2); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + assert.equal(subscriptionCount, 1); await act(async () => { pendingSend.resolve({ ok: true, turnId: 'late-turn' }); - assert.equal(await failedResult, false); - await Promise.resolve(); - }); - assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); - - await act(async () => { - pendingRetry.resolve({ ok: true, turnId: 'retry-after-resubscribe' }); - assert.equal(await retryResult, true); + assert.equal(await sendResult, true); await Promise.resolve(); }); await act(async () => { - handlers[1]?.(completeEvent('retry-complete', 'retry-after-resubscribe', 2)); + eventHandler?.(completeEvent('late-complete', 'late-turn', 2)); await Promise.resolve(); }); await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'false'); - - await act(async () => { - handlers[1]?.(recoverableErrorEvent('idle-recoverable-error', 'retry-after-resubscribe', 3)); - await Promise.resolve(); - }); - await act(async () => { - assert.equal(await send?.('re-observe while idle'), false); - await Promise.resolve(); - }); - assert.equal(subscriptionCount, 3); }); test('cancels a pending Side Conversation steer after Host stop without losing the old Turn', async () => { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index b6bac92840..ae87a2a089 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -281,6 +281,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan pendingAdmissionRef.current = null; activeTurnIdRef.current = turnId; ownTurnIdsRef.current.add(turnId); + setError(null); setOwnTurnTick((tick) => tick + 1); if (!(options.preserveLiveTurn && liveTurnRef.current?.turnId === turnId)) { setLiveTurn(armLiveTurn(turnId)); @@ -349,10 +350,13 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const admission = pendingAdmissionRef.current; if (event.type === 'error' && event.recoverable) { if (admission) { - abandonAdmission(forkId, admission, copyRef.current.errors.sendFailed); - } else { + // Observation failure does not prove whether Host admitted the + // dispatched command. Keep its identity until Host events or the + // command result provide an authoritative outcome. setError(copyRef.current.errors.sendFailed); + return; } + setError(copyRef.current.errors.sendFailed); const retry = Promise.reject(new Error(event.message)); void retry.catch(() => undefined); subscriptionReadyRef.current = retry; diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index a68aa0c814..ef7a08af35 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -89,14 +89,36 @@ test('emits the Host admission fact when a queued message enters a successor Tur [{ turnId: 'turn-1', messageId: 'rejoined-ticket' }], ); + const previous = snapshot({ + queue: { + hostEpoch: 'host-1', + queueRevision: 1, + steering: [ + { + entryId: 'entry-1', + messageId: 'ticket-1', + content: { text: 'continue in successor' }, + placement: 'current_turn', + state: 'in_flight', + }, + ], + followup: [], + }, + }); const projector = new RuntimeHostSessionProjector( - snapshot(), - createRuntimeHostSessionProjectionSeed([], snapshot()), + previous, + createRuntimeHostSessionProjectionSeed([], previous), () => 10, ); const next = withRootSourceMessageIds( snapshot({ projectionRevision: 2, + queue: { + hostEpoch: 'host-1', + queueRevision: 2, + steering: [], + followup: [], + }, rootTurn: { sessionId: 'session-1', turnId: 'turn-2', @@ -115,6 +137,13 @@ test('emits the Host admission fact when a queued message enters a successor Tur snapshot: next, }).events; + assert.deepEqual( + events + .filter((event) => event.type === 'message_admitted' || event.type === 'queue_update') + .map((event) => event.type), + ['message_admitted', 'queue_update'], + ); + assert.deepEqual( events .filter((event) => event.type === 'message_admitted') diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index bc3f510ccd..ea806a45ec 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -363,6 +363,7 @@ export class RuntimeHostSessionProjector { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); } const root = next.rootTurn; + events.push(...projectNewMessageAdmissionEvents(previousSnapshot, next, this.#now())); if (root && queueChanged(previousSnapshot.queue, next.queue)) { for (const entry of newlyInFlight(previousSnapshot.queue, next.queue)) { events.push({ @@ -379,7 +380,6 @@ export class RuntimeHostSessionProjector { const previousRoot = previousSnapshot.rootTurn; const startedTurn = root && (!previousRoot || root.runId !== previousRoot.runId) ? root : undefined; - events.push(...projectNewMessageAdmissionEvents(previousSnapshot, next, this.#now())); if (startedTurn) this.#accumulators.clear(); const retry = liveProviderRetryEvent(previousRoot, root, this.#now()); if (retry) events.push(retry); From e4595df6bc036c88e076da6390f94c7f19892108 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 02:35:15 +0800 Subject: [PATCH 08/26] fix(side-chat): resolve admission outcomes by ticket Generated-by: Codex --- .../permission-response-ipc-boundary.test.ts | 11 +- .../__tests__/quote-companion-retry.test.ts | 88 ++++++++++----- ...me-host-session-execution-ipc-main.test.ts | 81 ++++++++++++- .../src/main/permission-response-guard.ts | 9 ++ ...runtime-host-session-execution-ipc-main.ts | 106 +++++++++++++----- apps/desktop/src/preload/bridge-contract.d.ts | 18 ++- apps/desktop/src/preload/preload.ts | 18 ++- .../src/renderer/features/workbar/ports.ts | 6 +- .../tools/side-chat/quote-companion-core.ts | 4 + .../tools/side-chat/use-quote-companion.ts | 71 ++++++++---- .../desktop/create-workbar-services.ts | 6 +- packages/core/src/backend-types.ts | 1 + packages/core/src/events.ts | 7 ++ .../src/__tests__/session-projector.test.ts | 21 +++- .../src/adapter/session-projector.ts | 28 +++++ .../session-event-runtime-mapper.test.ts | 30 ++--- .../src/session-event-runtime-mapper.ts | 7 +- 17 files changed, 407 insertions(+), 105 deletions(-) diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index bbc6bbad36..e5c6a2585f 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -251,9 +251,14 @@ describe('permission response IPC boundary', () => { normalizeStopSessionInput({ source: 'stop_button', expectedTurnId: 'turn-workhub', + expectedAdmissionId: 'side-admission', extra: true, }), - { source: 'stop_button', expectedTurnId: 'turn-workhub' }, + { + source: 'stop_button', + expectedTurnId: 'turn-workhub', + expectedAdmissionId: 'side-admission', + }, ); assert.throws(() => normalizeStopSessionInput(null), /stop session input/); assert.throws(() => normalizeStopSessionInput({ source: 'toolbar' }), /stop session source/); @@ -261,5 +266,9 @@ describe('permission response IPC boundary', () => { () => normalizeStopSessionInput({ expectedTurnId: '' }), /expectedTurnId/, ); + assert.throws( + () => normalizeStopSessionInput({ expectedAdmissionId: '' }), + /expectedAdmissionId/, + ); }); }); diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 709d7af663..c725d1786e 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -338,7 +338,12 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a test('binds a busy-raced Side Conversation send through its Host-admitted message identity', async () => { let eventHandler: ((event: SessionEvent) => void) | undefined; let send: ((text: string) => Promise) | undefined; - const pendingSend = deferred<{ ok: true; steered: true; turnId: string; messageId: string }>(); + const pendingSend = deferred<{ + ok: true; + steered: true; + turnId: string; + messageId: string; + }>(); const { container } = await renderProbe( { subscribeEvents: (_sessionId, handler, onSeeded) => { @@ -413,9 +418,8 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s let eventHandler: ((event: SessionEvent) => void) | undefined; let send: ((text: string) => Promise) | undefined; const pendingSend = deferred<{ - ok: true; - steered: true; - turnId: string; + ok: false; + reason: 'outcome_unknown'; messageId: string; }>(); const { container } = await renderProbe( @@ -438,20 +442,19 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s await Promise.resolve(); }); await act(async () => { - pendingSend.resolve({ - ok: true, - steered: true, - turnId: 'requested-turn-is-not-the-owner', - messageId: 'ticket-1', - }); - assert.equal(await sendResult, true); + eventHandler?.(messageAdmittedEvent('successor-admission', 'successor-root', 1, 'ticket-1')); + eventHandler?.(queueUpdateEvent('successor-queue', 'successor-root', 2)); + eventHandler?.(textDeltaEvent('successor-text', 'successor-root', 3, 'answer from successor')); await Promise.resolve(); }); await act(async () => { - eventHandler?.(messageAdmittedEvent('successor-admission', 'successor-root', 1, 'ticket-1')); - eventHandler?.(queueUpdateEvent('successor-queue', 'successor-root', 2)); - eventHandler?.(textDeltaEvent('successor-text', 'successor-root', 3, 'answer from successor')); + pendingSend.resolve({ + ok: false, + reason: 'outcome_unknown', + messageId: 'ticket-1', + }); + assert.equal(await sendResult, true); await Promise.resolve(); }); @@ -465,6 +468,7 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s test('clears a queued Side Conversation send when Host stop cancels the admission', async () => { let send: ((text: string) => Promise) | undefined; let stop: (() => Promise) | undefined; + let admissionId: string | undefined; const pendingStop = deferred(); const pendingSend = deferred<{ ok: true; @@ -478,8 +482,14 @@ test('clears a queued Side Conversation send when Host stop cancels the admissio onSeeded?.(); return () => undefined; }, - send: async () => pendingSend.promise, - stop: async () => pendingStop.promise, + send: async (_sessionId, command) => { + admissionId = command.turnId; + return pendingSend.promise; + }, + stop: async (_sessionId, expectedAdmissionId) => { + assert.equal(expectedAdmissionId, admissionId); + return pendingStop.promise; + }, }, { ownership: true, @@ -514,7 +524,7 @@ test('clears a queued Side Conversation send when Host stop cancels the admissio ok: true, steered: true, turnId: 'old-turn', - messageId: 'retracted-message', + messageId: admissionId as string, }); assert.equal(await sendResult, false); await Promise.resolve(); @@ -574,6 +584,12 @@ test('keeps a Side Conversation admission when Host stop outcome is unknown', as test('releases a queued Side Conversation admission from the Host queue retract', async () => { let eventHandler: ((event: SessionEvent) => void) | undefined; let send: ((text: string) => Promise) | undefined; + const pendingSend = deferred<{ + ok: true; + steered: true; + turnId: string; + messageId: string; + }>(); const { container } = await renderProbe( { subscribeEvents: (_sessionId, handler, onSeeded) => { @@ -581,12 +597,7 @@ test('releases a queued Side Conversation admission from the Host queue retract' onSeeded?.(); return () => undefined; }, - send: async () => ({ - ok: true as const, - steered: true as const, - turnId: 'not-the-owner', - messageId: 'retracted-message', - }), + send: async () => pendingSend.promise, }, { ownership: true, @@ -597,13 +608,30 @@ test('releases a queued Side Conversation admission from the Host queue retract' assert.ok(eventHandler); await act(async () => { - assert.equal(await send?.('retract this queued send'), true); + void send?.('retract this queued send'); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { - eventHandler?.(queueUpdateEvent('retract-queue', 'old-turn', 1)); + eventHandler?.({ + type: 'message_retracted', + id: 'retracted-admission', + turnId: 'old-turn', + ts: 1, + messageId: 'retracted-message', + }); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + + await act(async () => { + pendingSend.resolve({ + ok: true, + steered: true, + turnId: 'not-the-owner', + messageId: 'retracted-message', + }); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); @@ -658,7 +686,10 @@ test('cancels a pending Side Conversation steer after Host stop without losing t let send: ((text: string) => Promise) | undefined; let steer: ((text: string) => Promise) | undefined; let stop: (() => Promise) | undefined; - const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + const pendingSteer = deferred<{ + kind: 'queued'; + messageId: string; + }>(); let stopCalls = 0; const { container } = await renderProbe( { @@ -703,7 +734,10 @@ test('cancels a pending Side Conversation steer after Host stop without losing t assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'old-turn'); await act(async () => { - pendingSteer.resolve({ kind: 'queued', messageId: 'cancelled-steer' }); + pendingSteer.resolve({ + kind: 'queued', + messageId: 'cancelled-steer', + }); assert.equal(await steerResult, false); await Promise.resolve(); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 39987da97f..5f5c6f0b1a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -722,6 +722,13 @@ test("retries a dispatched busy fallback with its original message identity", as }, submitMessage: async (input) => { submits.push(input); + if (input.messageId === "turn-unknown") { + throw new RuntimeHostOperationError( + "turn.message.submit", + "outcome_unknown", + "Message disposition cannot be proven in this Host Epoch", + ); + } if (submits.length === 1) { throw new RuntimeHostRequestInterruptedError( "turn.message.submit", @@ -774,6 +781,19 @@ test("retries a dispatched busy fallback with its original message identity", as inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] }, }); + assert.deepEqual( + await ipc.invoke("sessions:send", "session-1", { + type: "send", + turnId: "turn-unknown", + text: "keep waiting for the Host outcome", + }), + { + ok: false, + reason: "outcome_unknown", + messageId: "turn-unknown", + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }, + ); }); test("returns the Host-started Turn identity when a direct steer races idle", async () => { @@ -1097,11 +1117,19 @@ test("routes per-entry queue mutations to the Runtime Host", async () => { test("binds steer and stop to Host-owned queue and active Turn identities", async () => { const submits: unknown[] = []; const interrupts: unknown[] = []; + const retractions: unknown[] = []; const stopLifecycle: string[] = []; let sequence = 0; const client = executionClient({ submitMessage: async (input) => { submits.push(input); + if (input.messageId === 'unknown-ticket') { + throw new RuntimeHostOperationError( + 'turn.message.submit', + 'outcome_unknown', + 'Message disposition cannot be proven in this Host Epoch', + ); + } return { disposition: "steering", queueRevision: 2 }; }, interruptTurn: async (input) => { @@ -1120,8 +1148,27 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn }, }; }, + retractQueueEntry: async (input) => { + retractions.push(input); + return { queueRevision: 3 }; + }, + }); + const observer = observerWithSnapshot({ + queue: { + hostEpoch: 'host-1', + queueRevision: 2, + steering: [ + { + entryId: 'entry-1', + messageId: 'steer-ticket-1', + content: { text: 'Continue' }, + placement: 'current_turn', + state: 'queued', + }, + ], + followup: [], + }, }); - const observer = observerWithSnapshot(); const ipc = ipcHarness(); registerExecutionIpc( { @@ -1146,6 +1193,22 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn messageId: "steer-ticket-1", }, ); + assert.deepEqual( + await ipc.invoke('sessions:steer', 'session-1', 'Continue', 'unknown-ticket'), + { kind: 'outcome_unknown', messageId: 'unknown-ticket' }, + ); + await ipc.invoke("sessions:stop", "session-1", { + source: "stop_button", + expectedAdmissionId: "steer-ticket-1", + }); + assert.deepEqual(retractions, [ + { + sessionId: 'session-1', + entryId: 'entry-1', + retractId: 'id-1', + }, + ]); + assert.deepEqual(stopLifecycle, []); await ipc.invoke("sessions:stop", "session-1", { source: "stop_button", expectedTurnId: "turn-unrelated", @@ -1164,11 +1227,17 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn content: { text: "Continue" }, placement: "current_turn", }, + { + sessionId: 'session-1', + messageId: 'unknown-ticket', + content: { text: 'Continue' }, + placement: 'current_turn', + }, ]); assert.deepEqual(interrupts, [ { sessionId: "session-1", - interruptId: "id-1", + interruptId: "id-2", turnId: "turn-1", runId: "run-1", }, @@ -1219,12 +1288,15 @@ function unusedObserver(): RuntimeHostSessionObserver { }); } -function observerWithSnapshot(): RuntimeHostSessionObserver { - return observerWithTranscript([]); +function observerWithSnapshot( + overrides: Partial = {}, +): RuntimeHostSessionObserver { + return observerWithTranscript([], overrides); } function observerWithTranscript( transcript: readonly import('@maka/core/session').StoredMessage[], + overrides: Partial = {}, ): RuntimeHostSessionObserver { let finishEvents!: () => void; const eventsFinished = new Promise((resolve) => { @@ -1257,6 +1329,7 @@ function observerWithTranscript( followup: [], }, interactions: { pending: [] }, + ...overrides, }, activeAssistantStreams: [], transcript: Promise.resolve([...transcript]), diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 1dd307674a..4c39f944b5 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -65,6 +65,7 @@ interface NormalizedSendSessionCommand { type NormalizedStopSessionInput = { source?: 'stop_button'; expectedTurnId?: string; + expectedAdmissionId?: string; }; export function normalizeSandboxBoundaryResponse(input: unknown): SandboxBoundaryResponse { @@ -324,9 +325,17 @@ export function normalizeStopSessionInput(input: unknown): NormalizedStopSession 'Invalid stop session expectedTurnId', MAX_TURN_ID_LENGTH, ); + const expectedAdmissionId = value.expectedAdmissionId === undefined + ? undefined + : normalizeRequiredString( + value.expectedAdmissionId, + 'Invalid stop session expectedAdmissionId', + MAX_TURN_ID_LENGTH, + ); return { ...(value.source ? { source: 'stop_button' as const } : {}), ...(expectedTurnId ? { expectedTurnId } : {}), + ...(expectedAdmissionId ? { expectedAdmissionId } : {}), }; } diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index d8674d0c5c..7520a9d20b 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -109,6 +109,23 @@ type RuntimeHostSessionExecutionClient = Pick< | "updateSessionConfiguration" >; +async function submitMessageWithReconnect( + client: Pick, + input: Parameters[0], +): Promise> | undefined> { + try { + return await retryDispatchedCommand( + () => client.submitMessage(input), + () => client.getSession(input.sessionId), + ); + } catch (error) { + if (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') { + return undefined; + } + throw error; + } +} + export interface RuntimeHostSessionExecutionIpcDeps { client: RuntimeHostSessionExecutionClient; observer: RuntimeHostSessionObserver; @@ -321,19 +338,25 @@ export function registerRuntimeHostSessionExecutionIpc( ) { throw error; } - const submitted = await retryDispatchedCommand( - () => - deps.client.submitMessage({ - sessionId, - // Preserve the renderer's command identity in the durable message so - // a lost IPC reply can be reconciled as root-vs-steering later. - messageId: turnId, - content: startInput.content, - placement: "current_turn", - }), - () => deps.client.getSession(sessionId), - ); + // The requested Turn id is the submission/admission ticket. If the + // busy fallback queues this message and a successor root consumes it, + // the Host can report ownership with the same identity. + const messageId = turnId; const emptySkillInvocation = { loaded: [], failed: [], receipts: [] }; + const submitted = await submitMessageWithReconnect(deps.client, { + sessionId, + messageId, + content: startInput.content, + placement: "current_turn", + }); + if (!submitted) { + return { + ok: false as const, + reason: 'outcome_unknown' as const, + messageId, + skillInvocation: emptySkillInvocation, + }; + } if (submitted.disposition === "turn_started") { deps.emitSessionsChanged("status-change", sessionId, { turnId: submitted.turnId, @@ -383,19 +406,19 @@ export function registerRuntimeHostSessionExecutionIpc( async (_event, sessionId: string, text: unknown, admissionId: unknown) => { const content = steeringContent(text); const messageId = admissionId === undefined ? newId() : requiredId(admissionId, "Admission"); - const submitted = await retryDispatchedCommand( - () => - deps.client.submitMessage({ - sessionId, - messageId, - content: { text: content }, - placement: "current_turn", - }), - () => deps.client.getSession(sessionId), - ); + const submitted = await submitMessageWithReconnect(deps.client, { + sessionId, + messageId, + content: { text: content }, + placement: "current_turn", + }); + if (!submitted) return { kind: 'outcome_unknown' as const, messageId }; return submitted.disposition === "turn_started" ? { kind: "started" as const, turnId: submitted.turnId } - : { kind: "queued" as const, messageId }; + : { + kind: "queued" as const, + messageId, + }; }, ); ipcMain.handle( @@ -548,7 +571,7 @@ export function registerRuntimeHostSessionExecutionIpc( "sessions:stop", async (_event, sessionId: string, input: unknown) => { const normalized = normalizeStopSessionInput(input); - return stopSession(sessionId, normalized.expectedTurnId); + return stopSession(sessionId, normalized); }, ); @@ -772,8 +795,39 @@ function createRuntimeHostSessionStop( "beforeStop" | "client" | "observer" | "emitSessionsChanged" >, newId: () => string = randomUUID, -): (sessionId: string, expectedTurnId?: string) => Promise { - return async (sessionId, expectedTurnId) => { +): ( + sessionId: string, + target?: { readonly expectedTurnId?: string; readonly expectedAdmissionId?: string }, +) => Promise { + return async (sessionId, target = {}) => { + let expectedTurnId = target.expectedTurnId; + if (target.expectedAdmissionId) { + const observed = await deps.observer.snapshot(sessionId); + const root = observed.rootTurn; + const entry = [...observed.queue.steering, ...observed.queue.followup].find( + (candidate) => candidate.messageId === target.expectedAdmissionId, + ); + if (entry?.state === 'queued') { + await deps.client.retractQueueEntry({ + sessionId, + entryId: entry.entryId, + retractId: newId(), + }); + deps.emitSessionsChanged('status-change', sessionId); + return; + } + if ( + root && + !isTerminalStatus(root.status) && + (root.turnId === target.expectedAdmissionId || + observed.rootTurnSourceMessageIds?.includes(target.expectedAdmissionId) === true || + entry?.state === 'in_flight') + ) { + expectedTurnId = root.turnId; + } else { + throw new Error('Host admission outcome is unknown'); + } + } if (expectedTurnId) { const observed = (await deps.observer.snapshot(sessionId)).rootTurn; if ( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 40695c1f5d..6eba5804e1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -763,16 +763,30 @@ export interface MakaBridge { reason: 'skill_invocation_failed'; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } + | { + ok: false; + reason: 'outcome_unknown'; + messageId: string; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } >; stop( sessionId: string, - input?: { source?: 'stop_button'; expectedTurnId?: string }, + input?: { + source?: 'stop_button'; + expectedTurnId?: string; + expectedAdmissionId?: string; + }, ): Promise; steer( sessionId: string, text: string, admissionId?: string, - ): Promise<{ kind: 'queued'; messageId: string } | { kind: 'started'; turnId: string }>; + ): Promise< + | { kind: 'queued'; messageId: string } + | { kind: 'outcome_unknown'; messageId: string } + | { kind: 'started'; turnId: string } + >; enqueue( sessionId: string, placement: 'current_turn' | 'next_turn', diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f3ef4026f5..d9a724e6a5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1551,6 +1551,12 @@ const makaBridge = { reason: 'skill_invocation_failed'; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } + | { + ok: false; + reason: 'outcome_unknown'; + messageId: string; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } > { const session = await runtimeHostSessionRef(sessionId); const send = async (input: SessionCommand | Record) => { @@ -1587,7 +1593,11 @@ const makaBridge = { }, stop( sessionId: string, - input?: { source?: 'stop_button'; expectedTurnId?: string }, + input?: { + source?: 'stop_button'; + expectedTurnId?: string; + expectedAdmissionId?: string; + }, ): Promise { return invokeSessionRuntimeHost('sessions:stop', sessionId, input); }, @@ -1595,7 +1605,11 @@ const makaBridge = { sessionId: string, text: string, admissionId?: string, - ): Promise<{ kind: 'queued'; messageId: string } | { kind: 'started'; turnId: string }> { + ): Promise< + | { kind: 'queued'; messageId: string } + | { kind: 'outcome_unknown'; messageId: string } + | { kind: 'started'; turnId: string } + > { return invokeSessionRuntimeHost('sessions:steer', sessionId, text, admissionId); }, async enqueue( diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 312c9332ae..7c146c7c38 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -198,10 +198,12 @@ export interface WorkbarAttachmentsService { export type SideChatSendResult = | { ok: true; turnId: string; steered?: false } | { ok: true; turnId: string; steered: true; messageId: string } - | { ok: false; reason?: string }; + | { ok: false; reason: 'outcome_unknown'; messageId: string } + | { ok: false; reason?: string; messageId?: never }; export type SideChatSteerResult = | { kind: 'queued'; messageId: string } + | { kind: 'outcome_unknown'; messageId: string } | { kind: 'started'; turnId: string }; export interface SideChatSessionPort { @@ -235,7 +237,7 @@ export interface SideChatSessionPort { attachmentItems?: WorkbarIngestInput[]; }, ): Promise; - stop(sessionId: string): Promise; + stop(sessionId: string, admissionId?: string): Promise; steer(sessionId: string, text: string, admissionId?: string): Promise; setPermissionMode( sessionId: string, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index 4b107e39de..9d0f037ff7 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -286,6 +286,7 @@ export async function ensureCompanionFork( export type CompanionTurnResult = | { status: 'sent'; forkId: string; turnId: string; steered?: false } | { status: 'sent'; forkId: string; turnId: string; steered: true; messageId: string } + | { status: 'pending'; forkId: string; messageId: string } | { status: 'disposed' } | { status: 'error'; code: CompanionErrorCode }; @@ -355,6 +356,9 @@ export async function performCompanionTurn( // run was started, so surface the error and keep the quotes for retry rather // than reporting success and hanging in the processing state. if (!result.ok) { + if (result.reason === 'outcome_unknown' && result.messageId) { + return { status: 'pending', forkId, messageId: result.messageId }; + } return { status: 'error', code: 'send_rejected' }; } deps.onQuotesConsumed(); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index ae87a2a089..25d34d9384 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -62,19 +62,26 @@ import { import type { CompanionForkVisibilityEvent } from './quote-companion-visibility.js'; type PendingAdmission = { + admissionId: string; messageId?: string; events: SessionEvent[]; restoreTurnId: string | null; restoreLiveTurn: LiveTurnProjection | undefined; cancelled: boolean; + consumeOnAdmission?: () => void; stopPromise?: Promise<'confirmed' | 'unknown'>; }; -function admissionEventForMessage( +type AdmissionOutcome = + | { kind: 'admitted'; event: SessionEvent } + | { kind: 'retracted' } + | { kind: 'pending' }; + +function admissionOutcomeForMessage( events: readonly SessionEvent[], messageId: string, -): SessionEvent | undefined { - return events.find( +): AdmissionOutcome { + const admitted = events.find( (event) => ((event.type === 'steering_message' || event.type === 'message_admitted') && event.messageId === messageId) || @@ -83,6 +90,11 @@ function admissionEventForMessage( (entry) => entry.messageId === messageId && entry.state === 'in_flight', ) === true), ); + if (admitted) return { kind: 'admitted', event: admitted }; + const retracted = events.some( + (event) => event.type === 'message_retracted' && event.messageId === messageId, + ); + return retracted ? { kind: 'retracted' } : { kind: 'pending' }; } export interface UseQuoteCompanionInput { @@ -281,6 +293,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan pendingAdmissionRef.current = null; activeTurnIdRef.current = turnId; ownTurnIdsRef.current.add(turnId); + admission.consumeOnAdmission?.(); setError(null); setOwnTurnTick((tick) => tick + 1); if (!(options.preserveLiveTurn && liveTurnRef.current?.turnId === turnId)) { @@ -365,17 +378,10 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (admission) { admission.events.push(event); if (admission.messageId) { - const admitted = admissionEventForMessage(admission.events, admission.messageId); - if (admitted && !admission.cancelled) { - bindAdmittedTurn(forkId, admitted.turnId, { preserveLiveTurn: true }); - } else if ( - event.type === 'queue_update' && - event.steeringEntries && - event.followupEntries && - ![...event.steeringEntries, ...event.followupEntries].some( - (entry) => entry.messageId === admission.messageId, - ) - ) { + const outcome = admissionOutcomeForMessage(admission.events, admission.messageId); + if (outcome.kind === 'admitted' && !admission.cancelled) { + bindAdmittedTurn(forkId, outcome.event.turnId, { preserveLiveTurn: true }); + } else if (outcome.kind === 'retracted') { abandonAdmission(forkId, admission); } } @@ -603,6 +609,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const restoreTurnId = activeTurnIdRef.current; const restoreLiveTurn = liveTurnRef.current; const admission: PendingAdmission = { + admissionId: turnId, events: [], restoreTurnId, restoreLiveTurn, @@ -617,7 +624,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }, onQuotesConsumed: () => onQuotesConsumed(quoteSnapshot), }); - if (result.status === 'sent') { + if (result.status === 'sent' || result.status === 'pending') { const admission = sendAdmission; if (!admission || (admission.cancelled && pendingAdmissionRef.current !== admission)) { if (!pendingAdmissionRef.current) { @@ -634,11 +641,23 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } } if (admission) { - if (result.steered) { + if (result.status === 'pending') { + admission.consumeOnAdmission = () => onQuotesConsumed(quoteSnapshot); + admission.messageId = result.messageId; + const outcome = admissionOutcomeForMessage(admission.events, result.messageId); + if (outcome.kind === 'admitted' && !admission.cancelled) { + bindAdmittedTurn(result.forkId, outcome.event.turnId); + } else if (outcome.kind === 'retracted') { + abandonAdmission(result.forkId, admission); + return false; + } + } else if (result.steered) { admission.messageId = result.messageId; - const admitted = admissionEventForMessage(admission.events, result.messageId); - if (admitted && !admission.cancelled) { - bindAdmittedTurn(result.forkId, admitted.turnId); + const outcome = admissionOutcomeForMessage(admission.events, result.messageId); + if (outcome.kind === 'admitted' && !admission.cancelled) { + bindAdmittedTurn(result.forkId, outcome.event.turnId); + } else if (outcome.kind === 'retracted') { + abandonAdmission(result.forkId, admission); } } else { bindAdmittedTurn(result.forkId, result.turnId); @@ -711,7 +730,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setTurnInFlight(false); } if (admission) { - const stopPromise = sideChat.stop(id).then( + const stopPromise = sideChat.stop(id, admission.admissionId).then( () => 'confirmed' as const, () => { // A rejected Stop tells us nothing about whether the Host stopped @@ -755,13 +774,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ) { return false; } + const admissionId = crypto.randomUUID(); const admission: PendingAdmission = { + admissionId, events: [], restoreTurnId: activeTurnIdRef.current, restoreLiveTurn: liveTurnRef.current, cancelled: false, }; - const admissionId = crypto.randomUUID(); pendingAdmissionRef.current = admission; activeTurnIdRef.current = null; try { @@ -779,9 +799,12 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); } else { admission.messageId = outcome.messageId; - const admitted = admissionEventForMessage(admission.events, outcome.messageId); - if (admitted && !admission.cancelled) { - bindAdmittedTurn(id, admitted.turnId, { preserveLiveTurn: true }); + const resolution = admissionOutcomeForMessage(admission.events, outcome.messageId); + if (resolution.kind === 'admitted' && !admission.cancelled) { + bindAdmittedTurn(id, resolution.event.turnId, { preserveLiveTurn: true }); + } else if (resolution.kind === 'retracted') { + abandonAdmission(id, admission); + return false; } } setError(null); diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 4cc58afaf8..b3aebaa499 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -120,7 +120,11 @@ export function createDesktopWorkbarServices( abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), send: (sessionId, command) => bridge.sessions.send(sessionId, command), - stop: (sessionId) => bridge.sessions.stop(sessionId), + stop: (sessionId, admissionId) => + bridge.sessions.stop( + sessionId, + admissionId ? { source: 'stop_button', expectedAdmissionId: admissionId } : undefined, + ), steer: (sessionId, text, admissionId) => bridge.sessions.steer(sessionId, text, admissionId), setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 46bd9fd93b..5f6ff3480f 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -192,6 +192,7 @@ export type BackendSessionEvent = Exclude< type: | 'queue_update' | 'message_admitted' + | 'message_retracted' | 'permission_request' | 'permission_answer_ack' | 'permission_closure_ack' diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index dfd5311c4d..995ced9df3 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -487,6 +487,7 @@ export type SessionEvent = | TokenUsageEvent | SteeringMessageEvent | MessageAdmittedEvent + | MessageRetractedEvent | QueueUpdateEvent | ProviderRetryEvent | ErrorEvent @@ -1076,6 +1077,12 @@ export interface MessageAdmittedEvent extends BaseEvent { messageId: string; } +/** Transient Host fact that a queued admission was explicitly removed. */ +export interface MessageRetractedEvent extends BaseEvent { + type: 'message_retracted'; + messageId: string; +} + /** * Result of enqueuing a steering / followup message. `fallback` means there was * no active run to attach to (the turn just ended) and the caller should open a diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index ef7a08af35..de7601311b 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -99,7 +99,7 @@ test('emits the Host admission fact when a queued message enters a successor Tur messageId: 'ticket-1', content: { text: 'continue in successor' }, placement: 'current_turn', - state: 'in_flight', + state: 'queued', }, ], followup: [], @@ -153,6 +153,25 @@ test('emits the Host admission fact when a queued message enters a successor Tur })), [{ turnId: 'turn-2', messageId: 'ticket-1' }], ); + + const retracted = new RuntimeHostSessionProjector( + previous, + createRuntimeHostSessionProjectionSeed([], previous), + () => 10, + ).accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ + projectionRevision: 2, + queue: next.queue, + }), + }).events; + assert.deepEqual( + retracted.filter((event) => event.type === 'message_retracted').map((event) => event.messageId), + ['ticket-1'], + ); }); test('reseeds the latest provider retry when the active Turn still carries one', () => { diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index ea806a45ec..b8d8f9a097 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -363,6 +363,7 @@ export class RuntimeHostSessionProjector { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); } const root = next.rootTurn; + events.push(...projectMessageRetractionEvents(previousSnapshot, next, this.#now())); events.push(...projectNewMessageAdmissionEvents(previousSnapshot, next, this.#now())); if (root && queueChanged(previousSnapshot.queue, next.queue)) { for (const entry of newlyInFlight(previousSnapshot.queue, next.queue)) { @@ -477,6 +478,33 @@ function projectNewMessageAdmissionEvents( ); } +function projectMessageRetractionEvents( + previous: SessionContinuitySnapshot, + next: SessionContinuitySnapshot, + ts: number, +): SessionEvent[] { + const root = next.rootTurn ?? previous.rootTurn; + if (!root || previous.queue.hostEpoch !== next.queue.hostEpoch) return []; + const retained = new Set( + [...next.queue.steering, ...next.queue.followup].map((entry) => entry.messageId), + ); + const admitted = new Set(next.rootTurnSourceMessageIds ?? []); + return [...previous.queue.steering, ...previous.queue.followup] + .filter( + (entry) => + entry.state === 'queued' && + !retained.has(entry.messageId) && + !admitted.has(entry.messageId), + ) + .map((entry) => ({ + type: 'message_retracted' as const, + id: `host-retraction:${next.queue.hostEpoch}:${next.queue.queueRevision}:${entry.messageId}`, + turnId: root.turnId, + ts, + messageId: entry.messageId, + })); +} + export function projectRuntimeHostInteractionRequest( interaction: InteractionPendingSnapshot, now: number, diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 1e515bf7ab..71a6703d8c 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -625,20 +625,22 @@ const projectionRunHeader: AgentRunHeader = { describe('SessionEvent projection coverage', () => { test('keeps Host admission facts out of durable Runtime events', () => { - assert.throws( - () => - mapSessionEventToRuntimeEvent( - { - type: 'message_admitted', - id: 'admission-1', - turnId: 'turn-1', - ts: 1, - messageId: 'message-1', - }, - ctx, - ), - /message_admitted is not a backend event/, - ); + for (const type of ['message_admitted', 'message_retracted'] as const) { + assert.throws( + () => + mapSessionEventToRuntimeEvent( + { + type, + id: `${type}-1`, + turnId: 'turn-1', + ts: 1, + messageId: 'message-1', + }, + ctx, + ), + new RegExp(`${type} is not a backend event`), + ); + } }); // The contract is over what a reader can actually meet: every mapped event diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index ca564c6e1a..16b2f57a25 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -129,7 +129,11 @@ export function mapSessionEventToRuntimeEvent( ctx: RuntimeEventMapContext, memory: SessionEventMapMemory = createSessionEventMapMemory(), ): RuntimeEvent { - if (event.type === 'queue_update' || event.type === 'message_admitted') { + if ( + event.type === 'queue_update' || + event.type === 'message_admitted' || + event.type === 'message_retracted' + ) { // These are Host/kernel projection facts, not backend events. The live // ingress drops them, so reaching this line bypassed that authority boundary. throw new Error(`${event.type} is not a backend event`); @@ -145,6 +149,7 @@ export function isLiveBackendSessionEvent(event: SessionEvent): event is Backend return ( event.type !== 'queue_update' && event.type !== 'message_admitted' && + event.type !== 'message_retracted' && !isLegacyPermissionSessionEvent(event) ); } From f245919b460522b82947d8ab26490f7beef136c5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 02:45:33 +0800 Subject: [PATCH 09/26] fix(side-chat): retain admission fencing after bind Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 49 +++++++++++++++++-- ...me-host-session-execution-ipc-main.test.ts | 10 ++++ .../src/main/permission-response-guard.ts | 2 + ...runtime-host-session-execution-ipc-main.ts | 7 +++ apps/desktop/src/preload/bridge-contract.d.ts | 1 + apps/desktop/src/preload/preload.ts | 1 + .../tools/side-chat/use-quote-companion.ts | 45 +++++++---------- .../desktop/create-workbar-services.ts | 3 +- 8 files changed, 87 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index c725d1786e..02e3998c12 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -338,6 +338,7 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a test('binds a busy-raced Side Conversation send through its Host-admitted message identity', async () => { let eventHandler: ((event: SessionEvent) => void) | undefined; let send: ((text: string) => Promise) | undefined; + let admissionId: string | undefined; const pendingSend = deferred<{ ok: true; steered: true; @@ -351,7 +352,10 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag onSeeded?.(); return () => undefined; }, - send: async () => pendingSend.promise, + send: async (_sessionId, command) => { + admissionId = command.turnId; + return pendingSend.promise; + }, }, { ownership: true, onSend: (value) => (send = value) }, ); @@ -369,7 +373,7 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag queueUpdateEvent('accepted-queue', 'host-active-turn', 2, [ { entryId: 'accepted-entry', - messageId: 'accepted-message', + messageId: admissionId as string, content: { text: 'steer the active turn' }, placement: 'current_turn', state: 'queued', @@ -389,7 +393,7 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag ok: true, steered: true, turnId: 'requested-turn-is-not-the-owner', - messageId: 'accepted-message', + messageId: admissionId as string, }); assert.equal(await sendResult, true); await Promise.resolve(); @@ -400,7 +404,12 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag ); await act(async () => { eventHandler?.( - steeringMessageEvent('accepted-steering-message', 'host-active-turn', 2.5, 'accepted-message'), + steeringMessageEvent( + 'accepted-steering-message', + 'host-active-turn', + 2.5, + admissionId as string, + ), ); eventHandler?.(textDeltaEvent('accepted-text', 'host-active-turn', 3, 'answer after steering')); await Promise.resolve(); @@ -581,6 +590,38 @@ test('keeps a Side Conversation admission when Host stop outcome is unknown', as ); }); +test('stops a bound Side Conversation by its exact Host Turn identity', async () => { + let send: ((text: string) => Promise) | undefined; + let stop: (() => Promise) | undefined; + let stoppedAdmissionId: string | undefined; + await renderProbe( + { + subscribeEvents: (_sessionId, _handler, onSeeded) => { + onSeeded?.(); + return () => undefined; + }, + send: async () => ({ ok: true as const, turnId: 'host-turn-1' }), + stop: async (_sessionId, admissionId) => { + stoppedAdmissionId = admissionId; + }, + }, + { + ownership: true, + onSend: (value) => (send = value), + onStop: (value) => (stop = value), + }, + ); + await act(async () => { + assert.equal(await send?.('start this exact turn'), true); + await Promise.resolve(); + }); + await act(async () => { + await stop?.(); + await Promise.resolve(); + }); + assert.equal(stoppedAdmissionId, 'host-turn-1'); +}); + test('releases a queued Side Conversation admission from the Host queue retract', async () => { let eventHandler: ((event: SessionEvent) => void) | undefined; let send: ((text: string) => Promise) | undefined; diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 5f5c6f0b1a..67e69ef8e2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -781,9 +781,19 @@ test("retries a dispatched busy fallback with its original message identity", as inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] }, }); + await assert.rejects( + ipc.invoke("sessions:send", "session-1", { + type: "send", + turnId: "turn-unknown", + text: "ordinary chat keeps the existing failure contract", + }), + (error: unknown) => + error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown', + ); assert.deepEqual( await ipc.invoke("sessions:send", "session-1", { type: "send", + intent: 'side_conversation', turnId: "turn-unknown", text: "keep waiting for the Host outcome", }), diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 4c39f944b5..1546cc39c1 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -52,6 +52,7 @@ export type RuntimeHostReviseBeforeTurnInput = ReviseBeforeTurnInput & { copyId: interface NormalizedSendSessionCommand { type: 'send'; + intent?: 'side_conversation'; turnId?: string; text: string; displayText?: string; @@ -178,6 +179,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi } return { type: 'send', + ...(value.intent === 'side_conversation' ? { intent: 'side_conversation' as const } : {}), ...normalizeOptionalSendTurnId(value.turnId), text, ...(displayText !== undefined ? { displayText } : {}), diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 7520a9d20b..14038766fa 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -350,6 +350,13 @@ export function registerRuntimeHostSessionExecutionIpc( placement: "current_turn", }); if (!submitted) { + if (command.intent !== 'side_conversation') { + throw new RuntimeHostOperationError( + 'turn.message.submit', + 'outcome_unknown', + 'Message disposition cannot be proven in this Host Epoch', + ); + } return { ok: false as const, reason: 'outcome_unknown' as const, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 6eba5804e1..06e483ab93 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -722,6 +722,7 @@ export interface MakaBridge { | SessionCommand | { type: 'send'; + intent?: 'side_conversation'; turnId: string; text: string; displayText?: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d9a724e6a5..d71c52e0eb 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1528,6 +1528,7 @@ const makaBridge = { | SessionCommand | { type: 'send'; + intent?: 'side_conversation'; turnId: string; text: string; displayText?: string; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 25d34d9384..dceaa13a09 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -62,8 +62,7 @@ import { import type { CompanionForkVisibilityEvent } from './quote-companion-visibility.js'; type PendingAdmission = { - admissionId: string; - messageId?: string; + messageId: string; events: SessionEvent[]; restoreTurnId: string | null; restoreLiveTurn: LiveTurnProjection | undefined; @@ -74,13 +73,12 @@ type PendingAdmission = { type AdmissionOutcome = | { kind: 'admitted'; event: SessionEvent } - | { kind: 'retracted' } - | { kind: 'pending' }; + | { kind: 'retracted' }; function admissionOutcomeForMessage( events: readonly SessionEvent[], messageId: string, -): AdmissionOutcome { +): AdmissionOutcome | undefined { const admitted = events.find( (event) => ((event.type === 'steering_message' || event.type === 'message_admitted') && @@ -94,7 +92,7 @@ function admissionOutcomeForMessage( const retracted = events.some( (event) => event.type === 'message_retracted' && event.messageId === messageId, ); - return retracted ? { kind: 'retracted' } : { kind: 'pending' }; + return retracted ? { kind: 'retracted' } : undefined; } export interface UseQuoteCompanionInput { @@ -377,13 +375,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } if (admission) { admission.events.push(event); - if (admission.messageId) { - const outcome = admissionOutcomeForMessage(admission.events, admission.messageId); - if (outcome.kind === 'admitted' && !admission.cancelled) { - bindAdmittedTurn(forkId, outcome.event.turnId, { preserveLiveTurn: true }); - } else if (outcome.kind === 'retracted') { - abandonAdmission(forkId, admission); - } + const outcome = admissionOutcomeForMessage(admission.events, admission.messageId); + if (outcome?.kind === 'admitted' && !admission.cancelled) { + bindAdmittedTurn(forkId, outcome.event.turnId, { preserveLiveTurn: true }); + } else if (outcome?.kind === 'retracted') { + abandonAdmission(forkId, admission); } return; } @@ -609,7 +605,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const restoreTurnId = activeTurnIdRef.current; const restoreLiveTurn = liveTurnRef.current; const admission: PendingAdmission = { - admissionId: turnId, + messageId: turnId, events: [], restoreTurnId, restoreLiveTurn, @@ -643,20 +639,18 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (admission) { if (result.status === 'pending') { admission.consumeOnAdmission = () => onQuotesConsumed(quoteSnapshot); - admission.messageId = result.messageId; const outcome = admissionOutcomeForMessage(admission.events, result.messageId); - if (outcome.kind === 'admitted' && !admission.cancelled) { + if (outcome?.kind === 'admitted' && !admission.cancelled) { bindAdmittedTurn(result.forkId, outcome.event.turnId); - } else if (outcome.kind === 'retracted') { + } else if (outcome?.kind === 'retracted') { abandonAdmission(result.forkId, admission); return false; } } else if (result.steered) { - admission.messageId = result.messageId; const outcome = admissionOutcomeForMessage(admission.events, result.messageId); - if (outcome.kind === 'admitted' && !admission.cancelled) { + if (outcome?.kind === 'admitted' && !admission.cancelled) { bindAdmittedTurn(result.forkId, outcome.event.turnId); - } else if (outcome.kind === 'retracted') { + } else if (outcome?.kind === 'retracted') { abandonAdmission(result.forkId, admission); } } else { @@ -730,7 +724,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setTurnInFlight(false); } if (admission) { - const stopPromise = sideChat.stop(id, admission.admissionId).then( + const stopPromise = sideChat.stop(id, admission.messageId).then( () => 'confirmed' as const, () => { // A rejected Stop tells us nothing about whether the Host stopped @@ -755,7 +749,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan return; } try { - await sideChat.stop(id); + await sideChat.stop(id, activeTurnIdRef.current ?? undefined); } catch { stopRequestedRef.current = false; // best-effort; the terminal event still reconciles state @@ -776,7 +770,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } const admissionId = crypto.randomUUID(); const admission: PendingAdmission = { - admissionId, + messageId: admissionId, events: [], restoreTurnId: activeTurnIdRef.current, restoreLiveTurn: liveTurnRef.current, @@ -798,11 +792,10 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (outcome.kind === 'started') { bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); } else { - admission.messageId = outcome.messageId; const resolution = admissionOutcomeForMessage(admission.events, outcome.messageId); - if (resolution.kind === 'admitted' && !admission.cancelled) { + if (resolution?.kind === 'admitted' && !admission.cancelled) { bindAdmittedTurn(id, resolution.event.turnId, { preserveLiveTurn: true }); - } else if (resolution.kind === 'retracted') { + } else if (resolution?.kind === 'retracted') { abandonAdmission(id, admission); return false; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index b3aebaa499..f2e49eb748 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -119,7 +119,8 @@ export function createDesktopWorkbarServices( bridge.sessions.cleanupSessionCopy(sessionId), abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), - send: (sessionId, command) => bridge.sessions.send(sessionId, command), + send: (sessionId, command) => + bridge.sessions.send(sessionId, { ...command, intent: 'side_conversation' }), stop: (sessionId, admissionId) => bridge.sessions.stop( sessionId, From bef381a41311fe404712dac676b4cb0043c6a635 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 02:50:12 +0800 Subject: [PATCH 10/26] refactor(side-chat): unify admission outcome events Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 5 +-- .../tools/side-chat/use-quote-companion.ts | 9 ++++-- packages/core/src/backend-types.ts | 3 +- packages/core/src/events.ts | 14 +++------ .../src/__tests__/session-projector.test.ts | 22 ++++++++++--- .../src/adapter/session-projector.ts | 6 ++-- .../session-event-runtime-mapper.test.ts | 31 +++++++++---------- .../src/session-event-runtime-mapper.ts | 9 ++---- 8 files changed, 53 insertions(+), 46 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 02e3998c12..ab0e7a3389 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -94,7 +94,7 @@ function messageAdmittedEvent( ts: number, messageId: string, ): SessionEvent { - return { type: 'message_admitted', id, messageId, turnId, ts }; + return { type: 'message_admission', id, messageId, turnId, ts, outcome: 'admitted' }; } function recoverableErrorEvent(id: string, turnId: string, ts: number): SessionEvent { @@ -656,11 +656,12 @@ test('releases a queued Side Conversation admission from the Host queue retract' await act(async () => { eventHandler?.({ - type: 'message_retracted', + type: 'message_admission', id: 'retracted-admission', turnId: 'old-turn', ts: 1, messageId: 'retracted-message', + outcome: 'retracted', }); await Promise.resolve(); }); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index dceaa13a09..b23d2e9005 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -81,7 +81,9 @@ function admissionOutcomeForMessage( ): AdmissionOutcome | undefined { const admitted = events.find( (event) => - ((event.type === 'steering_message' || event.type === 'message_admitted') && + (event.type === 'steering_message' && event.messageId === messageId) || + (event.type === 'message_admission' && + event.outcome === 'admitted' && event.messageId === messageId) || (event.type === 'queue_update' && event.steeringEntries?.some( @@ -90,7 +92,10 @@ function admissionOutcomeForMessage( ); if (admitted) return { kind: 'admitted', event: admitted }; const retracted = events.some( - (event) => event.type === 'message_retracted' && event.messageId === messageId, + (event) => + event.type === 'message_admission' && + event.outcome === 'retracted' && + event.messageId === messageId, ); return retracted ? { kind: 'retracted' } : undefined; } diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 5f6ff3480f..46262ca9f2 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -191,8 +191,7 @@ export type BackendSessionEvent = Exclude< { type: | 'queue_update' - | 'message_admitted' - | 'message_retracted' + | 'message_admission' | 'permission_request' | 'permission_answer_ack' | 'permission_closure_ack' diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 995ced9df3..bea135e5fd 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -486,8 +486,7 @@ export type SessionEvent = | PlanSubmittedEvent | TokenUsageEvent | SteeringMessageEvent - | MessageAdmittedEvent - | MessageRetractedEvent + | MessageAdmissionEvent | QueueUpdateEvent | ProviderRetryEvent | ErrorEvent @@ -1072,15 +1071,10 @@ export interface SteeringMessageEvent extends BaseEvent { * event ledger, so a client can bind a queued admission without guessing from * timing or Turn ids returned by a stale command response. */ -export interface MessageAdmittedEvent extends BaseEvent { - type: 'message_admitted'; - messageId: string; -} - -/** Transient Host fact that a queued admission was explicitly removed. */ -export interface MessageRetractedEvent extends BaseEvent { - type: 'message_retracted'; +export interface MessageAdmissionEvent extends BaseEvent { + type: 'message_admission'; messageId: string; + outcome: 'admitted' | 'retracted'; } /** diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index de7601311b..5c06aea4f1 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import type { SessionEvent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; import { createRuntimeHostSessionProjectionSeed, @@ -81,7 +82,10 @@ test('emits the Host admission fact when a queued message enters a successor Tur assert.deepEqual( rejoined .seedActive(false) - .filter((event) => event.type === 'message_admitted') + .filter( + (event): event is Extract => + event.type === 'message_admission' && event.outcome === 'admitted', + ) .map((event) => ({ turnId: event.turnId, messageId: event.messageId, @@ -139,14 +143,17 @@ test('emits the Host admission fact when a queued message enters a successor Tur assert.deepEqual( events - .filter((event) => event.type === 'message_admitted' || event.type === 'queue_update') + .filter((event) => event.type === 'message_admission' || event.type === 'queue_update') .map((event) => event.type), - ['message_admitted', 'queue_update'], + ['message_admission', 'queue_update'], ); assert.deepEqual( events - .filter((event) => event.type === 'message_admitted') + .filter( + (event): event is Extract => + event.type === 'message_admission' && event.outcome === 'admitted', + ) .map((event) => ({ turnId: event.turnId, messageId: event.messageId, @@ -169,7 +176,12 @@ test('emits the Host admission fact when a queued message enters a successor Tur }), }).events; assert.deepEqual( - retracted.filter((event) => event.type === 'message_retracted').map((event) => event.messageId), + retracted + .filter( + (event): event is Extract => + event.type === 'message_admission' && event.outcome === 'retracted', + ) + .map((event) => event.messageId), ['ticket-1'], ); }); diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index b8d8f9a097..ad165e4ec7 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -452,11 +452,12 @@ function projectMessageAdmissionEvents( ts: number, ): SessionEvent[] { return (messageIds ?? []).map((messageId) => ({ - type: 'message_admitted' as const, + type: 'message_admission' as const, id: `host-admission:${root.runId}:${messageId}`, turnId: root.turnId, ts, messageId, + outcome: 'admitted' as const, })); } @@ -497,11 +498,12 @@ function projectMessageRetractionEvents( !admitted.has(entry.messageId), ) .map((entry) => ({ - type: 'message_retracted' as const, + type: 'message_admission' as const, id: `host-retraction:${next.queue.hostEpoch}:${next.queue.queueRevision}:${entry.messageId}`, turnId: root.turnId, ts, messageId: entry.messageId, + outcome: 'retracted' as const, })); } diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 71a6703d8c..045f94d21b 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -625,22 +625,21 @@ const projectionRunHeader: AgentRunHeader = { describe('SessionEvent projection coverage', () => { test('keeps Host admission facts out of durable Runtime events', () => { - for (const type of ['message_admitted', 'message_retracted'] as const) { - assert.throws( - () => - mapSessionEventToRuntimeEvent( - { - type, - id: `${type}-1`, - turnId: 'turn-1', - ts: 1, - messageId: 'message-1', - }, - ctx, - ), - new RegExp(`${type} is not a backend event`), - ); - } + assert.throws( + () => + mapSessionEventToRuntimeEvent( + { + type: 'message_admission', + id: 'message-admission-1', + turnId: 'turn-1', + ts: 1, + messageId: 'message-1', + outcome: 'admitted', + }, + ctx, + ), + /message_admission is not a backend event/, + ); }); // The contract is over what a reader can actually meet: every mapped event diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 16b2f57a25..3044d110f9 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -129,11 +129,7 @@ export function mapSessionEventToRuntimeEvent( ctx: RuntimeEventMapContext, memory: SessionEventMapMemory = createSessionEventMapMemory(), ): RuntimeEvent { - if ( - event.type === 'queue_update' || - event.type === 'message_admitted' || - event.type === 'message_retracted' - ) { + if (event.type === 'queue_update' || event.type === 'message_admission') { // These are Host/kernel projection facts, not backend events. The live // ingress drops them, so reaching this line bypassed that authority boundary. throw new Error(`${event.type} is not a backend event`); @@ -148,8 +144,7 @@ export function mapSessionEventToRuntimeEvent( export function isLiveBackendSessionEvent(event: SessionEvent): event is BackendSessionEvent { return ( event.type !== 'queue_update' && - event.type !== 'message_admitted' && - event.type !== 'message_retracted' && + event.type !== 'message_admission' && !isLegacyPermissionSessionEvent(event) ); } From 60d09f7a303d66cca68dceed37d2e6c71401802c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 02:53:53 +0800 Subject: [PATCH 11/26] fix(side-chat): isolate admission retry semantics Generated-by: Codex --- ...me-host-session-execution-ipc-main.test.ts | 53 +++++++++++++++++-- ...runtime-host-session-execution-ipc-main.ts | 38 ++++++------- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 67e69ef8e2..fab1dbde09 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -612,7 +612,7 @@ test("queues a mid-turn send as steering when the Host reports the session busy" assert.deepEqual(submits, [ { sessionId: "session-1", - messageId: "turn-1", + messageId: "id-1", content: { text: "also check the tests", inlineReferences: [] }, placement: "current_turn", }, @@ -621,7 +621,6 @@ test("queues a mid-turn send as steering when the Host reports the session busy" ok: true, steered: true, turnId: "turn-1", - messageId: "turn-1", attachments: [], inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] }, @@ -677,6 +676,7 @@ test("retries a dispatched normal send with its original Turn identity", async ( const result = await ipc.invoke("sessions:send", "session-1", { type: "send", + intent: "side_conversation", text: "keep this Turn identity", }); @@ -702,6 +702,49 @@ test("retries a dispatched normal send with its original Turn identity", async ( }); }); +test("does not add admission retry semantics to an ordinary send", async () => { + let starts = 0; + let sessionQueries = 0; + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => { + sessionQueries += 1; + return session(); + }, + startTurn: async () => { + starts += 1; + throw new RuntimeHostRequestInterruptedError( + "turn.start", + "command", + "dispatched", + "connection_lost", + ); + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + newId: () => "turn-1", + }, + ipc, + ); + + await assert.rejects( + ipc.invoke("sessions:send", "session-1", { + type: "send", + text: "preserve the ordinary send contract", + }), + RuntimeHostRequestInterruptedError, + ); + assert.equal(starts, 1); + assert.equal(sessionQueries, 1, "only the initial Session lookup runs"); +}); + test("retries a dispatched busy fallback with its original message identity", async () => { const submits: unknown[] = []; let reconnectQueries = 0; @@ -722,7 +765,10 @@ test("retries a dispatched busy fallback with its original message identity", as }, submitMessage: async (input) => { submits.push(input); - if (input.messageId === "turn-unknown") { + if ( + input.messageId === "turn-unknown" || + input.content.text === "ordinary chat keeps the existing failure contract" + ) { throw new RuntimeHostOperationError( "turn.message.submit", "outcome_unknown", @@ -753,6 +799,7 @@ test("retries a dispatched busy fallback with its original message identity", as const result = await ipc.invoke("sessions:send", "session-1", { type: "send", + intent: "side_conversation", turnId: "turn-1", text: "keep this message identity", }); diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 14038766fa..18b766e5fe 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -314,10 +314,13 @@ export function registerRuntimeHostSessionExecutionIpc( }; let startResult; try { - startResult = await retryDispatchedCommand( - () => deps.client.startTurn(startInput), - () => deps.client.getSession(sessionId), - ); + startResult = + command.intent === 'side_conversation' + ? await retryDispatchedCommand( + () => deps.client.startTurn(startInput), + () => deps.client.getSession(sessionId), + ) + : await deps.client.startTurn(startInput); } catch (error) { // The renderer routes text at a session it sees as running to // `sessions:steer`, but its view can lag the Host: another window, a @@ -338,25 +341,22 @@ export function registerRuntimeHostSessionExecutionIpc( ) { throw error; } - // The requested Turn id is the submission/admission ticket. If the - // busy fallback queues this message and a successor root consumes it, - // the Host can report ownership with the same identity. - const messageId = turnId; + // Side Conversation keeps its requested Turn id as the admission + // ticket so a successor root can report ownership with that identity. + // Ordinary sends retain the pre-existing independent message id. + const sideConversation = command.intent === 'side_conversation'; + const messageId = sideConversation ? turnId : newId(); const emptySkillInvocation = { loaded: [], failed: [], receipts: [] }; - const submitted = await submitMessageWithReconnect(deps.client, { + const submitInput = { sessionId, messageId, content: startInput.content, - placement: "current_turn", - }); + placement: 'current_turn' as const, + }; + const submitted = sideConversation + ? await submitMessageWithReconnect(deps.client, submitInput) + : await deps.client.submitMessage(submitInput); if (!submitted) { - if (command.intent !== 'side_conversation') { - throw new RuntimeHostOperationError( - 'turn.message.submit', - 'outcome_unknown', - 'Message disposition cannot be proven in this Host Epoch', - ); - } return { ok: false as const, reason: 'outcome_unknown' as const, @@ -383,7 +383,7 @@ export function registerRuntimeHostSessionExecutionIpc( ok: true as const, steered: true as const, turnId, - messageId, + ...(sideConversation ? { messageId } : {}), attachments, inlineReferences, skillInvocation: emptySkillInvocation, From 43974b02453df021364cb054325de3a415eab206 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 03:13:33 +0800 Subject: [PATCH 12/26] refactor(side-chat): centralize admission transitions Generated-by: Codex --- .../tools/side-chat/use-quote-companion.ts | 71 +++++++++---------- 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index b23d2e9005..2092ba48ef 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -72,7 +72,7 @@ type PendingAdmission = { }; type AdmissionOutcome = - | { kind: 'admitted'; event: SessionEvent } + | { kind: 'admitted'; turnId: string } | { kind: 'retracted' }; function admissionOutcomeForMessage( @@ -90,7 +90,7 @@ function admissionOutcomeForMessage( (entry) => entry.messageId === messageId && entry.state === 'in_flight', ) === true), ); - if (admitted) return { kind: 'admitted', event: admitted }; + if (admitted) return { kind: 'admitted', turnId: admitted.turnId }; const retracted = events.some( (event) => event.type === 'message_admission' && @@ -328,6 +328,24 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan [applyOwnedEvent], ); + const resolveAdmission = useCallback( + ( + forkId: string, + admission: PendingAdmission, + messageId: string, + preserveLiveTurn = false, + ): AdmissionOutcome | undefined => { + const outcome = admissionOutcomeForMessage(admission.events, messageId); + if (outcome?.kind === 'admitted' && !admission.cancelled) { + bindAdmittedTurn(forkId, outcome.turnId, { preserveLiveTurn }); + } else if (outcome?.kind === 'retracted') { + abandonAdmission(forkId, admission); + } + return outcome; + }, + [abandonAdmission, bindAdmittedTurn], + ); + // Subscribe to the fork's event stream + load its transcript. Called // synchronously the moment the fork is committed, BEFORE the run starts, so // no boundary request / complete can be missed (the stream has no replay). @@ -380,12 +398,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } if (admission) { admission.events.push(event); - const outcome = admissionOutcomeForMessage(admission.events, admission.messageId); - if (outcome?.kind === 'admitted' && !admission.cancelled) { - bindAdmittedTurn(forkId, outcome.event.turnId, { preserveLiveTurn: true }); - } else if (outcome?.kind === 'retracted') { - abandonAdmission(forkId, admission); - } + resolveAdmission(forkId, admission, admission.messageId, true); return; } applyOwnedEvent(forkId, event); @@ -403,7 +416,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan resolveReady(); }; return ready; - }, [abandonAdmission, applyOwnedEvent, bindAdmittedTurn, mountedRef, sideChat]); + }, [applyOwnedEvent, mountedRef, resolveAdmission, sideChat]); const commitFork = useCallback( (session: SessionSummary) => { @@ -641,26 +654,15 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan return false; } } - if (admission) { - if (result.status === 'pending') { - admission.consumeOnAdmission = () => onQuotesConsumed(quoteSnapshot); - const outcome = admissionOutcomeForMessage(admission.events, result.messageId); - if (outcome?.kind === 'admitted' && !admission.cancelled) { - bindAdmittedTurn(result.forkId, outcome.event.turnId); - } else if (outcome?.kind === 'retracted') { - abandonAdmission(result.forkId, admission); - return false; - } - } else if (result.steered) { - const outcome = admissionOutcomeForMessage(admission.events, result.messageId); - if (outcome?.kind === 'admitted' && !admission.cancelled) { - bindAdmittedTurn(result.forkId, outcome.event.turnId); - } else if (outcome?.kind === 'retracted') { - abandonAdmission(result.forkId, admission); - } - } else { - bindAdmittedTurn(result.forkId, result.turnId); + if (result.status === 'pending') { + admission.consumeOnAdmission = () => onQuotesConsumed(quoteSnapshot); + if (resolveAdmission(result.forkId, admission, result.messageId)?.kind === 'retracted') { + return false; } + } else if (result.steered) { + resolveAdmission(result.forkId, admission, result.messageId); + } else { + bindAdmittedTurn(result.forkId, result.turnId); } setHasContent(true); // Surface the just-sent user message immediately, and reflect any @@ -714,6 +716,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan sideChat, abandonAdmission, bindAdmittedTurn, + resolveAdmission, ], ); @@ -796,14 +799,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } if (outcome.kind === 'started') { bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); - } else { - const resolution = admissionOutcomeForMessage(admission.events, outcome.messageId); - if (resolution?.kind === 'admitted' && !admission.cancelled) { - bindAdmittedTurn(id, resolution.event.turnId, { preserveLiveTurn: true }); - } else if (resolution?.kind === 'retracted') { - abandonAdmission(id, admission); - return false; - } + } else if (resolveAdmission(id, admission, outcome.messageId, true)?.kind === 'retracted') { + return false; } setError(null); return true; @@ -817,7 +814,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } return false; } - }, [abandonAdmission, bindAdmittedTurn, mountedRef, sideChat, turnInFlight]); + }, [abandonAdmission, bindAdmittedTurn, mountedRef, resolveAdmission, sideChat, turnInFlight]); const setPermissionMode = useCallback( async (mode: PermissionMode): Promise => { From 7d77d312a6091fe292c64de6d9fdfee756723188 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 03:13:33 +0800 Subject: [PATCH 13/26] test(side-chat): compress admission race fixtures Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 378 +++++++----------- ...me-host-session-execution-ipc-main.test.ts | 87 ++-- 2 files changed, 187 insertions(+), 278 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index ab0e7a3389..b56b7ad7d9 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -170,6 +170,43 @@ async function renderProbe( return { container, root, services }; } +async function renderOwnershipProbe(sideChat: Partial) { + let send!: (text: string) => Promise; + let steer!: (text: string) => Promise; + let stop!: () => Promise; + let eventHandler: ((event: SessionEvent) => void) | undefined; + const subscribeEvents = sideChat.subscribeEvents; + const rendered = await renderProbe( + { + ...sideChat, + subscribeEvents: (sessionId, handler, onSeeded, onSeedError) => { + eventHandler = handler; + if (subscribeEvents) { + return subscribeEvents(sessionId, handler, onSeeded, onSeedError); + } + onSeeded?.(); + return () => undefined; + }, + }, + { + ownership: true, + onSend: (value) => (send = value), + onSteer: (value) => (steer = value), + onStop: (value) => (stop = value), + }, + ); + return { + ...rendered, + send, + steer, + stop, + emit(event: SessionEvent) { + assert.ok(eventHandler); + eventHandler(event); + }, + }; +} + afterEach(async () => { if (mountedRoot) { await act(async () => { @@ -286,37 +323,25 @@ test('does not restart foreground setup when the source Session object refreshes }); test('keeps Side Conversation events owned by the Host-admitted turn across an admission race', async () => { - let eventHandler: ((event: SessionEvent) => void) | undefined; - let send: ((text: string) => Promise) | undefined; const pendingSend = deferred<{ ok: true; turnId: string }>(); - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, handler, onSeeded) => { - eventHandler = handler; - onSeeded?.(); - return () => undefined; - }, - send: async () => pendingSend.promise, - }, - { ownership: true, onSend: (value) => (send = value) }, - ); - assert.ok(send); - assert.ok(eventHandler); + const { container, emit, send } = await renderOwnershipProbe({ + send: async () => pendingSend.promise, + }); - let sendResult: Promise | undefined; + let sendResult!: Promise; await act(async () => { - sendResult = send?.('new prompt'); + sendResult = send('new prompt'); await Promise.resolve(); }); await act(async () => { - eventHandler?.(completeEvent('late-old-terminal', 'old-turn', 1)); + emit(completeEvent('late-old-terminal', 'old-turn', 1)); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { - eventHandler?.(textDeltaEvent('new-text-before-response', 'host-admitted-turn', 2, 'answer')); + emit(textDeltaEvent('new-text-before-response', 'host-admitted-turn', 2, 'answer')); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); @@ -336,8 +361,6 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a }); test('binds a busy-raced Side Conversation send through its Host-admitted message identity', async () => { - let eventHandler: ((event: SessionEvent) => void) | undefined; - let send: ((text: string) => Promise) | undefined; let admissionId: string | undefined; const pendingSend = deferred<{ ok: true; @@ -345,31 +368,21 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag turnId: string; messageId: string; }>(); - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, handler, onSeeded) => { - eventHandler = handler; - onSeeded?.(); - return () => undefined; - }, - send: async (_sessionId, command) => { - admissionId = command.turnId; - return pendingSend.promise; - }, + const { container, emit, send } = await renderOwnershipProbe({ + send: async (_sessionId, command) => { + admissionId = command.turnId; + return pendingSend.promise; }, - { ownership: true, onSend: (value) => (send = value) }, - ); - assert.ok(send); - assert.ok(eventHandler); + }); - let sendResult: Promise | undefined; + let sendResult!: Promise; await act(async () => { - sendResult = send?.('steer the active turn'); + sendResult = send('steer the active turn'); await Promise.resolve(); }); await act(async () => { - eventHandler?.(completeEvent('late-old-terminal', 'old-turn', 1)); - eventHandler?.( + emit(completeEvent('late-old-terminal', 'old-turn', 1)); + emit( queueUpdateEvent('accepted-queue', 'host-active-turn', 2, [ { entryId: 'accepted-entry', @@ -403,7 +416,7 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag 'host-active-turn', ); await act(async () => { - eventHandler?.( + emit( steeringMessageEvent( 'accepted-steering-message', 'host-active-turn', @@ -411,7 +424,7 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag admissionId as string, ), ); - eventHandler?.(textDeltaEvent('accepted-text', 'host-active-turn', 3, 'answer after steering')); + emit(textDeltaEvent('accepted-text', 'host-active-turn', 3, 'answer after steering')); await Promise.resolve(); }); @@ -424,36 +437,24 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag }); test('replays queued Side Conversation text after Host assigns the ticket to a successor Turn', async () => { - let eventHandler: ((event: SessionEvent) => void) | undefined; - let send: ((text: string) => Promise) | undefined; const pendingSend = deferred<{ ok: false; reason: 'outcome_unknown'; messageId: string; }>(); - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, handler, onSeeded) => { - eventHandler = handler; - onSeeded?.(); - return () => undefined; - }, - send: async () => pendingSend.promise, - }, - { ownership: true, onSend: (value) => (send = value) }, - ); - assert.ok(send); - assert.ok(eventHandler); + const { container, emit, send } = await renderOwnershipProbe({ + send: async () => pendingSend.promise, + }); - let sendResult: Promise | undefined; + let sendResult!: Promise; await act(async () => { - sendResult = send?.('continue in the successor turn'); + sendResult = send('continue in the successor turn'); await Promise.resolve(); }); await act(async () => { - eventHandler?.(messageAdmittedEvent('successor-admission', 'successor-root', 1, 'ticket-1')); - eventHandler?.(queueUpdateEvent('successor-queue', 'successor-root', 2)); - eventHandler?.(textDeltaEvent('successor-text', 'successor-root', 3, 'answer from successor')); + emit(messageAdmittedEvent('successor-admission', 'successor-root', 1, 'ticket-1')); + emit(queueUpdateEvent('successor-queue', 'successor-root', 2)); + emit(textDeltaEvent('successor-text', 'successor-root', 3, 'answer from successor')); await Promise.resolve(); }); @@ -475,8 +476,6 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s }); test('clears a queued Side Conversation send when Host stop cancels the admission', async () => { - let send: ((text: string) => Promise) | undefined; - let stop: (() => Promise) | undefined; let admissionId: string | undefined; const pendingStop = deferred(); const pendingSend = deferred<{ @@ -485,38 +484,25 @@ test('clears a queued Side Conversation send when Host stop cancels the admissio turnId: string; messageId: string; }>(); - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, _handler, onSeeded) => { - onSeeded?.(); - return () => undefined; - }, - send: async (_sessionId, command) => { - admissionId = command.turnId; - return pendingSend.promise; - }, - stop: async (_sessionId, expectedAdmissionId) => { - assert.equal(expectedAdmissionId, admissionId); - return pendingStop.promise; - }, + const { container, send, stop } = await renderOwnershipProbe({ + send: async (_sessionId, command) => { + admissionId = command.turnId; + return pendingSend.promise; }, - { - ownership: true, - onSend: (value) => (send = value), - onStop: (value) => (stop = value), + stop: async (_sessionId, expectedAdmissionId) => { + assert.equal(expectedAdmissionId, admissionId); + return pendingStop.promise; }, - ); - assert.ok(send); - assert.ok(stop); + }); - let sendResult: Promise | undefined; + let sendResult!: Promise; await act(async () => { - sendResult = send?.('stop this queued send'); + sendResult = send('stop this queued send'); await Promise.resolve(); }); - let stopResult: Promise | undefined; + let stopResult!: Promise; await act(async () => { - stopResult = stop?.(); + stopResult = stop(); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); @@ -543,38 +529,21 @@ test('clears a queued Side Conversation send when Host stop cancels the admissio }); test('keeps a Side Conversation admission when Host stop outcome is unknown', async () => { - let send: ((text: string) => Promise) | undefined; - let stop: (() => Promise) | undefined; const pendingSend = deferred<{ ok: true; turnId: string }>(); - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, _handler, onSeeded) => { - onSeeded?.(); - return () => undefined; - }, - send: async () => pendingSend.promise, - stop: async () => { - throw new Error('Host stop result is unknown'); - }, - }, - { - ownership: true, - onSend: (value) => (send = value), - onStop: (value) => (stop = value), + const { container, send, stop } = await renderOwnershipProbe({ + send: async () => pendingSend.promise, + stop: async () => { + throw new Error('Host stop result is unknown'); }, - ); - assert.ok(send); - assert.ok(stop); + }); - let sendResult: Promise | undefined; + let sendResult!: Promise; await act(async () => { - sendResult = send?.('keep this admission'); + sendResult = send('keep this admission'); await Promise.resolve(); }); - let stopResult: Promise | undefined; await act(async () => { - stopResult = stop?.(); - await stopResult; + await stop(); await Promise.resolve(); }); @@ -591,71 +560,43 @@ test('keeps a Side Conversation admission when Host stop outcome is unknown', as }); test('stops a bound Side Conversation by its exact Host Turn identity', async () => { - let send: ((text: string) => Promise) | undefined; - let stop: (() => Promise) | undefined; let stoppedAdmissionId: string | undefined; - await renderProbe( - { - subscribeEvents: (_sessionId, _handler, onSeeded) => { - onSeeded?.(); - return () => undefined; - }, - send: async () => ({ ok: true as const, turnId: 'host-turn-1' }), - stop: async (_sessionId, admissionId) => { - stoppedAdmissionId = admissionId; - }, + const { send, stop } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'host-turn-1' }), + stop: async (_sessionId, admissionId) => { + stoppedAdmissionId = admissionId; }, - { - ownership: true, - onSend: (value) => (send = value), - onStop: (value) => (stop = value), - }, - ); + }); await act(async () => { - assert.equal(await send?.('start this exact turn'), true); + assert.equal(await send('start this exact turn'), true); await Promise.resolve(); }); await act(async () => { - await stop?.(); + await stop(); await Promise.resolve(); }); assert.equal(stoppedAdmissionId, 'host-turn-1'); }); test('releases a queued Side Conversation admission from the Host queue retract', async () => { - let eventHandler: ((event: SessionEvent) => void) | undefined; - let send: ((text: string) => Promise) | undefined; const pendingSend = deferred<{ ok: true; steered: true; turnId: string; messageId: string; }>(); - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, handler, onSeeded) => { - eventHandler = handler; - onSeeded?.(); - return () => undefined; - }, - send: async () => pendingSend.promise, - }, - { - ownership: true, - onSend: (value) => (send = value), - }, - ); - assert.ok(send); - assert.ok(eventHandler); + const { container, emit, send } = await renderOwnershipProbe({ + send: async () => pendingSend.promise, + }); await act(async () => { - void send?.('retract this queued send'); + void send('retract this queued send'); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { - eventHandler?.({ + emit({ type: 'message_admission', id: 'retracted-admission', turnId: 'old-turn', @@ -680,33 +621,26 @@ test('releases a queued Side Conversation admission from the Host queue retract' }); test('keeps the same Side Conversation admission across a recoverable subscription error', async () => { - let send: ((text: string) => Promise) | undefined; - let eventHandler: ((event: SessionEvent) => void) | undefined; let subscriptionCount = 0; const pendingSend = deferred<{ ok: true; turnId: string }>(); - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, handler, onSeeded) => { - subscriptionCount += 1; - eventHandler = handler; - onSeeded?.(); - return () => undefined; - }, - send: async () => pendingSend.promise, + const { container, emit, send } = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded) => { + subscriptionCount += 1; + onSeeded?.(); + return () => undefined; }, - { ownership: true, onSend: (value) => (send = value) }, - ); - assert.ok(send); + send: async () => pendingSend.promise, + }); assert.equal(subscriptionCount, 1); - let sendResult: Promise | undefined; + let sendResult!: Promise; await act(async () => { - sendResult = send?.('survive a recoverable stream error'); + sendResult = send('survive a recoverable stream error'); await Promise.resolve(); }); await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); await act(async () => { - eventHandler?.(recoverableErrorEvent('recoverable-subscription-error', 'old-turn', 1)); + emit(recoverableErrorEvent('recoverable-subscription-error', 'old-turn', 1)); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); @@ -718,58 +652,38 @@ test('keeps the same Side Conversation admission across a recoverable subscripti await Promise.resolve(); }); await act(async () => { - eventHandler?.(completeEvent('late-complete', 'late-turn', 2)); + emit(completeEvent('late-complete', 'late-turn', 2)); await Promise.resolve(); }); await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'false'); }); test('cancels a pending Side Conversation steer after Host stop without losing the old Turn', async () => { - let send: ((text: string) => Promise) | undefined; - let steer: ((text: string) => Promise) | undefined; - let stop: (() => Promise) | undefined; const pendingSteer = deferred<{ kind: 'queued'; messageId: string; }>(); let stopCalls = 0; - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, _handler, onSeeded) => { - onSeeded?.(); - return () => undefined; - }, - send: async () => ({ ok: true as const, turnId: 'old-turn' }), - steer: async () => pendingSteer.promise, - stop: async () => { - stopCalls += 1; - }, - }, - { - ownership: true, - onSend: (value) => (send = value), - onSteer: (value) => (steer = value), - onStop: (value) => (stop = value), + const { container, send, steer, stop } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async () => pendingSteer.promise, + stop: async () => { + stopCalls += 1; }, - ); - assert.ok(send); + }); await act(async () => { - assert.equal(await send?.('initial prompt'), true); + assert.equal(await send('initial prompt'), true); await Promise.resolve(); }); - assert.ok(steer); - assert.ok(stop); - let steerResult: Promise | undefined; + let steerResult!: Promise; await act(async () => { - steerResult = steer?.('cancel this steer'); + steerResult = steer('cancel this steer'); await Promise.resolve(); }); - let stopResult: Promise | undefined; await act(async () => { - stopResult = stop?.(); - await stopResult; + await stop(); await Promise.resolve(); }); assert.equal(stopCalls, 1); @@ -787,32 +701,27 @@ test('cancels a pending Side Conversation steer after Host stop without losing t }); test('fails a send when observation seed rejects and resubscribes for retry', async () => { - let send: ((text: string) => Promise) | undefined; let sendCalls = 0; let subscriptionCount = 0; let rejectSeed: ((error: unknown) => void) | undefined; let markSeeded: (() => void) | undefined; - const { container } = await renderProbe( - { - subscribeEvents: (_sessionId, _handler, onSeeded, onSeedError) => { - subscriptionCount += 1; - if (subscriptionCount === 1) rejectSeed = onSeedError; - else markSeeded = onSeeded; - return () => undefined; - }, - send: async () => { - sendCalls += 1; - return { ok: true as const, turnId: 'retry-turn' }; - }, + const { send } = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded, onSeedError) => { + subscriptionCount += 1; + if (subscriptionCount === 1) rejectSeed = onSeedError; + else markSeeded = onSeeded; + return () => undefined; }, - { ownership: true, onSend: (value) => (send = value) }, - ); - assert.ok(send); + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'retry-turn' }; + }, + }); assert.ok(rejectSeed); - let failedResult: Promise | undefined; + let failedResult!: Promise; await act(async () => { - failedResult = send?.('observer failure'); + failedResult = send('observer failure'); rejectSeed?.(new Error('observer failed')); assert.equal(await failedResult, false); }); @@ -824,35 +733,30 @@ test('fails a send when observation seed rejects and resubscribes for retry', as markSeeded?.(); await Promise.resolve(); }); - let retryResult: Promise | undefined; + let retryResult!: Promise; await act(async () => { - retryResult = send?.('retry after observer failure'); + retryResult = send('retry after observer failure'); assert.equal(await retryResult, true); }); assert.equal(sendCalls, 1); }); test('releases a send waiting for observation when the Side Conversation is disposed', async () => { - let send: ((text: string) => Promise) | undefined; let sendCalls = 0; let unsubscribed = false; - const { container, root } = await renderProbe( - { - subscribeEvents: () => () => { - unsubscribed = true; - }, - send: async () => { - sendCalls += 1; - return { ok: true as const, turnId: 'disposed-turn' }; - }, + const { root, send } = await renderOwnershipProbe({ + subscribeEvents: () => () => { + unsubscribed = true; }, - { ownership: true, onSend: (value) => (send = value) }, - ); - assert.ok(send); + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'disposed-turn' }; + }, + }); - let sendResult: Promise | undefined; + let sendResult!: Promise; await act(async () => { - sendResult = send?.('dispose while observing'); + sendResult = send('dispose while observing'); await Promise.resolve(); }); await act(async () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index fab1dbde09..ff840a518a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -663,12 +663,6 @@ test("retries a dispatched normal send with its original Turn identity", async ( }; }, }), - observer: unusedObserver(), - attachmentApprovals: createAttachmentApprovalRegistry(), - emitSessionsChanged() {}, - stat: async () => ({ size: 0 }), - resizeImage: async (bytes) => bytes, - beforeStop() {}, newId: () => "turn-1", }, ipc, @@ -723,12 +717,6 @@ test("does not add admission retry semantics to an ordinary send", async () => { ); }, }), - observer: unusedObserver(), - attachmentApprovals: createAttachmentApprovalRegistry(), - emitSessionsChanged() {}, - stat: async () => ({ size: 0 }), - resizeImage: async (bytes) => bytes, - beforeStop() {}, newId: () => "turn-1", }, ipc, @@ -786,12 +774,6 @@ test("retries a dispatched busy fallback with its original message identity", as return { disposition: "steering", queueRevision: 1 }; }, }), - observer: unusedObserver(), - attachmentApprovals: createAttachmentApprovalRegistry(), - emitSessionsChanged() {}, - stat: async () => ({ size: 0 }), - resizeImage: async (bytes) => bytes, - beforeStop() {}, newId: () => "id-1", }, ipc, @@ -864,12 +846,6 @@ test("returns the Host-started Turn identity when a direct steer races idle", as turnId: "host-started-turn", }), }), - observer: unusedObserver(), - attachmentApprovals: createAttachmentApprovalRegistry(), - emitSessionsChanged() {}, - stat: async () => ({ size: 0 }), - resizeImage: async (bytes) => bytes, - beforeStop() {}, newId: () => "steer-message-id", }, ipc, @@ -1222,19 +1198,23 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn placement: 'current_turn', state: 'queued', }, + { + entryId: 'entry-2', + messageId: 'in-flight-ticket', + content: { text: 'Already accepted' }, + placement: 'current_turn', + state: 'in_flight', + }, ], followup: [], }, + rootTurnSourceMessageIds: ['successor-ticket'], }); const ipc = ipcHarness(); registerExecutionIpc( { client, observer, - attachmentApprovals: createAttachmentApprovalRegistry(), - emitSessionsChanged() {}, - stat: async () => ({ size: 0 }), - resizeImage: async (bytes) => bytes, beforeStop() { stopLifecycle.push("teardown"); }, @@ -1266,16 +1246,30 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn }, ]); assert.deepEqual(stopLifecycle, []); + for (const expectedAdmissionId of ['in-flight-ticket', 'successor-ticket']) { + await ipc.invoke('sessions:stop', 'session-1', { + source: 'stop_button', + expectedAdmissionId, + }); + } + assert.deepEqual(stopLifecycle, ['teardown', 'interrupt', 'teardown', 'interrupt']); await ipc.invoke("sessions:stop", "session-1", { source: "stop_button", expectedTurnId: "turn-unrelated", }); - assert.deepEqual(stopLifecycle, []); + assert.deepEqual(stopLifecycle, ['teardown', 'interrupt', 'teardown', 'interrupt']); await ipc.invoke("sessions:stop", "session-1", { source: "stop_button", expectedTurnId: "turn-1", }); - assert.deepEqual(stopLifecycle, ["teardown", "interrupt"]); + assert.deepEqual(stopLifecycle, [ + 'teardown', + 'interrupt', + 'teardown', + 'interrupt', + 'teardown', + 'interrupt', + ]); assert.deepEqual(submits, [ { @@ -1298,6 +1292,18 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn turnId: "turn-1", runId: "run-1", }, + { + sessionId: 'session-1', + interruptId: 'id-3', + turnId: 'turn-1', + runId: 'run-1', + }, + { + sessionId: 'session-1', + interruptId: 'id-4', + turnId: 'turn-1', + runId: 'run-1', + }, ]); await observer.close(); }); @@ -1433,22 +1439,21 @@ function ipcHarness() { } function registerExecutionIpc( - deps: Omit< - RuntimeHostSessionExecutionIpcDeps, - 'sessionCopyCleanup' | 'onBackgroundError' | 'observations' - > & - Partial< - Pick< - RuntimeHostSessionExecutionIpcDeps, - 'sessionCopyCleanup' | 'onBackgroundError' | 'observations' - > - >, + deps: Pick & + Partial>, ipcMain: Pick, ): (sessionId: string) => Promise { + const observer = deps.observer ?? unusedObserver(); return registerRuntimeHostSessionExecutionIpc( { + observer, + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, ...deps, - observations: deps.observations ?? deps.observer, + observations: deps.observations ?? observer, sessionCopyCleanup: deps.sessionCopyCleanup ?? unusedSessionCopyCleanup(), onBackgroundError: deps.onBackgroundError ?? (() => undefined), }, From 815725a15640496c77b581c96817cd3319f27778 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 03:19:57 +0800 Subject: [PATCH 14/26] refactor(side-chat): require admission source tickets Generated-by: Codex --- .../__tests__/desktop-transcript-range-store.test.ts | 1 + .../__tests__/runtime-host-bot-session-adapter.test.ts | 1 + .../src/main/__tests__/runtime-host-client.test.ts | 1 + .../__tests__/runtime-host-desktop-candidate.test.ts | 1 + .../runtime-host-session-execution-ipc-main.test.ts | 1 + .../__tests__/runtime-host-session-observer.test.ts | 1 + .../main/runtime-host-session-execution-ipc-main.ts | 2 +- .../cli/src/__tests__/runtime-host-run-command.test.ts | 1 + .../src/__tests__/runtime-host-session-driver.test.ts | 1 + .../src/__tests__/agent-graph-two-client-uds.test.ts | 1 + .../src/__tests__/canonical-session-projection.test.ts | 1 + .../src/__tests__/connection-session.test.ts | 1 + .../runtime-host/src/__tests__/goal-protocol.test.ts | 1 + packages/runtime-host/src/__tests__/protocol.test.ts | 4 ++++ .../__tests__/session-continuity-coordinator.test.ts | 1 + .../src/__tests__/session-projector.test.ts | 3 ++- .../src/__tests__/session-subscription-client.test.ts | 1 + packages/runtime-host/src/adapter/session-projector.ts | 10 +++++----- .../runtime-host/src/protocol/session-continuity.ts | 9 +++------ .../src/server/canonical-session-projection.ts | 6 +++--- 20 files changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 308b9dce42..fa8cb4a3fc 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -582,5 +582,6 @@ function continuitySnapshot() { followup: [], }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts index 6482a9a2bf..2dc9bd14ea 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts @@ -467,6 +467,7 @@ function continuitySnapshot(rootTurn: TurnSnapshot | null): SessionContinuitySna goal: null, queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts index 01fd41646e..748a679bc4 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts @@ -181,6 +181,7 @@ function subscription( goal: null, queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }, loadTranscript: async (_decodeMessage: (value: unknown) => T) => { lifecycle.push(`${sessionId}:transcript`); diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index ea2d4b24b7..af1ab014cc 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1119,6 +1119,7 @@ function continuitySnapshot( followup: [], }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], ...overrides, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index ff840a518a..6f6e6b95fe 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -1392,6 +1392,7 @@ function observerWithTranscript( followup: [], }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], ...overrides, }, activeAssistantStreams: [], diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 29e4ebcde1..e8c21a387a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -2570,6 +2570,7 @@ function continuitySnapshot( followup: [], }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], ...overrides, }; } diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 18b766e5fe..285807d0d9 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -827,7 +827,7 @@ function createRuntimeHostSessionStop( root && !isTerminalStatus(root.status) && (root.turnId === target.expectedAdmissionId || - observed.rootTurnSourceMessageIds?.includes(target.expectedAdmissionId) === true || + observed.rootTurnSourceMessageIds.includes(target.expectedAdmissionId) || entry?.state === 'in_flight') ) { expectedTurnId = root.turnId; diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 94165620fc..3ba1fa5910 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -1554,6 +1554,7 @@ function continuitySnapshot( queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, ...overrides, + rootTurnSourceMessageIds: overrides.rootTurnSourceMessageIds ?? [], }; } diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 1b9dc7abb1..37b5873595 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1824,6 +1824,7 @@ function continuitySnapshot( queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, ...overrides, + rootTurnSourceMessageIds: overrides.rootTurnSourceMessageIds ?? [], }; } diff --git a/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts index e05eb35488..44c1343bbe 100644 --- a/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts @@ -365,6 +365,7 @@ function canonical(hostEpoch: string): CanonicalSessionProjection { goal: null, queue: { hostEpoch, queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }; } diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index e90e4518cb..742d9cd9d1 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -74,6 +74,7 @@ test('projects the canonical root lifecycle and the attachment queue from real S goal: null, queue: { hostEpoch: 'epoch-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }); const admitted = await rootAdmissions.admitRootTurn({ diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index a80204812e..48020f9895 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -1436,6 +1436,7 @@ function canonicalProjection(sessionId: string): CanonicalSessionProjection { followup: [], }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }; } diff --git a/packages/runtime-host/src/__tests__/goal-protocol.test.ts b/packages/runtime-host/src/__tests__/goal-protocol.test.ts index f922dad0ce..519a7918b2 100644 --- a/packages/runtime-host/src/__tests__/goal-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/goal-protocol.test.ts @@ -124,6 +124,7 @@ test('Goal projection is part of the exact Session continuity schema', () => { goal, queue: { hostEpoch: 'epoch-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }); assert.deepEqual(snapshot.goal, goal); assert.throws(() => diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c4f2fad83b..d5c8102105 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -295,6 +295,9 @@ describe('Runtime Host bootstrap protocol', () => { }), isInvalidFrame, ); + const { rootTurnSourceMessageIds: _missing, ...missingAdmissionTickets } = + continuitySnapshot('epoch-1'); + assert.throws(() => decodeSessionContinuitySnapshot(missingAdmissionTickets), isInvalidFrame); const waiting = { ...continuitySnapshot('epoch-1'), rootTurn: { @@ -1796,5 +1799,6 @@ function continuitySnapshot(hostEpoch: string) { followup: [], }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }; } diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index 4ca2ec4d36..c3921be3a7 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -2078,6 +2078,7 @@ function canonical( followup: [], }, interactions: overrides.interactions ?? { pending: [] }, + rootTurnSourceMessageIds: [], }; } diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 5c06aea4f1..5bfe6fb451 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -493,6 +493,7 @@ function snapshot(overrides: Partial = {}): SessionCo followup: [], }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], ...overrides, }; } @@ -501,7 +502,7 @@ function withRootSourceMessageIds( value: SessionContinuitySnapshot, rootTurnSourceMessageIds: readonly string[], ): SessionContinuitySnapshot { - return { ...value, rootTurnSourceMessageIds } as unknown as SessionContinuitySnapshot; + return { ...value, rootTurnSourceMessageIds }; } function assistant(id: string, text: string): Extract { diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index dfe5d1bcab..40c0ef67f1 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -1255,6 +1255,7 @@ function openResult( goal: null, queue: { hostEpoch, queueRevision: 1, steering: [], followup: [] }, interactions: { pending: [] }, + rootTurnSourceMessageIds: [], }, }; } diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index ad165e4ec7..132325c561 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -448,10 +448,10 @@ function emptyUpdate(events: readonly SessionEvent[]): RuntimeHostProjectionUpda function projectMessageAdmissionEvents( root: TurnSnapshot, - messageIds: readonly string[] | undefined, + messageIds: readonly string[], ts: number, ): SessionEvent[] { - return (messageIds ?? []).map((messageId) => ({ + return messageIds.map((messageId) => ({ type: 'message_admission' as const, id: `host-admission:${root.runId}:${messageId}`, turnId: root.turnId, @@ -470,11 +470,11 @@ function projectNewMessageAdmissionEvents( if (!root) return []; const previousIds = previous.rootTurn?.runId === root.runId - ? new Set(previous.rootTurnSourceMessageIds ?? []) + ? new Set(previous.rootTurnSourceMessageIds) : new Set(); return projectMessageAdmissionEvents( root, - (next.rootTurnSourceMessageIds ?? []).filter((messageId) => !previousIds.has(messageId)), + next.rootTurnSourceMessageIds.filter((messageId) => !previousIds.has(messageId)), ts, ); } @@ -489,7 +489,7 @@ function projectMessageRetractionEvents( const retained = new Set( [...next.queue.steering, ...next.queue.followup].map((entry) => entry.messageId), ); - const admitted = new Set(next.rootTurnSourceMessageIds ?? []); + const admitted = new Set(next.rootTurnSourceMessageIds); return [...previous.queue.steering, ...previous.queue.followup] .filter( (entry) => diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index d248b4841f..b74e8d80d0 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -88,7 +88,7 @@ export interface SessionContinuitySnapshot { queue: SessionMessageQueueProjection; interactions: SessionInteractionProjection; /** Host-owned source message tickets admitted into the root Turn. */ - rootTurnSourceMessageIds?: readonly string[]; + rootTurnSourceMessageIds: readonly string[]; } export interface SubscriptionOpenInput { @@ -527,6 +527,7 @@ export function decodeSessionContinuitySnapshot(value: unknown): SessionContinui 'queue', 'interactions', 'rootTurnSourceMessageIds', + 'rootTurnSourceMessageIds', ]); assertRequiredKeys(record, 'Session continuity snapshot', [ 'schemaVersion', @@ -550,10 +551,6 @@ export function decodeSessionContinuitySnapshot(value: unknown): SessionContinui if (goal !== null && goal.sessionId !== session.sessionId) { throw invalidProtocolFrame('Session continuity Goal belongs to a different Session'); } - const rootTurnSourceMessageIds = - record.rootTurnSourceMessageIds === undefined - ? undefined - : decodeRootTurnSourceMessageIds(record.rootTurnSourceMessageIds); return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session, @@ -562,7 +559,7 @@ export function decodeSessionContinuitySnapshot(value: unknown): SessionContinui goal, queue: decodeSessionMessageQueueProjection(record.queue), interactions, - ...(rootTurnSourceMessageIds === undefined ? {} : { rootTurnSourceMessageIds }), + rootTurnSourceMessageIds: decodeRootTurnSourceMessageIds(record.rootTurnSourceMessageIds), }; } diff --git a/packages/runtime-host/src/server/canonical-session-projection.ts b/packages/runtime-host/src/server/canonical-session-projection.ts index a5ad5613f6..76cabef6ee 100644 --- a/packages/runtime-host/src/server/canonical-session-projection.ts +++ b/packages/runtime-host/src/server/canonical-session-projection.ts @@ -54,7 +54,7 @@ export interface CanonicalSessionProjection { readonly goal: GoalProjection | null; readonly queue: SessionMessageQueueProjection; readonly interactions: SessionInteractionProjection; - readonly rootTurnSourceMessageIds?: readonly string[]; + readonly rootTurnSourceMessageIds: readonly string[]; } export interface CanonicalSessionProjectionCandidate { @@ -133,7 +133,7 @@ export class CanonicalSessionProjectionReader { goal, queue, interactions, - ...(admission ? { rootTurnSourceMessageIds } : {}), + rootTurnSourceMessageIds, }; } @@ -202,7 +202,7 @@ function sessionContinuitySnapshotInput( goal: canonical.goal, queue: canonical.queue, interactions: canonical.interactions, - rootTurnSourceMessageIds: canonical.rootTurnSourceMessageIds ?? [], + rootTurnSourceMessageIds: canonical.rootTurnSourceMessageIds, }; } From edd79fd32b688510dce848b723ea43ec7f53788b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 03:56:46 +0800 Subject: [PATCH 15/26] fix(side-chat): scope admission projection Derive admission observation from the Host-owned Session mode and keep ordinary Session streams unchanged. Remove the duplicate renderer send intent and tighten continuity decoding. Generated-by: Codex --- ...me-host-session-execution-ipc-main.test.ts | 27 +++++++++-------- .../src/main/permission-response-guard.ts | 2 -- ...runtime-host-session-execution-ipc-main.ts | 21 +++++++++----- ...ntime-host-session-observation-registry.ts | 17 +++++++++-- .../src/main/runtime-host-session-observer.ts | 8 +++++ apps/desktop/src/preload/bridge-contract.d.ts | 3 +- apps/desktop/src/preload/preload.ts | 3 +- .../desktop/create-workbar-services.ts | 3 +- .../src/__tests__/session-projector.test.ts | 16 ++++++++++ .../src/adapter/session-projector.ts | 29 +++++++++++++++---- .../src/protocol/session-continuity.ts | 2 +- 11 files changed, 94 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 6f6e6b95fe..cb547339a6 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -639,7 +639,7 @@ test("retries a dispatched normal send with its original Turn identity", async ( client: executionClient({ getSession: async () => { reconnectQueries += 1; - return session(); + return sideConversationSession(); }, startTurn: async (input) => { starts.push(input); @@ -670,7 +670,6 @@ test("retries a dispatched normal send with its original Turn identity", async ( const result = await ipc.invoke("sessions:send", "session-1", { type: "send", - intent: "side_conversation", text: "keep this Turn identity", }); @@ -740,9 +739,11 @@ test("retries a dispatched busy fallback with its original message identity", as registerExecutionIpc( { client: executionClient({ - getSession: async () => { + getSession: async (sessionId) => { reconnectQueries += 1; - return session(); + return sessionId === 'side-session' + ? sideConversationSession(sessionId) + : session(); }, startTurn: async () => { throw new RuntimeHostOperationError( @@ -779,9 +780,8 @@ test("retries a dispatched busy fallback with its original message identity", as ipc, ); - const result = await ipc.invoke("sessions:send", "session-1", { + const result = await ipc.invoke("sessions:send", "side-session", { type: "send", - intent: "side_conversation", turnId: "turn-1", text: "keep this message identity", }); @@ -789,13 +789,13 @@ test("retries a dispatched busy fallback with its original message identity", as assert.equal(reconnectQueries, 2, 'initial Session lookup plus reconnect probe'); assert.deepEqual(submits, [ { - sessionId: "session-1", + sessionId: "side-session", messageId: "turn-1", content: { text: "keep this message identity", inlineReferences: [] }, placement: "current_turn", }, { - sessionId: "session-1", + sessionId: "side-session", messageId: "turn-1", content: { text: "keep this message identity", inlineReferences: [] }, placement: "current_turn", @@ -820,9 +820,8 @@ test("retries a dispatched busy fallback with its original message identity", as error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown', ); assert.deepEqual( - await ipc.invoke("sessions:send", "session-1", { + await ipc.invoke("sessions:send", "side-session", { type: "send", - intent: 'side_conversation', turnId: "turn-unknown", text: "keep waiting for the Host outcome", }), @@ -1475,9 +1474,9 @@ function unusedSessionCopyCleanup(): RuntimeHostSessionExecutionIpcDeps['session }; } -function session(cwd = "/workspace"): SessionCatalogProjection { +function session(cwd = "/workspace", id = 'session-1'): SessionCatalogProjection { return { - id: "session-1", + id, revision: 1, workspace: { target: { kind: 'host_path', path: cwd }, @@ -1501,3 +1500,7 @@ function session(cwd = "/workspace"): SessionCatalogProjection { orchestrationMode: "default", }; } + +function sideConversationSession(id = 'session-1'): SessionCatalogProjection { + return { ...session('/workspace', id), labels: [SIDE_CONVERSATION_SESSION_LABEL] }; +} diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 1546cc39c1..4c39f944b5 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -52,7 +52,6 @@ export type RuntimeHostReviseBeforeTurnInput = ReviseBeforeTurnInput & { copyId: interface NormalizedSendSessionCommand { type: 'send'; - intent?: 'side_conversation'; turnId?: string; text: string; displayText?: string; @@ -179,7 +178,6 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi } return { type: 'send', - ...(value.intent === 'side_conversation' ? { intent: 'side_conversation' as const } : {}), ...normalizeOptionalSendTurnId(value.turnId), text, ...(displayText !== undefined ? { displayText } : {}), diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 285807d0d9..d5ed901375 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -25,6 +25,7 @@ import { RuntimeHostRequestInterruptedError, } from '@maka/runtime-host/client'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; +import { isSideConversationSession } from '@maka/core/side-conversation'; import { type SessionChangedEvent, type SessionChangedReason, @@ -193,10 +194,15 @@ export function registerRuntimeHostSessionExecutionIpc( async (event, sessionId: unknown, observerId: unknown) => { const normalizedSessionId = requiredId(sessionId, "Session"); const normalizedObserverId = requiredId(observerId, "Session observer"); + const session = await deps.client.getSession(normalizedSessionId); + if (!session) { + throw new Error(`Runtime Host Session not found: ${normalizedSessionId}`); + } await deps.observations.observe( normalizedSessionId, normalizedObserverId, event.sender as RuntimeHostSessionObserverTarget, + isSideConversationSession(session.labels), ); }, ); @@ -254,6 +260,7 @@ export function registerRuntimeHostSessionExecutionIpc( const session = await deps.client.getSession(sessionId); if (!session) throw new Error(`Runtime Host Session not found: ${sessionId}`); + const sideConversation = isSideConversationSession(session.labels); const turnId = command.turnId ?? newId(); let attachments = retainedAttachmentsForSession( sessionId, @@ -314,13 +321,12 @@ export function registerRuntimeHostSessionExecutionIpc( }; let startResult; try { - startResult = - command.intent === 'side_conversation' - ? await retryDispatchedCommand( - () => deps.client.startTurn(startInput), - () => deps.client.getSession(sessionId), - ) - : await deps.client.startTurn(startInput); + startResult = sideConversation + ? await retryDispatchedCommand( + () => deps.client.startTurn(startInput), + () => deps.client.getSession(sessionId), + ) + : await deps.client.startTurn(startInput); } catch (error) { // The renderer routes text at a session it sees as running to // `sessions:steer`, but its view can lag the Host: another window, a @@ -344,7 +350,6 @@ export function registerRuntimeHostSessionExecutionIpc( // Side Conversation keeps its requested Turn id as the admission // ticket so a successor root can report ownership with that identity. // Ordinary sends retain the pre-existing independent message id. - const sideConversation = command.intent === 'side_conversation'; const messageId = sideConversation ? turnId : newId(); const emptySkillInvocation = { loaded: [], failed: [], receipts: [] }; const submitInput = { diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index e60ca88983..0e95057c59 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -76,6 +76,7 @@ function requireTranscriptSource( interface SessionObservationRegistration { readonly sessionId: string; + readonly messageAdmissions: boolean; readonly target: RuntimeHostSessionObserverTarget; readonly destroyedListener: () => void; readonly ready: ObservationReadiness; @@ -151,6 +152,7 @@ export class RuntimeHostSessionObservationRegistry { registration.sessionId, observerId, bindTarget(registration.target), + registration.messageAdmissions, ); if ( this.#source !== source || @@ -218,11 +220,16 @@ export class RuntimeHostSessionObservationRegistry { sessionId: string, observerId: string, target: RuntimeHostSessionObserverTarget, + messageAdmissions = false, ): Promise { this.#assertOpen(); const previous = this.#registrations.get(observerId); if (previous) { - if (previous.sessionId !== sessionId || previous.target.id !== target.id) { + if ( + previous.sessionId !== sessionId || + previous.target.id !== target.id || + previous.messageAdmissions !== messageAdmissions + ) { throw new Error("Runtime Host Session observer identity was reused"); } return previous.ready.promise; @@ -235,6 +242,7 @@ export class RuntimeHostSessionObservationRegistry { void ready.promise.catch(() => undefined); const registration: SessionObservationRegistration = { sessionId, + messageAdmissions, target, destroyedListener, ready, @@ -246,7 +254,12 @@ export class RuntimeHostSessionObservationRegistry { const source = this.#source; if (!source) return registration.ready.promise; try { - await source.observe(sessionId, observerId, this.#bindTarget(target)); + await source.observe( + sessionId, + observerId, + this.#bindTarget(target), + messageAdmissions, + ); if ( this.#source === source && this.#registrations.get(observerId) === registration diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 390ee5f34e..273dfca234 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -118,6 +118,7 @@ interface ObservedSessionState { snapshot?: SessionContinuitySnapshot; projector?: RuntimeHostSessionProjector; transcriptAccess: number; + messageAdmissions: boolean; closing: boolean; } @@ -425,6 +426,7 @@ export class RuntimeHostSessionObserver { sessionId: string, observerId: string, target: RuntimeHostSessionObserverTarget, + messageAdmissions = false, ): Promise { this.#assertOpen(); const previous = this.#observers.get(observerId); @@ -438,6 +440,10 @@ export class RuntimeHostSessionObserver { return; } const state = this.#state(sessionId); + if (messageAdmissions && !state.messageAdmissions) { + state.messageAdmissions = true; + state.projector?.enableMessageAdmissions(); + } let group = state.targets.get(target.id); if (!group) { const destroyedListener = () => { @@ -622,6 +628,7 @@ export class RuntimeHostSessionObserver { subscriptionOwner, pendingTranscriptConsumers: 0, transcriptAccess: 0, + messageAdmissions: false, closing: false, }; this.#states.set(sessionId, state); @@ -800,6 +807,7 @@ export class RuntimeHostSessionObserver { subscription.replica.projectionSeed, this.#now, subscription.activeAssistantStreams, + state.messageAdmissions, ); const terminalTurnIds = new Set(); for (const turnId of state.watchedTurnIds) { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 06e483ab93..3917b1014c 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -720,9 +720,8 @@ export interface MakaBridge { sessionId: string, command: | SessionCommand - | { + | { type: 'send'; - intent?: 'side_conversation'; turnId: string; text: string; displayText?: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d71c52e0eb..2f7169564e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1526,9 +1526,8 @@ const makaBridge = { sessionId: string, command: | SessionCommand - | { + | { type: 'send'; - intent?: 'side_conversation'; turnId: string; text: string; displayText?: string; diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index f2e49eb748..b3aebaa499 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -119,8 +119,7 @@ export function createDesktopWorkbarServices( bridge.sessions.cleanupSessionCopy(sessionId), abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), - send: (sessionId, command) => - bridge.sessions.send(sessionId, { ...command, intent: 'side_conversation' }), + send: (sessionId, command) => bridge.sessions.send(sessionId, command), stop: (sessionId, admissionId) => bridge.sessions.stop( sessionId, diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 5bfe6fb451..23b35485bd 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -74,10 +74,22 @@ test('applies authoritative replacement once and does not complete it again at T }); test('emits the Host admission fact when a queued message enters a successor Turn', () => { + const ordinary = new RuntimeHostSessionProjector( + withRootSourceMessageIds(snapshot(), ['ordinary-ticket']), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + assert.equal( + ordinary.seedActive(false).some((event) => event.type === 'message_admission'), + false, + ); + const rejoined = new RuntimeHostSessionProjector( withRootSourceMessageIds(snapshot(), ['rejoined-ticket']), createRuntimeHostSessionProjectionSeed([], snapshot()), () => 10, + [], + true, ); assert.deepEqual( rejoined @@ -113,6 +125,8 @@ test('emits the Host admission fact when a queued message enters a successor Tur previous, createRuntimeHostSessionProjectionSeed([], previous), () => 10, + [], + true, ); const next = withRootSourceMessageIds( snapshot({ @@ -165,6 +179,8 @@ test('emits the Host admission fact when a queued message enters a successor Tur previous, createRuntimeHostSessionProjectionSeed([], previous), () => 10, + [], + true, ).accept({ kind: 'subscription.session_projection', hostEpoch: 'host-1', diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 132325c561..6404654a0c 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -85,16 +85,19 @@ export class RuntimeHostSessionProjector { readonly #now: () => number; readonly #transcriptIds: Set; readonly #accumulators = new Map(); + #projectMessageAdmissions: boolean; constructor( snapshot: SessionContinuitySnapshot, seed: RuntimeHostSessionProjectionSeed, now: () => number = Date.now, activeAssistantStreams: readonly SessionAssistantStreamIdentity[] = [], + projectMessageAdmissions = false, ) { this.#snapshot = structuredClone(snapshot); this.#now = now; this.#transcriptIds = new Set(seed.durableInFlightMessageIds); + this.#projectMessageAdmissions = projectMessageAdmissions; const root = snapshot.rootTurn; if (!root) return; for (const message of seed.activeAssistantMessages) { @@ -139,13 +142,23 @@ export class RuntimeHostSessionProjector { return structuredClone(this.#snapshot); } + enableMessageAdmissions(): void { + this.#projectMessageAdmissions = true; + } + seedActive(includeAssistantText: boolean): SessionEvent[] { const root = this.#snapshot.rootTurn; if (!root) return []; const events: SessionEvent[] = []; - events.push( - ...projectMessageAdmissionEvents(root, this.#snapshot.rootTurnSourceMessageIds, this.#now()), - ); + if (this.#projectMessageAdmissions) { + events.push( + ...projectMessageAdmissionEvents( + root, + this.#snapshot.rootTurnSourceMessageIds, + this.#now(), + ), + ); + } if (isRuntimeHostTerminalTurn(root)) return events; let seededAssistantText = false; if (includeAssistantText) { @@ -197,7 +210,9 @@ export class RuntimeHostSessionProjector { seedTerminal(turn: RuntimeHostTerminalTurn): SessionEvent[] { return [ - ...projectMessageAdmissionEvents(turn, this.#snapshot.rootTurnSourceMessageIds, this.#now()), + ...(this.#projectMessageAdmissions + ? projectMessageAdmissionEvents(turn, this.#snapshot.rootTurnSourceMessageIds, this.#now()) + : []), ...this.#terminalEvents(turn, true), ]; } @@ -363,8 +378,10 @@ export class RuntimeHostSessionProjector { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); } const root = next.rootTurn; - events.push(...projectMessageRetractionEvents(previousSnapshot, next, this.#now())); - events.push(...projectNewMessageAdmissionEvents(previousSnapshot, next, this.#now())); + if (this.#projectMessageAdmissions) { + events.push(...projectMessageRetractionEvents(previousSnapshot, next, this.#now())); + events.push(...projectNewMessageAdmissionEvents(previousSnapshot, next, this.#now())); + } if (root && queueChanged(previousSnapshot.queue, next.queue)) { for (const entry of newlyInFlight(previousSnapshot.queue, next.queue)) { events.push({ diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index b74e8d80d0..4a50f11f19 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -527,7 +527,6 @@ export function decodeSessionContinuitySnapshot(value: unknown): SessionContinui 'queue', 'interactions', 'rootTurnSourceMessageIds', - 'rootTurnSourceMessageIds', ]); assertRequiredKeys(record, 'Session continuity snapshot', [ 'schemaVersion', @@ -537,6 +536,7 @@ export function decodeSessionContinuitySnapshot(value: unknown): SessionContinui 'goal', 'queue', 'interactions', + 'rootTurnSourceMessageIds', ]); if (record.schemaVersion !== SESSION_CONTINUITY_SCHEMA_VERSION) { throw invalidProtocolFrame('Unsupported Session continuity snapshot schema'); From 2131e7c71ba06d2ab2e6bceb353ba2b99420e31a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 04:07:39 +0800 Subject: [PATCH 16/26] fix(side-chat): follow replacement Host scope Keep Session event listeners bound to the current validated Host target epoch instead of filtering replacement events through the original host identity. Generated-by: Codex --- apps/desktop/src/preload/preload.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2f7169564e..67ca60b4bb 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1759,14 +1759,12 @@ const makaBridge = { unsubscribeEvents = subscribeEveryRuntimeHostEvent( `sessions:event:${session.sessionId}`, (scope, event: SessionEvent) => { - if (scope.hostId !== session.scope.hostId) return; handler(projectDesktopSessionEvent(scope, event)); }, ); unsubscribeObservationSeed = subscribeEveryRuntimeHostEvent( 'sessions:observation-seed', - (scope, payload: { sessionId?: string; phase?: string }) => { - if (scope.hostId !== session.scope.hostId) return; + (_scope, payload: { sessionId?: string; phase?: string }) => { if (payload.sessionId !== session.sessionId) return; if (payload.phase === 'pending' || payload.phase === 'ready') { onObservationSeed?.(payload.phase); From f9d0a03975067198bd2f0adaa7520081e50be1f7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 04:07:39 +0800 Subject: [PATCH 17/26] fix(side-chat): replay admission after unknown stop Reconcile buffered Host admission events when Stop outcome is unknown and bind successor/retraction fixtures to the actual submitted ticket. Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 92 ++++++++++++++----- .../tools/side-chat/use-quote-companion.ts | 3 +- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index b56b7ad7d9..d5c0e0a6c2 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -47,10 +47,12 @@ const SOURCE_SESSION = session('source-session'); function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((settle) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((settle, fail) => { resolve = settle; + reject = fail; }); - return { promise, resolve }; + return { promise, reject, resolve }; } type QueueUpdate = Extract; @@ -437,13 +439,17 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag }); test('replays queued Side Conversation text after Host assigns the ticket to a successor Turn', async () => { + let admissionId: string | undefined; const pendingSend = deferred<{ ok: false; reason: 'outcome_unknown'; messageId: string; }>(); const { container, emit, send } = await renderOwnershipProbe({ - send: async () => pendingSend.promise, + send: async (_sessionId, command) => { + admissionId = command.turnId; + return pendingSend.promise; + }, }); let sendResult!: Promise; @@ -452,7 +458,14 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s await Promise.resolve(); }); await act(async () => { - emit(messageAdmittedEvent('successor-admission', 'successor-root', 1, 'ticket-1')); + emit( + messageAdmittedEvent( + 'successor-admission', + 'successor-root', + 1, + admissionId as string, + ), + ); emit(queueUpdateEvent('successor-queue', 'successor-root', 2)); emit(textDeltaEvent('successor-text', 'successor-root', 3, 'answer from successor')); await Promise.resolve(); @@ -462,7 +475,7 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s pendingSend.resolve({ ok: false, reason: 'outcome_unknown', - messageId: 'ticket-1', + messageId: admissionId as string, }); assert.equal(await sendResult, true); await Promise.resolve(); @@ -529,34 +542,63 @@ test('clears a queued Side Conversation send when Host stop cancels the admissio }); test('keeps a Side Conversation admission when Host stop outcome is unknown', async () => { - const pendingSend = deferred<{ ok: true; turnId: string }>(); - const { container, send, stop } = await renderOwnershipProbe({ - send: async () => pendingSend.promise, - stop: async () => { - throw new Error('Host stop result is unknown'); + let admissionId: string | undefined; + const pendingStop = deferred(); + const { container, emit, send, stop } = await renderOwnershipProbe({ + send: async (_sessionId, command) => { + admissionId = command.turnId; + return { + ok: false as const, + reason: 'outcome_unknown' as const, + messageId: admissionId as string, + }; }, + stop: async () => pendingStop.promise, }); - let sendResult!: Promise; await act(async () => { - sendResult = send('keep this admission'); + assert.equal(await send('keep this admission'), true); await Promise.resolve(); }); + let stopResult!: Promise; await act(async () => { - await stop(); + stopResult = stop(); await Promise.resolve(); }); - - assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { - pendingSend.resolve({ ok: true, turnId: 'admitted-after-unknown-stop' }); - assert.equal(await sendResult, true); + emit( + messageAdmittedEvent( + 'admitted-during-unknown-stop', + 'admitted-after-unknown-stop', + 1, + admissionId as string, + ), + ); + emit( + textDeltaEvent( + 'text-during-unknown-stop', + 'admitted-after-unknown-stop', + 2, + 'answer', + ), + ); await Promise.resolve(); }); + await act(async () => { + pendingStop.reject(new Error('Host stop result is unknown')); + await stopResult; + await Promise.resolve(); + }); + await waitUntil( + () => + container.firstElementChild?.getAttribute('data-live-turn-id') === + 'admitted-after-unknown-stop', + ); assert.equal( container.firstElementChild?.getAttribute('data-live-turn-id'), 'admitted-after-unknown-stop', ); + assert.equal(container.firstElementChild?.getAttribute('data-live-text'), 'answer'); }); test('stops a bound Side Conversation by its exact Host Turn identity', async () => { @@ -579,6 +621,7 @@ test('stops a bound Side Conversation by its exact Host Turn identity', async () }); test('releases a queued Side Conversation admission from the Host queue retract', async () => { + let admissionId: string | undefined; const pendingSend = deferred<{ ok: true; steered: true; @@ -586,11 +629,15 @@ test('releases a queued Side Conversation admission from the Host queue retract' messageId: string; }>(); const { container, emit, send } = await renderOwnershipProbe({ - send: async () => pendingSend.promise, + send: async (_sessionId, command) => { + admissionId = command.turnId; + return pendingSend.promise; + }, }); + let sendResult!: Promise; await act(async () => { - void send('retract this queued send'); + sendResult = send('retract this queued send'); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); @@ -601,20 +648,21 @@ test('releases a queued Side Conversation admission from the Host queue retract' id: 'retracted-admission', turnId: 'old-turn', ts: 1, - messageId: 'retracted-message', + messageId: admissionId as string, outcome: 'retracted', }); await Promise.resolve(); }); - assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); await act(async () => { pendingSend.resolve({ ok: true, steered: true, turnId: 'not-the-owner', - messageId: 'retracted-message', + messageId: admissionId as string, }); + assert.equal(await sendResult, false); await Promise.resolve(); }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 2092ba48ef..c0cf79016e 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -744,6 +744,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan activeTurnIdRef.current = admission.restoreTurnId; setLiveTurn(admission.restoreLiveTurn); setTurnInFlight(true); + resolveAdmission(id, admission, admission.messageId, true); } stopRequestedRef.current = false; return 'unknown' as const; @@ -762,7 +763,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan stopRequestedRef.current = false; // best-effort; the terminal event still reconciles state } - }, [abandonAdmission, sideChat]); + }, [abandonAdmission, resolveAdmission, sideChat]); const steer = useCallback(async (text: string): Promise => { const id = companionIdRef.current; From 0fe0854b3699b7014100f0240b3b9c1e467626c6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 04:13:11 +0800 Subject: [PATCH 18/26] fix(side-chat): fence replacement Host profile Follow replacement target epochs within the owning profile while rejecting same-named Session channels from other Hosts. Generated-by: Codex --- apps/desktop/src/preload/preload.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 67ca60b4bb..6df3004c3b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1753,18 +1753,23 @@ const makaBridge = { let unsubscribeObservationSeed = () => {}; const observeDispatch = runtimeHostSessionRef(sessionId).then((session) => { if (disposed) return { completion: Promise.resolve() }; + const profileId = runtimeHostMetadata.get(session.scope.hostId)?.profileId; + if (!profileId) throw new Error('The Runtime Host profile for this task is unavailable'); // Keep the renderer listener across Host target epochs. The observer - // registry restores this observer on the replacement target, while - // the dynamic subscription accepts the replacement scope. + // registry restores this observer on the replacement target. Profile + // identity admits that replacement without accepting another Host's + // same-named Session channel. unsubscribeEvents = subscribeEveryRuntimeHostEvent( `sessions:event:${session.sessionId}`, (scope, event: SessionEvent) => { + if (runtimeHostMetadata.get(scope.hostId)?.profileId !== profileId) return; handler(projectDesktopSessionEvent(scope, event)); }, ); unsubscribeObservationSeed = subscribeEveryRuntimeHostEvent( 'sessions:observation-seed', - (_scope, payload: { sessionId?: string; phase?: string }) => { + (scope, payload: { sessionId?: string; phase?: string }) => { + if (runtimeHostMetadata.get(scope.hostId)?.profileId !== profileId) return; if (payload.sessionId !== session.sessionId) return; if (payload.phase === 'pending' || payload.phase === 'ready') { onObservationSeed?.(payload.phase); From a1fdad13609690a9ef88078f03b504b99236543a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 04:13:11 +0800 Subject: [PATCH 19/26] test(side-chat): preserve streaming after unknown stop Assert that replaying admission and text after an unknown Stop restores the real Host Turn streaming projection. Generated-by: Codex --- apps/desktop/src/main/__tests__/quote-companion-retry.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index d5c0e0a6c2..e670c1ab9d 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -599,6 +599,8 @@ test('keeps a Side Conversation admission when Host stop outcome is unknown', as 'admitted-after-unknown-stop', ); assert.equal(container.firstElementChild?.getAttribute('data-live-text'), 'answer'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); }); test('stops a bound Side Conversation by its exact Host Turn identity', async () => { From 31908668ac60f7ba1fb9ea02d23d20a7bae09421 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 11:28:38 +0800 Subject: [PATCH 20/26] fix(side-chat): keep active turn during admission Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 163 ++++++++++++--- .../tools/side-chat/quote-companion-core.ts | 17 +- .../tools/side-chat/use-quote-companion.ts | 195 ++++++++---------- 3 files changed, 230 insertions(+), 145 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index e670c1ab9d..f5860d165c 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -86,10 +86,6 @@ function queueUpdateEvent( }; } -function steeringMessageEvent(id: string, turnId: string, ts: number, messageId: string): SessionEvent { - return { type: 'steering_message', id, messageId, turnId, ts, content: { text: 'steer the active turn' } }; -} - function messageAdmittedEvent( id: string, turnId: string, @@ -199,9 +195,9 @@ async function renderOwnershipProbe(sideChat: Partial send(text), + steer: (text: string) => steer(text), + stop: () => stop(), emit(event: SessionEvent) { assert.ok(eventHandler); eventHandler(event); @@ -419,8 +415,8 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag ); await act(async () => { emit( - steeringMessageEvent( - 'accepted-steering-message', + messageAdmittedEvent( + 'accepted-admission', 'host-active-turn', 2.5, admissionId as string, @@ -488,7 +484,7 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s assert.equal(probe.getAttribute('data-processing'), 'false'); }); -test('clears a queued Side Conversation send when Host stop cancels the admission', async () => { +test('waits for Host retraction before clearing a stopped Side Conversation admission', async () => { let admissionId: string | undefined; const pendingStop = deferred(); const pendingSend = deferred<{ @@ -497,7 +493,7 @@ test('clears a queued Side Conversation send when Host stop cancels the admissio turnId: string; messageId: string; }>(); - const { container, send, stop } = await renderOwnershipProbe({ + const { container, emit, send, stop } = await renderOwnershipProbe({ send: async (_sessionId, command) => { admissionId = command.turnId; return pendingSend.promise; @@ -518,13 +514,26 @@ test('clears a queued Side Conversation send when Host stop cancels the admissio stopResult = stop(); await Promise.resolve(); }); - assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { pendingStop.resolve(); await stopResult; await Promise.resolve(); }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + + await act(async () => { + emit({ + type: 'message_admission', + id: 'stopped-admission-retracted', + turnId: 'old-turn', + ts: 1, + messageId: admissionId as string, + outcome: 'retracted', + }); + await Promise.resolve(); + }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); await act(async () => { @@ -708,46 +717,144 @@ test('keeps the same Side Conversation admission across a recoverable subscripti await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'false'); }); -test('cancels a pending Side Conversation steer after Host stop without losing the old Turn', async () => { - const pendingSteer = deferred<{ - kind: 'queued'; - messageId: string; - }>(); - let stopCalls = 0; - const { container, send, steer, stop } = await renderOwnershipProbe({ +test('keeps the active Side Conversation streaming when Stop retracts a queued steer', async () => { + const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + let admissionId: string | undefined; + let steerCalls = 0; + const { container, emit, send, steer, stop } = await renderOwnershipProbe({ send: async () => ({ ok: true as const, turnId: 'old-turn' }), - steer: async () => pendingSteer.promise, - stop: async () => { - stopCalls += 1; + steer: async (_sessionId, _text, requestedAdmissionId) => { + steerCalls += 1; + admissionId = requestedAdmissionId; + return pendingSteer.promise; }, + stop: async () => undefined, }); await act(async () => { assert.equal(await send('initial prompt'), true); await Promise.resolve(); }); - + await waitUntil(() => container.firstElementChild?.getAttribute('data-streaming') === 'true'); let steerResult!: Promise; await act(async () => { - steerResult = steer('cancel this steer'); + steerResult = steer('queue this steer'); await Promise.resolve(); }); + await waitUntil(() => steerCalls === 1); + assert.ok(admissionId); await act(async () => { await stop(); await Promise.resolve(); }); - assert.equal(stopCalls, 1); assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'old-turn'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); + + await act(async () => { + emit({ + type: 'message_admission', + id: 'queued-steer-retracted', + turnId: 'old-turn', + ts: 1, + messageId: admissionId as string, + outcome: 'retracted', + }); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); + + await act(async () => { + pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); + assert.equal(await steerResult, false); + await Promise.resolve(); + }); +}); + +test('stops the active Side Conversation after retracting its queued steer', async () => { + const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + let admissionId: string | undefined; + const stoppedIds: Array = []; + const { emit, send, steer, stop } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, requestedAdmissionId) => { + admissionId = requestedAdmissionId; + return pendingSteer.promise; + }, + stop: async (_sessionId, expectedId) => { + stoppedIds.push(expectedId); + }, + }); await act(async () => { - pendingSteer.resolve({ - kind: 'queued', - messageId: 'cancelled-steer', + assert.equal(await send('initial prompt'), true); + await Promise.resolve(); + }); + let steerResult!: Promise; + await act(async () => { + steerResult = steer('queue this steer'); + await Promise.resolve(); + }); + await waitUntil(() => admissionId !== undefined); + await act(async () => { + await stop(); + emit({ + type: 'message_admission', + id: 'queued-steer-retracted-before-active-stop', + turnId: 'old-turn', + ts: 1, + messageId: admissionId as string, + outcome: 'retracted', }); + await Promise.resolve(); + }); + await act(async () => { + await stop(); + await Promise.resolve(); + }); + + assert.deepEqual(stoppedIds, [admissionId, 'old-turn']); + await act(async () => { + pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); assert.equal(await steerResult, false); await Promise.resolve(); }); +}); + +test('continues projecting the active Turn while a steer awaits Host admission', async () => { + const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + let admissionId: string | undefined; + const { container, emit, send, steer } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, requestedAdmissionId) => { + admissionId = requestedAdmissionId; + return pendingSteer.promise; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + await Promise.resolve(); + }); + let steerResult!: Promise; + await act(async () => { + steerResult = steer('queue this steer'); + await Promise.resolve(); + }); + await waitUntil(() => admissionId !== undefined); + await act(async () => { + emit(textDeltaEvent('old-turn-text', 'old-turn', 1, 'still streaming')); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'old-turn'); + assert.equal(container.firstElementChild?.getAttribute('data-live-text'), 'still streaming'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); + + await act(async () => { + pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); + assert.equal(await steerResult, true); + await Promise.resolve(); + }); }); test('fails a send when observation seed rejects and resubscribes for retry', async () => { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index 9d0f037ff7..626f6d6dce 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -168,19 +168,22 @@ export async function dismissCompanionCopy( } /** - * The shared Composer's `streaming` input means "a turn is interruptible", not - * merely "a text delta has arrived". Keep the companion interruptible from the - * optimistic waiting projection through live output; `processing` only chooses - * the quieter pre-first-token presentation inside that same in-flight window. + * The shared Composer's `streaming` input means Host work is interruptible, not + * merely that a text delta has arrived. A pending admission remains stoppable + * before it owns a Turn; an admitted Turn remains stoppable through live output. */ export function deriveCompanionComposerState( - turnInFlight: boolean, + hasPendingAdmission: boolean, + activeTurnId: string | null, liveTurn: LiveTurnProjection | undefined, ): { streaming: boolean; processing: boolean } { - const streaming = turnInFlight && liveTurn?.terminal !== true; + const activeTurnStreaming = activeTurnId !== null && liveTurn?.terminal !== true; + const streaming = hasPendingAdmission || activeTurnStreaming; return { streaming, - processing: streaming && (!liveTurn || liveTurn.phase === 'waiting'), + processing: + streaming && + (!activeTurnStreaming || !liveTurn || liveTurn.phase === 'waiting'), }; } diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index c0cf79016e..ca3c5da373 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -64,9 +64,6 @@ import type { CompanionForkVisibilityEvent } from './quote-companion-visibility. type PendingAdmission = { messageId: string; events: SessionEvent[]; - restoreTurnId: string | null; - restoreLiveTurn: LiveTurnProjection | undefined; - cancelled: boolean; consumeOnAdmission?: () => void; stopPromise?: Promise<'confirmed' | 'unknown'>; }; @@ -81,14 +78,9 @@ function admissionOutcomeForMessage( ): AdmissionOutcome | undefined { const admitted = events.find( (event) => - (event.type === 'steering_message' && event.messageId === messageId) || - (event.type === 'message_admission' && - event.outcome === 'admitted' && - event.messageId === messageId) || - (event.type === 'queue_update' && - event.steeringEntries?.some( - (entry) => entry.messageId === messageId && entry.state === 'in_flight', - ) === true), + event.type === 'message_admission' && + event.outcome === 'admitted' && + event.messageId === messageId, ); if (admitted) return { kind: 'admitted', turnId: admitted.turnId }; const retracted = events.some( @@ -200,7 +192,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const activeTurnIdRef = useRef(null); const pendingAdmissionRef = useRef(null); const subscriptionReadyRef = useRef>(Promise.resolve()); - const turnInFlightRef = useRef(false); + const submitLockRef = useRef(false); const settlingTurnIdsRef = useRef>(new Set()); const onForkVisibilityChangeRef = useRef(onForkVisibilityChange); onForkVisibilityChangeRef.current = onForkVisibilityChange; @@ -214,7 +206,13 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const liveTurnRef = useRef(liveTurn); liveTurnRef.current = liveTurn; const [interactions, setInteractions] = useState({}); - const [turnInFlight, setTurnInFlight] = useState(false); + const [pendingAdmission, setPendingAdmissionState] = useState(null); + const { streaming, processing } = deriveCompanionComposerState( + pendingAdmission !== null, + activeTurnIdRef.current, + liveTurn, + ); + const turnInFlight = streaming; const [preparing, setPreparing] = useState(Boolean(sourceSession)); const [permissionModePending, setPermissionModePending] = useState(false); const [regeneratePendingTurnId, setRegeneratePendingTurnId] = useState( @@ -233,6 +231,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const mountedRef = useMountedRef(); const dismissalGuardRef = useRef(createCompanionDismissalGuard()); + const setPendingAdmission = useCallback((admission: PendingAdmission | null) => { + pendingAdmissionRef.current = admission; + setPendingAdmissionState(admission); + }, []); + const applyOwnedEvent = useCallback( (forkId: string, event: SessionEvent) => { const effect = companionRunEventEffect( @@ -265,16 +268,12 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setAllMessages((current) => mergeSettledMessages(current, next)); setLiveTurn((prev) => (prev ? reconcileTerminalLiveTurn(prev, next) : prev)); activeTurnIdRef.current = null; - turnInFlightRef.current = false; stopRequestedRef.current = false; - setTurnInFlight(false); }) .catch(() => { if (!mountedRef.current || activeTurnIdRef.current !== settledTurnId) return; activeTurnIdRef.current = null; - turnInFlightRef.current = false; stopRequestedRef.current = false; - setTurnInFlight(false); setError((current) => current ?? copyRef.current.errors.settlementFailed); }) .finally(() => { @@ -293,7 +292,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ) => { const admission = pendingAdmissionRef.current; if (!admission) return; - pendingAdmissionRef.current = null; + setPendingAdmission(null); activeTurnIdRef.current = turnId; ownTurnIdsRef.current.add(turnId); admission.consumeOnAdmission?.(); @@ -306,26 +305,18 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (event.turnId === turnId) applyOwnedEvent(forkId, event); } }, - [applyOwnedEvent], + [applyOwnedEvent, setPendingAdmission], ); - const abandonAdmission = useCallback( - (forkId: string, admission: PendingAdmission, message?: string) => { + const releaseAdmission = useCallback( + (admission: PendingAdmission, message?: string) => { if (pendingAdmissionRef.current !== admission) return; - admission.cancelled = true; - pendingAdmissionRef.current = null; - activeTurnIdRef.current = admission.restoreTurnId; - setLiveTurn(admission.restoreLiveTurn); - turnInFlightRef.current = false; - setTurnInFlight(false); + setPendingAdmission(null); + stopRequestedRef.current = false; + if (!activeTurnIdRef.current) setLiveTurn(undefined); if (message) setError(message); - if (admission.restoreTurnId) { - for (const event of admission.events) { - if (event.turnId === admission.restoreTurnId) applyOwnedEvent(forkId, event); - } - } }, - [applyOwnedEvent], + [setPendingAdmission], ); const resolveAdmission = useCallback( @@ -336,14 +327,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan preserveLiveTurn = false, ): AdmissionOutcome | undefined => { const outcome = admissionOutcomeForMessage(admission.events, messageId); - if (outcome?.kind === 'admitted' && !admission.cancelled) { + if (outcome?.kind === 'admitted') { bindAdmittedTurn(forkId, outcome.turnId, { preserveLiveTurn }); } else if (outcome?.kind === 'retracted') { - abandonAdmission(forkId, admission); + releaseAdmission(admission); } return outcome; }, - [abandonAdmission, bindAdmittedTurn], + [bindAdmittedTurn, releaseAdmission], ); // Subscribe to the fork's event stream + load its transcript. Called @@ -397,8 +388,17 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan return; } if (admission) { - admission.events.push(event); - resolveAdmission(forkId, admission, admission.messageId, true); + if ( + event.type === 'message_admission' && + event.messageId === admission.messageId + ) { + admission.events.push(event); + resolveAdmission(forkId, admission, admission.messageId, true); + } else if (event.turnId === activeTurnIdRef.current) { + applyOwnedEvent(forkId, event); + } else { + admission.events.push(event); + } return; } applyOwnedEvent(forkId, event); @@ -568,19 +568,25 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan attachmentItems?: WorkbarIngestInput[], ): Promise => { const trimmed = text.trim(); - if (!mountedRef.current || !trimmed || turnInFlightRef.current || !sourceSession) { + if ( + !mountedRef.current || + !trimmed || + submitLockRef.current || + activeTurnIdRef.current || + pendingAdmissionRef.current || + !sourceSession + ) { return false; } - // Close the same-frame double-submit window before the first await. The - // visible in-flight state still begins only when the run is armed. - turnInFlightRef.current = true; + // Close the same-frame double-submit window before fork readiness can yield. + submitLockRef.current = true; setError(null); const turnId = crypto.randomUUID(); const quoteSnapshot = snapshotCompanionQuotes(panelId, pendingQuotes); const label = (quoteSnapshot.quotes[0]?.text ?? trimmed).slice(0, 24); const fork = await ensureFork(`${copyRef.current.namePrefix}${label}`); if (fork.status !== 'ready') { - turnInFlightRef.current = false; + submitLockRef.current = false; return false; } try { @@ -591,11 +597,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan subscriptionReadyRef.current = subscribeToFork(fork.session.id); setError(copyRef.current.errors.sendFailed); } - turnInFlightRef.current = false; + submitLockRef.current = false; return false; } if (!mountedRef.current) { - turnInFlightRef.current = false; + submitLockRef.current = false; return false; } let sendAdmission: PendingAdmission | undefined; @@ -620,50 +626,36 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // Arm the optimistic live turn right before the send. onBeforeSend: () => { stopRequestedRef.current = false; - const restoreTurnId = activeTurnIdRef.current; - const restoreLiveTurn = liveTurnRef.current; const admission: PendingAdmission = { messageId: turnId, events: [], - restoreTurnId, - restoreLiveTurn, - cancelled: false, }; sendAdmission = admission; - activeTurnIdRef.current = null; - pendingAdmissionRef.current = admission; - turnInFlightRef.current = true; - setTurnInFlight(true); + setPendingAdmission(admission); + submitLockRef.current = false; setLiveTurn(armLiveTurn(turnId)); }, onQuotesConsumed: () => onQuotesConsumed(quoteSnapshot), }); if (result.status === 'sent' || result.status === 'pending') { const admission = sendAdmission; - if (!admission || (admission.cancelled && pendingAdmissionRef.current !== admission)) { - if (!pendingAdmissionRef.current) { - turnInFlightRef.current = false; - setTurnInFlight(false); - } - return false; - } - if (admission.cancelled) { - await admission.stopPromise; - if (admission.cancelled || pendingAdmissionRef.current !== admission) { - abandonAdmission(result.forkId, admission); - return false; - } - } + if (!admission) return false; if (result.status === 'pending') { admission.consumeOnAdmission = () => onQuotesConsumed(quoteSnapshot); - if (resolveAdmission(result.forkId, admission, result.messageId)?.kind === 'retracted') { + const wasPending = pendingAdmissionRef.current === admission; + const outcome = resolveAdmission(result.forkId, admission, result.messageId); + if (outcome?.kind === 'admitted' && !wasPending) admission.consumeOnAdmission(); + if (outcome?.kind === 'retracted') { return false; } } else if (result.steered) { - resolveAdmission(result.forkId, admission, result.messageId); + if (resolveAdmission(result.forkId, admission, result.messageId)?.kind === 'retracted') { + return false; + } } else { bindAdmittedTurn(result.forkId, result.turnId); } + if ((await admission.stopPromise) === 'confirmed') return false; setHasContent(true); // Surface the just-sent user message immediately, and reflect any // automatic connection/model rebound in the read-only model label. @@ -697,13 +689,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }; setError(byCode[result.code]); activeTurnIdRef.current = null; - pendingAdmissionRef.current = null; - turnInFlightRef.current = false; - setTurnInFlight(false); + if (sendAdmission) releaseAdmission(sendAdmission); setLiveTurn(undefined); } // 'disposed' → the panel unmounted mid-create; nothing to update. - turnInFlightRef.current = false; + submitLockRef.current = false; return false; }, [ @@ -714,9 +704,10 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ensureFork, mountedRef, sideChat, - abandonAdmission, bindAdmittedTurn, + releaseAdmission, resolveAdmission, + setPendingAdmission, ], ); @@ -725,12 +716,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (!id || stopRequestedRef.current) return; stopRequestedRef.current = true; const admission = pendingAdmissionRef.current; - if (admission) { - admission.cancelled = true; - activeTurnIdRef.current = admission.restoreTurnId; - setLiveTurn(admission.restoreLiveTurn); - setTurnInFlight(false); - } if (admission) { const stopPromise = sideChat.stop(id, admission.messageId).then( () => 'confirmed' as const, @@ -739,11 +724,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // the Turn. Keep the admission alive so a late Host outcome can // still bind its own Turn; the user can retry Stop after this. if (pendingAdmissionRef.current === admission) { - admission.cancelled = false; admission.stopPromise = undefined; - activeTurnIdRef.current = admission.restoreTurnId; - setLiveTurn(admission.restoreLiveTurn); - setTurnInFlight(true); resolveAdmission(id, admission, admission.messageId, true); } stopRequestedRef.current = false; @@ -751,10 +732,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }, ); admission.stopPromise = stopPromise; - const outcome = await stopPromise; - if (outcome === 'confirmed' && pendingAdmissionRef.current === admission) { - abandonAdmission(id, admission); - } + await stopPromise; return; } try { @@ -763,7 +741,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan stopRequestedRef.current = false; // best-effort; the terminal event still reconciles state } - }, [abandonAdmission, resolveAdmission, sideChat]); + }, [resolveAdmission, sideChat]); const steer = useCallback(async (text: string): Promise => { const id = companionIdRef.current; @@ -781,22 +759,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const admission: PendingAdmission = { messageId: admissionId, events: [], - restoreTurnId: activeTurnIdRef.current, - restoreLiveTurn: liveTurnRef.current, - cancelled: false, }; - pendingAdmissionRef.current = admission; - activeTurnIdRef.current = null; + setPendingAdmission(admission); try { const outcome = await sideChat.steer(id, trimmed, admissionId); if (!mountedRef.current) return false; - if (admission.cancelled && pendingAdmissionRef.current !== admission) return false; - if (admission.cancelled) { - await admission.stopPromise; - if (admission.cancelled || pendingAdmissionRef.current !== admission) { - abandonAdmission(id, admission); - return false; - } + if ((await admission.stopPromise) === 'confirmed') return false; + if (admissionOutcomeForMessage(admission.events, admission.messageId)?.kind === 'retracted') { + return false; } if (outcome.kind === 'started') { bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); @@ -808,14 +778,24 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } catch { if (mountedRef.current) { if (pendingAdmissionRef.current === admission) { - abandonAdmission(id, admission, copyRef.current.errors.sendFailed); - } else if (!admission.cancelled) { + releaseAdmission(admission, copyRef.current.errors.sendFailed); + } else if ( + admissionOutcomeForMessage(admission.events, admission.messageId)?.kind !== 'retracted' + ) { setError(copyRef.current.errors.sendFailed); } } return false; } - }, [abandonAdmission, bindAdmittedTurn, mountedRef, resolveAdmission, sideChat, turnInFlight]); + }, [ + bindAdmittedTurn, + mountedRef, + releaseAdmission, + resolveAdmission, + setPendingAdmission, + sideChat, + turnInFlight, + ]); const setPermissionMode = useCallback( async (mode: PermissionMode): Promise => { @@ -847,8 +827,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const regenerationTurnId = crypto.randomUUID(); stopRequestedRef.current = false; activeTurnIdRef.current = regenerationTurnId; - turnInFlightRef.current = true; - setTurnInFlight(true); setError(null); setLiveTurn(armLiveTurn(regenerationTurnId)); ownTurnIdsRef.current.add(regenerationTurnId); @@ -862,8 +840,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } catch { if (mountedRef.current) { activeTurnIdRef.current = null; - turnInFlightRef.current = false; - setTurnInFlight(false); setLiveTurn(undefined); setError(copyRef.current.errors.sendFailed); } @@ -906,7 +882,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const messages = allMessages.filter( (message) => message.turnId !== undefined && ownTurnIdsRef.current.has(message.turnId), ); - const { streaming, processing } = deriveCompanionComposerState(turnInFlight, liveTurn); // Inherited model (read-only): the fork's once created, else the source's. const activeModel = companion ? { llmConnectionSlug: companion.llmConnectionSlug, model: companion.model } From de04c74876522cf2266267d3d83896a216713f71 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 11:29:40 +0800 Subject: [PATCH 21/26] fix(desktop): preserve renderer send identity Generated-by: Codex --- .../runtime-host-session-execution-ipc-main.test.ts | 2 +- .../src/main/runtime-host-session-execution-ipc-main.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index cb547339a6..1a32f19c97 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -612,7 +612,7 @@ test("queues a mid-turn send as steering when the Host reports the session busy" assert.deepEqual(submits, [ { sessionId: "session-1", - messageId: "id-1", + messageId: "turn-1", content: { text: "also check the tests", inlineReferences: [] }, placement: "current_turn", }, diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index d5ed901375..51c12a6f45 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -347,10 +347,9 @@ export function registerRuntimeHostSessionExecutionIpc( ) { throw error; } - // Side Conversation keeps its requested Turn id as the admission - // ticket so a successor root can report ownership with that identity. - // Ordinary sends retain the pre-existing independent message id. - const messageId = sideConversation ? turnId : newId(); + // Preserve the renderer's command identity in the durable message so + // a lost IPC reply can be reconciled as root-vs-steering later. + const messageId = turnId; const emptySkillInvocation = { loaded: [], failed: [], receipts: [] }; const submitInput = { sessionId, From a8b4569f3b00e63af25b62077e196cd431395868 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 11:29:45 +0800 Subject: [PATCH 22/26] fix(desktop): reconnect session observation setup Generated-by: Codex --- ...me-host-session-execution-ipc-main.test.ts | 30 ++++++++++++++----- ...runtime-host-session-execution-ipc-main.ts | 3 +- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 1a32f19c97..43eed88c09 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -43,6 +43,13 @@ import { import { RuntimeHostSessionObserver } from "../runtime-host-session-observer.js"; import { runtimeHostSessionFixture } from "./runtime-host-session-test-fixture.js"; +test('registers Session observation as one reconnectable operation', () => { + const ipc = ipcHarness(); + registerExecutionIpc({ client: executionClient({}) }, ipc); + + assert.equal(ipc.reconnectableChannels.has('sessions:observe'), true); +}); + test("keeps synthetic E2E interactions visible through Host hydration and retires their answer", async () => { const observer = observerWithSnapshot(); const ipc = ipcHarness(); @@ -1414,15 +1421,24 @@ type IpcHandler = Parameters["handle"]>[1]; function ipcHarness() { const handlers = new Map(); + const reconnectableChannels = new Set(); const sender = Object.assign(new EventEmitter(), { id: 9, send() {} }); + const register = (channel: string, handler: IpcHandler) => { + assert.equal( + handlers.has(channel), + false, + `duplicate handler: ${channel}`, + ); + handlers.set(channel, handler); + }; return { + reconnectableChannels, handle(channel: string, handler: IpcHandler) { - assert.equal( - handlers.has(channel), - false, - `duplicate handler: ${channel}`, - ); - handlers.set(channel, handler); + register(channel, handler); + }, + handleReconnectableRead(channel: string, handler: IpcHandler) { + reconnectableChannels.add(channel); + register(channel, handler); }, async invoke(channel: string, ...args: unknown[]): Promise { const handler = handlers.get(channel); @@ -1441,7 +1457,7 @@ function ipcHarness() { function registerExecutionIpc( deps: Pick & Partial>, - ipcMain: Pick, + ipcMain: Pick & { handleReconnectableRead?: IpcMain['handle'] }, ): (sessionId: string) => Promise { const observer = deps.observer ?? unusedObserver(); return registerRuntimeHostSessionExecutionIpc( diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 51c12a6f45..4df772c916 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -189,7 +189,8 @@ export function registerRuntimeHostSessionExecutionIpc( const newId = deps.newId ?? randomUUID; const stopSession = createRuntimeHostSessionStop(deps, newId); - ipcMain.handle( + handleReconnectableRead( + ipcMain, "sessions:observe", async (event, sessionId: unknown, observerId: unknown) => { const normalizedSessionId = requiredId(sessionId, "Session"); From 9c892052332d2f6c764d81ce039ea7447135df71 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 11:33:52 +0800 Subject: [PATCH 23/26] fix(side-chat): fence overlapping stop results Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 59 +++++++++++++++++++ .../tools/side-chat/use-quote-companion.ts | 26 ++++---- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index f5860d165c..89afebe2f6 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -820,6 +820,65 @@ test('stops the active Side Conversation after retracting its queued steer', asy }); }); +test('does not let an older Stop failure release a newer active Turn Stop', async () => { + const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + const queuedStop = deferred(); + const activeStop = deferred(); + let admissionId: string | undefined; + const stoppedIds: Array = []; + const { emit, send, steer, stop } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, requestedAdmissionId) => { + admissionId = requestedAdmissionId; + return pendingSteer.promise; + }, + stop: async (_sessionId, expectedId) => { + stoppedIds.push(expectedId); + return stoppedIds.length === 1 ? queuedStop.promise : activeStop.promise; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + await Promise.resolve(); + }); + let steerResult!: Promise; + await act(async () => { + steerResult = steer('queue this steer'); + await Promise.resolve(); + }); + await waitUntil(() => admissionId !== undefined); + const queuedStopResult = stop(); + await act(async () => { + emit({ + type: 'message_admission', + id: 'queued-steer-retracted-before-stop-reply', + turnId: 'old-turn', + ts: 1, + messageId: admissionId as string, + outcome: 'retracted', + }); + await Promise.resolve(); + }); + const activeStopResult = stop(); + await act(async () => { + queuedStop.reject(new Error('old Stop reply was lost')); + await queuedStopResult; + await Promise.resolve(); + }); + const duplicateStopResult = stop(); + await Promise.resolve(); + + assert.deepEqual(stoppedIds, [admissionId, 'old-turn']); + activeStop.resolve(); + await Promise.all([activeStopResult, duplicateStopResult]); + await act(async () => { + pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); + assert.equal(await steerResult, false); + await Promise.resolve(); + }); +}); + test('continues projecting the active Turn while a steer awaits Host admission', async () => { const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); let admissionId: string | undefined; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index ca3c5da373..4a1b3e4031 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -188,7 +188,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const sourceSessionIdRef = useRef(sourceSession?.id); sourceSessionIdRef.current = sourceSessionId; const forkSetupPromiseRef = useRef | null>(null); - const stopRequestedRef = useRef(false); + const stopRequestRef = useRef | null>(null); const activeTurnIdRef = useRef(null); const pendingAdmissionRef = useRef(null); const subscriptionReadyRef = useRef>(Promise.resolve()); @@ -241,7 +241,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const effect = companionRunEventEffect( event, activeTurnIdRef.current, - stopRequestedRef.current, + stopRequestRef.current !== null, localeRef.current, ); if (effect.kind === 'ignore') return; @@ -268,12 +268,12 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setAllMessages((current) => mergeSettledMessages(current, next)); setLiveTurn((prev) => (prev ? reconcileTerminalLiveTurn(prev, next) : prev)); activeTurnIdRef.current = null; - stopRequestedRef.current = false; + stopRequestRef.current = null; }) .catch(() => { if (!mountedRef.current || activeTurnIdRef.current !== settledTurnId) return; activeTurnIdRef.current = null; - stopRequestedRef.current = false; + stopRequestRef.current = null; setError((current) => current ?? copyRef.current.errors.settlementFailed); }) .finally(() => { @@ -312,7 +312,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan (admission: PendingAdmission, message?: string) => { if (pendingAdmissionRef.current !== admission) return; setPendingAdmission(null); - stopRequestedRef.current = false; + if (stopRequestRef.current === admission.stopPromise) stopRequestRef.current = null; if (!activeTurnIdRef.current) setLiveTurn(undefined); if (message) setError(message); }, @@ -625,7 +625,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan onForkCommitted: () => {}, // Arm the optimistic live turn right before the send. onBeforeSend: () => { - stopRequestedRef.current = false; + stopRequestRef.current = null; const admission: PendingAdmission = { messageId: turnId, events: [], @@ -713,8 +713,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const stop = useCallback(async (): Promise => { const id = companionIdRef.current; - if (!id || stopRequestedRef.current) return; - stopRequestedRef.current = true; + if (!id || stopRequestRef.current) return; const admission = pendingAdmissionRef.current; if (admission) { const stopPromise = sideChat.stop(id, admission.messageId).then( @@ -727,18 +726,21 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan admission.stopPromise = undefined; resolveAdmission(id, admission, admission.messageId, true); } - stopRequestedRef.current = false; + if (stopRequestRef.current === stopPromise) stopRequestRef.current = null; return 'unknown' as const; }, ); admission.stopPromise = stopPromise; + stopRequestRef.current = stopPromise; await stopPromise; return; } + const stopPromise = sideChat.stop(id, activeTurnIdRef.current ?? undefined); + stopRequestRef.current = stopPromise; try { - await sideChat.stop(id, activeTurnIdRef.current ?? undefined); + await stopPromise; } catch { - stopRequestedRef.current = false; + if (stopRequestRef.current === stopPromise) stopRequestRef.current = null; // best-effort; the terminal event still reconciles state } }, [resolveAdmission, sideChat]); @@ -825,7 +827,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (!id || turnInFlight || regeneratePendingTurnId) return false; setRegeneratePendingTurnId(turnId); const regenerationTurnId = crypto.randomUUID(); - stopRequestedRef.current = false; + stopRequestRef.current = null; activeTurnIdRef.current = regenerationTurnId; setError(null); setLiveTurn(armLiveTurn(regenerationTurnId)); From e72ed85e8b4702958c9f82ac55ce338bf1b139b6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 11:55:42 +0800 Subject: [PATCH 24/26] fix(side-chat): admit active turn steering Generated-by: Codex --- .../src/__tests__/session-projector.test.ts | 105 ++++++++++++++++++ .../src/adapter/session-projector.ts | 19 +++- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 23b35485bd..33171c50fb 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -202,6 +202,111 @@ test('emits the Host admission fact when a queued message enters a successor Tur ); }); +test('emits the Host admission fact when a queued message enters the active Turn', () => { + const previous = snapshot({ + queue: { + hostEpoch: 'host-1', + queueRevision: 1, + steering: [ + { + entryId: 'entry-1', + messageId: 'ticket-1', + content: { text: 'continue here' }, + placement: 'current_turn', + state: 'queued', + }, + ], + followup: [], + }, + }); + const projector = new RuntimeHostSessionProjector( + previous, + createRuntimeHostSessionProjectionSeed([], previous), + () => 10, + [], + true, + ); + + const events = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ + projectionRevision: 2, + queue: { + hostEpoch: 'host-1', + queueRevision: 2, + steering: [ + { + entryId: 'entry-1', + messageId: 'ticket-1', + content: { text: 'continue here' }, + placement: 'current_turn', + state: 'in_flight', + }, + ], + followup: [], + }, + }), + }).events; + + assert.deepEqual( + events + .filter( + (event): event is Extract => + event.type === 'message_admission', + ) + .map((event) => ({ + outcome: event.outcome, + turnId: event.turnId, + messageId: event.messageId, + })), + [{ outcome: 'admitted', turnId: 'turn-1', messageId: 'ticket-1' }], + ); +}); + +test('reseeds the Host admission fact for a message already in the active Turn', () => { + const current = snapshot({ + queue: { + hostEpoch: 'host-1', + queueRevision: 2, + steering: [ + { + entryId: 'entry-1', + messageId: 'ticket-1', + content: { text: 'continue here' }, + placement: 'current_turn', + state: 'in_flight', + }, + ], + followup: [], + }, + }); + const projector = new RuntimeHostSessionProjector( + current, + createRuntimeHostSessionProjectionSeed([], current), + () => 10, + [], + true, + ); + + assert.deepEqual( + projector + .seedActive(false) + .filter( + (event): event is Extract => + event.type === 'message_admission', + ) + .map((event) => ({ + outcome: event.outcome, + turnId: event.turnId, + messageId: event.messageId, + })), + [{ outcome: 'admitted', turnId: 'turn-1', messageId: 'ticket-1' }], + ); +}); + test('reseeds the latest provider retry when the active Turn still carries one', () => { const retry = { phase: 'scheduled' as const, diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 6404654a0c..82378119c5 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -154,7 +154,12 @@ export class RuntimeHostSessionProjector { events.push( ...projectMessageAdmissionEvents( root, - this.#snapshot.rootTurnSourceMessageIds, + [ + ...new Set([ + ...this.#snapshot.rootTurnSourceMessageIds, + ...rootQueueInFlight(this.#snapshot.queue).map((entry) => entry.messageId), + ]), + ], this.#now(), ), ); @@ -378,12 +383,16 @@ export class RuntimeHostSessionProjector { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); } const root = next.rootTurn; + const enteredActiveTurn = + root && queueChanged(previousSnapshot.queue, next.queue) + ? newlyInFlight(previousSnapshot.queue, next.queue) + : []; if (this.#projectMessageAdmissions) { events.push(...projectMessageRetractionEvents(previousSnapshot, next, this.#now())); events.push(...projectNewMessageAdmissionEvents(previousSnapshot, next, this.#now())); } if (root && queueChanged(previousSnapshot.queue, next.queue)) { - for (const entry of newlyInFlight(previousSnapshot.queue, next.queue)) { + for (const entry of enteredActiveTurn) { events.push({ type: 'steering_message', id: `host-queue:${next.queue.hostEpoch}:${next.queue.queueRevision}:${entry.entryId}`, @@ -489,11 +498,11 @@ function projectNewMessageAdmissionEvents( previous.rootTurn?.runId === root.runId ? new Set(previous.rootTurnSourceMessageIds) : new Set(); - return projectMessageAdmissionEvents( - root, + const messageIds = new Set( next.rootTurnSourceMessageIds.filter((messageId) => !previousIds.has(messageId)), - ts, ); + for (const entry of newlyInFlight(previous.queue, next.queue)) messageIds.add(entry.messageId); + return projectMessageAdmissionEvents(root, [...messageIds], ts); } function projectMessageRetractionEvents( From e896f464838f229bc3b5dbbe2b042ba3b741faf5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 12:09:07 +0800 Subject: [PATCH 25/26] fix(side-chat): replay durable steering admission Generated-by: Codex --- .../src/__tests__/session-projector.test.ts | 38 +++++++++++++++++++ .../src/adapter/session-projector.ts | 26 +++++++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 33171c50fb..492b0967ee 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -307,6 +307,44 @@ test('reseeds the Host admission fact for a message already in the active Turn', ); }); +test('reseeds the Host admission fact after an active Turn message leaves the queue', () => { + const current = snapshot(); + const projector = new RuntimeHostSessionProjector( + current, + createRuntimeHostSessionProjectionSeed( + [ + { + type: 'user', + id: 'ticket-1', + turnId: 'turn-1', + ts: 1, + text: 'continue here', + steeringEventId: 'steering-event-1', + }, + ], + current, + ), + () => 10, + [], + true, + ); + + assert.deepEqual( + projector + .seedActive(false) + .filter( + (event): event is Extract => + event.type === 'message_admission', + ) + .map((event) => ({ + outcome: event.outcome, + turnId: event.turnId, + messageId: event.messageId, + })), + [{ outcome: 'admitted', turnId: 'turn-1', messageId: 'ticket-1' }], + ); +}); + test('reseeds the latest provider retry when the active Turn still carries one', () => { const retry = { phase: 'scheduled' as const, diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 82378119c5..bb1618e4e2 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -42,7 +42,10 @@ interface AssistantAccumulator { } export interface RuntimeHostSessionProjectionSeed { - readonly durableInFlightMessageIds: readonly string[]; + readonly durableSteeringMessages: readonly { + readonly messageId: string; + readonly turnId: string; + }[]; readonly activeAssistantMessages: readonly Extract[]; } @@ -50,13 +53,13 @@ export function createRuntimeHostSessionProjectionSeed( transcript: readonly StoredMessage[], snapshot: SessionContinuitySnapshot, ): RuntimeHostSessionProjectionSeed { - const inFlightMessageIds = new Set( - rootQueueInFlight(snapshot.queue).map((entry) => entry.messageId), - ); return { - durableInFlightMessageIds: transcript - .filter((message) => inFlightMessageIds.has(message.id)) - .map((message) => message.id), + durableSteeringMessages: transcript + .filter( + (message): message is Extract => + message.type === 'user' && message.steeringEventId !== undefined, + ) + .map((message) => ({ messageId: message.id, turnId: message.turnId })), activeAssistantMessages: snapshot.rootTurn === null ? [] @@ -84,6 +87,7 @@ export class RuntimeHostSessionProjector { #snapshot: SessionContinuitySnapshot; readonly #now: () => number; readonly #transcriptIds: Set; + readonly #durableSteeringTurnByMessage: ReadonlyMap; readonly #accumulators = new Map(); #projectMessageAdmissions: boolean; @@ -96,7 +100,10 @@ export class RuntimeHostSessionProjector { ) { this.#snapshot = structuredClone(snapshot); this.#now = now; - this.#transcriptIds = new Set(seed.durableInFlightMessageIds); + this.#durableSteeringTurnByMessage = new Map( + seed.durableSteeringMessages.map(({ messageId, turnId }) => [messageId, turnId]), + ); + this.#transcriptIds = new Set(this.#durableSteeringTurnByMessage.keys()); this.#projectMessageAdmissions = projectMessageAdmissions; const root = snapshot.rootTurn; if (!root) return; @@ -158,6 +165,9 @@ export class RuntimeHostSessionProjector { ...new Set([ ...this.#snapshot.rootTurnSourceMessageIds, ...rootQueueInFlight(this.#snapshot.queue).map((entry) => entry.messageId), + ...[...this.#durableSteeringTurnByMessage] + .filter(([, turnId]) => turnId === root.turnId) + .map(([messageId]) => messageId), ]), ], this.#now(), From 1a5d92d87229f873bf0f540da29f3e8eaf2c37e5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 15:17:16 +0800 Subject: [PATCH 26/26] fix(side-chat): confirm queued admission retraction Generated-by: Codex --- .../__tests__/quote-companion-retry.test.ts | 42 ++++++------------- ...me-host-session-execution-ipc-main.test.ts | 11 +++-- ...runtime-host-session-execution-ipc-main.ts | 9 ++-- apps/desktop/src/preload/bridge-contract.d.ts | 6 ++- apps/desktop/src/preload/preload.ts | 3 +- .../src/renderer/features/workbar/ports.ts | 5 ++- .../tools/side-chat/use-quote-companion.ts | 12 +++++- .../src/renderer/workhub-session-port.ts | 2 +- 8 files changed, 47 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 89afebe2f6..c92153b392 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -484,16 +484,16 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s assert.equal(probe.getAttribute('data-processing'), 'false'); }); -test('waits for Host retraction before clearing a stopped Side Conversation admission', async () => { +test('clears a stopped Side Conversation admission when its live retraction is lost', async () => { let admissionId: string | undefined; - const pendingStop = deferred(); + const pendingStop = deferred<{ kind: 'retracted'; messageId: string }>(); const pendingSend = deferred<{ ok: true; steered: true; turnId: string; messageId: string; }>(); - const { container, emit, send, stop } = await renderOwnershipProbe({ + const { container, send, stop } = await renderOwnershipProbe({ send: async (_sessionId, command) => { admissionId = command.turnId; return pendingSend.promise; @@ -517,23 +517,10 @@ test('waits for Host retraction before clearing a stopped Side Conversation admi assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { - pendingStop.resolve(); + pendingStop.resolve({ kind: 'retracted', messageId: admissionId as string }); await stopResult; await Promise.resolve(); }); - assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); - - await act(async () => { - emit({ - type: 'message_admission', - id: 'stopped-admission-retracted', - turnId: 'old-turn', - ts: 1, - messageId: admissionId as string, - outcome: 'retracted', - }); - await Promise.resolve(); - }); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); await act(async () => { @@ -552,7 +539,7 @@ test('waits for Host retraction before clearing a stopped Side Conversation admi test('keeps a Side Conversation admission when Host stop outcome is unknown', async () => { let admissionId: string | undefined; - const pendingStop = deferred(); + const pendingStop = deferred(); const { container, emit, send, stop } = await renderOwnershipProbe({ send: async (_sessionId, command) => { admissionId = command.turnId; @@ -774,7 +761,7 @@ test('stops the active Side Conversation after retracting its queued steer', asy const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); let admissionId: string | undefined; const stoppedIds: Array = []; - const { emit, send, steer, stop } = await renderOwnershipProbe({ + const { send, steer, stop } = await renderOwnershipProbe({ send: async () => ({ ok: true as const, turnId: 'old-turn' }), steer: async (_sessionId, _text, requestedAdmissionId) => { admissionId = requestedAdmissionId; @@ -782,6 +769,9 @@ test('stops the active Side Conversation after retracting its queued steer', asy }, stop: async (_sessionId, expectedId) => { stoppedIds.push(expectedId); + return expectedId && expectedId === admissionId + ? { kind: 'retracted' as const, messageId: expectedId } + : undefined; }, }); @@ -797,14 +787,6 @@ test('stops the active Side Conversation after retracting its queued steer', asy await waitUntil(() => admissionId !== undefined); await act(async () => { await stop(); - emit({ - type: 'message_admission', - id: 'queued-steer-retracted-before-active-stop', - turnId: 'old-turn', - ts: 1, - messageId: admissionId as string, - outcome: 'retracted', - }); await Promise.resolve(); }); await act(async () => { @@ -822,8 +804,8 @@ test('stops the active Side Conversation after retracting its queued steer', asy test('does not let an older Stop failure release a newer active Turn Stop', async () => { const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); - const queuedStop = deferred(); - const activeStop = deferred(); + const queuedStop = deferred(); + const activeStop = deferred(); let admissionId: string | undefined; const stoppedIds: Array = []; const { emit, send, steer, stop } = await renderOwnershipProbe({ @@ -870,7 +852,7 @@ test('does not let an older Stop failure release a newer active Turn Stop', asyn await Promise.resolve(); assert.deepEqual(stoppedIds, [admissionId, 'old-turn']); - activeStop.resolve(); + activeStop.resolve(undefined); await Promise.all([activeStopResult, duplicateStopResult]); await act(async () => { pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 43eed88c09..708350c8f2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -1240,10 +1240,13 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn await ipc.invoke('sessions:steer', 'session-1', 'Continue', 'unknown-ticket'), { kind: 'outcome_unknown', messageId: 'unknown-ticket' }, ); - await ipc.invoke("sessions:stop", "session-1", { - source: "stop_button", - expectedAdmissionId: "steer-ticket-1", - }); + assert.deepEqual( + await ipc.invoke("sessions:stop", "session-1", { + source: "stop_button", + expectedAdmissionId: "steer-ticket-1", + }), + { kind: 'retracted', messageId: 'steer-ticket-1' }, + ); assert.deepEqual(retractions, [ { sessionId: 'session-1', diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 4df772c916..533c8786ec 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -60,6 +60,7 @@ import { type RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; import type { DesktopTranscriptRangeRequest } from '../preload/transcript-contract.js'; +import type { DesktopSessionStopResult } from '../preload/bridge-contract.js'; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { mergeWorkspaceFileInlineReferences } from "./session-workspace-inline-references.js"; @@ -756,7 +757,9 @@ export function registerRuntimeHostSessionExecutionIpc( return toDesktopHostSessionSummary(revision); }, ); - return stopSession; + return async (sessionId) => { + await stopSession(sessionId); + }; } function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRangeRequest { @@ -810,7 +813,7 @@ function createRuntimeHostSessionStop( ): ( sessionId: string, target?: { readonly expectedTurnId?: string; readonly expectedAdmissionId?: string }, -) => Promise { +) => Promise { return async (sessionId, target = {}) => { let expectedTurnId = target.expectedTurnId; if (target.expectedAdmissionId) { @@ -826,7 +829,7 @@ function createRuntimeHostSessionStop( retractId: newId(), }); deps.emitSessionsChanged('status-change', sessionId); - return; + return { kind: 'retracted', messageId: entry.messageId }; } if ( root && diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 3917b1014c..293268ad20 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -206,6 +206,10 @@ export type DesktopSideConversationBranchResult = | { ok: true; session: DesktopSessionSummary } | { ok: false; reason: 'session_busy' | 'operation_unavailable' }; +export type DesktopSessionStopResult = + | { kind: 'retracted'; messageId: string } + | undefined; + export type DesktopReviseBeforeTurnInput = ReviseBeforeTurnInput & { /** Stable target identity for retrying one Desktop copy action. */ copyId: string; @@ -777,7 +781,7 @@ export interface MakaBridge { expectedTurnId?: string; expectedAdmissionId?: string; }, - ): Promise; + ): Promise; steer( sessionId: string, text: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 6df3004c3b..661baf0b69 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -30,6 +30,7 @@ import type { RendererIngestInput, DesktopBranchFromTurnInput, DesktopSideConversationBranchResult, + DesktopSessionStopResult, DesktopReviseBeforeTurnInput, AppUpdateInstallRequest, AppUpdateInstallResult, @@ -1598,7 +1599,7 @@ const makaBridge = { expectedTurnId?: string; expectedAdmissionId?: string; }, - ): Promise { + ): Promise { return invokeSessionRuntimeHost('sessions:stop', sessionId, input); }, steer( diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 7c146c7c38..37e97ff710 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -237,7 +237,10 @@ export interface SideChatSessionPort { attachmentItems?: WorkbarIngestInput[]; }, ): Promise; - stop(sessionId: string, admissionId?: string): Promise; + stop( + sessionId: string, + admissionId?: string, + ): Promise<{ kind: 'retracted'; messageId: string } | undefined>; steer(sessionId: string, text: string, admissionId?: string): Promise; setPermissionMode( sessionId: string, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 4a1b3e4031..cc5c5fec10 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -717,7 +717,15 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const admission = pendingAdmissionRef.current; if (admission) { const stopPromise = sideChat.stop(id, admission.messageId).then( - () => 'confirmed' as const, + (outcome) => { + if ( + outcome?.kind === 'retracted' && + outcome.messageId === admission.messageId + ) { + releaseAdmission(admission); + } + return 'confirmed' as const; + }, () => { // A rejected Stop tells us nothing about whether the Host stopped // the Turn. Keep the admission alive so a late Host outcome can @@ -743,7 +751,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (stopRequestRef.current === stopPromise) stopRequestRef.current = null; // best-effort; the terminal event still reconciles state } - }, [resolveAdmission, sideChat]); + }, [releaseAdmission, resolveAdmission, sideChat]); const steer = useCallback(async (text: string): Promise => { const id = companionIdRef.current; diff --git a/apps/desktop/src/renderer/workhub-session-port.ts b/apps/desktop/src/renderer/workhub-session-port.ts index 4d66344637..35558b5fab 100644 --- a/apps/desktop/src/renderer/workhub-session-port.ts +++ b/apps/desktop/src/renderer/workhub-session-port.ts @@ -68,7 +68,7 @@ export interface WorkHubDesktopSessionBridge { stop( sessionId: string, input?: { source?: 'stop_button'; expectedTurnId?: string }, - ): Promise; + ): Promise; subscribeChanges(handler: () => void): () => void; }