From c25ac967afe921936a0b876f73d76a397dca749f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 22:01:14 +0800 Subject: [PATCH 01/26] fix: reconcile client messages by canonical identity Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 1 + .../transient-message-projection.test.ts | 48 +++++ ...runtime-host-session-execution-ipc-main.ts | 5 +- apps/desktop/src/preload/bridge-contract.d.ts | 1 + apps/desktop/src/preload/preload.ts | 2 + .../src/renderer/app-shell-chat-actions.ts | 33 ++- apps/desktop/src/renderer/app-shell.tsx | 4 + .../renderer/transient-message-projection.ts | 37 ++++ .../use-app-shell-session-workspace.ts | 52 ++++- .../cli/src/__tests__/pi-transcript.test.ts | 32 ++- .../cli/src/__tests__/pi-tui-runner.test.ts | 26 +-- .../pi-tui-transcript-viewer.test.ts | 2 +- .../cli/src/__tests__/pi-tui-turn.test.ts | 13 +- packages/cli/src/pi-transcript.ts | 78 ++++--- packages/cli/src/pi-tui-runner.ts | 192 ++---------------- packages/cli/src/pi-tui-turn.ts | 11 +- .../cli/src/runtime-host-session-driver.ts | 6 - packages/cli/src/session-driver.ts | 1 - .../__tests__/root-turn-coordinator.test.ts | 29 +++ .../src/server/root-turn-coordinator.ts | 8 +- packages/ui/src/__tests__/materialize.test.ts | 4 + packages/ui/src/materialize.ts | 11 + 22 files changed, 332 insertions(+), 264 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/transient-message-projection.test.ts create mode 100644 apps/desktop/src/renderer/transient-message-projection.ts diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 7b0edd27b9..2bbcc27ffc 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -179,6 +179,7 @@ describe('busy-raced send settlement', () => { const optimistic = messageState.messages.filter((message) => message.type === 'user'); assert.equal(optimistic.length, 1); assert.equal(optimistic[0]?.turnId, 'host-turn'); + assert.equal(optimistic[0]?.id, 'host-turn'); } finally { restoreWindow(); } diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts new file mode 100644 index 0000000000..0ff51ec392 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -0,0 +1,48 @@ +/* + * 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 type { StoredMessage } from '@maka/core/session'; +import { reconcileTransientMessages } from '../../renderer/transient-message-projection.js'; + +const transient: StoredMessage = { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 2, + text: 'send now', +}; + +test('keeps a transient message through sparse transcript replacement', () => { + const pending = new Map([[transient.id, transient]]); + const projected = reconcileTransientMessages(pending, []); + + assert.deepEqual(projected, [transient]); + assert.equal(pending.has(transient.id), true); +}); + +test('replaces a transient message by canonical message id exactly once', () => { + const pending = new Map([[transient.id, transient]]); + const canonical = { ...transient, ts: 3, text: 'canonical send' }; + const projected = reconcileTransientMessages(pending, [canonical]); + + assert.deepEqual(projected, [canonical]); + assert.equal(pending.size, 0); +}); 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 8e42f395c9..5e0ba74a77 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 @@ -487,9 +487,10 @@ export function registerRuntimeHostSessionExecutionIpc( displayText, workspaceFileReferences: command.workspaceFileReferences, }); + const messageId = newId(); const result = await deps.client.submitMessage({ sessionId, - messageId: newId(), + messageId, placement, content: { text: command.text, @@ -508,12 +509,14 @@ export function registerRuntimeHostSessionExecutionIpc( return { kind: "started" as const, turnId: result.turnId, + messageId, attachments, inlineReferences, }; } return { kind: "queued" as const, + messageId, attachments, inlineReferences, }; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f2aa6d8817..cb3e0a41bf 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -870,6 +870,7 @@ export interface MakaBridge { ): Promise<{ kind: 'queued' | 'started'; turnId?: string; + messageId: string; attachments: import('@maka/core/events').AttachmentRef[]; inlineReferences: import('@maka/core/events').InlineReference[]; }>; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7fc166f717..ffad51db57 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1693,6 +1693,7 @@ const makaBridge = { ): Promise<{ kind: 'queued' | 'started'; turnId?: string; + messageId: string; attachments: AttachmentRef[]; inlineReferences: InlineReference[]; }> { @@ -1712,6 +1713,7 @@ const makaBridge = { ) as { kind: 'queued' | 'started'; turnId?: string; + messageId: string; attachments: AttachmentRef[]; inlineReferences: InlineReference[]; }; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 3c74ddfd47..6b2af3c717 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -144,6 +144,8 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession: MessageLoadErrorUpdater; setMessageRetryPendingBySession: BooleanRecordUpdater; setMessages: MessageListUpdater; + addTransientMessage?: (sessionId: string, message: StoredMessage) => void; + removeTransientMessage?: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; setNavSelection: (selection: NavSelection) => void; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait @@ -192,6 +194,8 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, + addTransientMessage, + removeTransientMessage, transcriptRangeRef, setNavSelection, setLiveTurnBySession, @@ -219,7 +223,10 @@ export function createAppShellChatActions(deps: { ): StoredMessage { return { type: 'user', - id: `optimistic-user-${turnId}`, + // `turnId` is the Host's canonical message identity for direct sends. + // Keeping it on the transient row lets the durable transcript replace + // the same row instead of appending a second user message. + id: turnId, turnId, ts: Date.now(), text, @@ -247,22 +254,30 @@ export function createAppShellChatActions(deps: { delete next[sessionId]; return next; }); + const next = optimisticUserMessage( + turnId, + text, + attachments, + options.quotes, + options.inlineReferences, + ); + if (addTransientMessage) { + addTransientMessage(sessionId, next); + return; + } setMessages((current) => { if (current.some((message) => message.type === 'user' && message.turnId === turnId)) return current; - const next = optimisticUserMessage( - turnId, - text, - attachments, - options.quotes, - options.inlineReferences, - ); return options.replaceCurrentMessages ? [next] : [...current, next]; }); } function removeOptimisticUserMessage(sessionId: string, turnId: string): void { if (activeIdRef.current !== sessionId) return; - setMessages((current) => current.filter((message) => message.id !== `optimistic-user-${turnId}`)); + if (removeTransientMessage) { + removeTransientMessage(sessionId, turnId); + return; + } + setMessages((current) => current.filter((message) => message.id !== turnId)); } // #646: open the turn's model-wait window for a session. Armed the moment diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d398cbff8e..06898fdfb0 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -352,6 +352,8 @@ function AppShellContent({ clearOwnedSessionState, messages, setMessages, + addTransientMessage, + removeTransientMessage, transcriptRangeRef, messageLoadPending, setMessageLoadPending, @@ -1774,6 +1776,8 @@ function AppShellContent({ setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, + addTransientMessage, + removeTransientMessage, transcriptRangeRef, setNavSelection, setLiveTurnBySession, diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/transient-message-projection.ts new file mode 100644 index 0000000000..e38f2283b1 --- /dev/null +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -0,0 +1,37 @@ +/* + * 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 type { StoredMessage } from '@maka/core/session'; + +/** + * Merge a renderer-only message into the current transcript until the + * canonical transcript carries the same message id. The map is presentation + * state only; it never decides delivery or retry. + */ +export function reconcileTransientMessages( + transient: Map, + durable: readonly StoredMessage[], +): StoredMessage[] { + for (const message of durable) transient.delete(message.id); + if (transient.size === 0) return [...durable]; + return [ + ...durable, + ...[...transient.values()].sort((left, right) => left.ts - right.ts), + ]; +} diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index a43043eeda..310440b37e 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -28,11 +28,16 @@ import { markNewTaskReloadIntent, } from './new-task-reload-intent'; import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; +import { reconcileTransientMessages } from './transient-message-projection.js'; type ToastApi = { error(title: string, description?: string): void; }; +type MessageListUpdater = ( + next: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[]), +) => void; + export function useAppShellSessionWorkspace(toastApi: ToastApi) { const [activeId, setActiveIdState] = useState(); const activeIdRef = useRef(undefined); @@ -45,11 +50,52 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { const selectionRevisionRef = useRef(0); const bootstrapSelectionLeaseRef = useRef | null>(null); const [messages, setMessages] = useState([]); + const transientMessagesBySessionRef = useRef(new Map>()); const transcriptRangeRef = useRef(undefined); const [messageLoadPending, setMessageLoadPending] = useState(false); const messageRetryPendingRef = useRef>(new Set()); const stopPendingRef = useRef>(new Set()); + function mergeTransientMessages(sessionId: string, durable: readonly StoredMessage[]): StoredMessage[] { + const pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending || pending.size === 0) return [...durable]; + const projected = reconcileTransientMessages(pending, durable); + if (pending.size === 0) { + transientMessagesBySessionRef.current.delete(sessionId); + } + return projected; + } + + const setMessagesForActiveSession: MessageListUpdater = (next) => { + setMessages((current) => { + const projected = typeof next === 'function' ? next([...current]) : next; + const sessionId = activeIdRef.current; + return sessionId ? mergeTransientMessages(sessionId, projected) : projected; + }); + }; + + function addTransientMessage(sessionId: string, message: StoredMessage): void { + let pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending) { + pending = new Map(); + transientMessagesBySessionRef.current.set(sessionId, pending); + } + pending.set(message.id, message); + if (activeIdRef.current === sessionId) { + setMessages((current) => mergeTransientMessages(sessionId, current)); + } + } + + function removeTransientMessage(sessionId: string, messageId: string): void { + const pending = transientMessagesBySessionRef.current.get(sessionId); + if (pending?.delete(messageId) && pending.size === 0) { + transientMessagesBySessionRef.current.delete(sessionId); + } + if (activeIdRef.current === sessionId) { + setMessages((current) => current.filter((message) => message.id !== messageId)); + } + } + function setActiveId(next: string | undefined): void { selectionRevisionRef.current += 1; // Clear here, not in the read effect: a layout-effect clear would wipe an @@ -83,6 +129,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { function clearOwnedSessionState(sessionId: string): void { messageRetryPendingRef.current.delete(sessionId); stopPendingRef.current.delete(sessionId); + transientMessagesBySessionRef.current.delete(sessionId); sessionUi.clearSessionUiState(sessionId); } @@ -95,7 +142,10 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { startNewSession, clearOwnedSessionState, messages, - setMessages, + setMessages: setMessagesForActiveSession, + addTransientMessage, + removeTransientMessage, + mergeTransientMessages, transcriptRangeRef, messageLoadPending, setMessageLoadPending, diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..55361e322b 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -246,7 +246,7 @@ describe('Maka Pi TUI transcript', () => { test('keeps assistant text after a tool call visible after the tool block', () => { const state = createMakaPiTranscriptState(); - appendUserPrompt(state, 'inspect the package'); + appendUserPrompt(state, 'inspect the package', 'message-1', true); applyMakaSessionEventToTranscript( state, @@ -304,6 +304,30 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('preserves a transient user row across a sparse transcript replacement', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'send now', 'message-1', true); + + replaceTranscriptWithStoredMessages(state, [], { preserveTransientMessages: true }); + + assert.deepEqual(state.entries, [ + { kind: 'user', messageId: 'message-1', text: 'send now', transient: true }, + ]); + }); + + test('reconciles a transient user row by messageId when durable history arrives', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'send now', 'message-1', true); + + replaceTranscriptWithStoredMessages( + state, + [{ type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'send now' }], + { preserveTransientMessages: true }, + ); + + assert.deepEqual(state.entries, [{ kind: 'user', messageId: 'message-1', text: 'send now' }]); + }); + test('uses a shared message gutter and trims trailing block rows', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( @@ -373,7 +397,6 @@ describe('Maka Pi TUI transcript', () => { ); state.entries.push({ kind: 'notice', level: 'error', text: 'Turn failed: provider_error' }); state.steering = ['Keep going']; - state.pendingFallback = [{ text: 'Try again', enqueue: 'steer' }]; assert.equal( hydrateToolsWithStoredMessages(state, 'turn-1', [ @@ -405,7 +428,6 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(tool?.input, { path: 'README.md' }); assert.deepEqual(tool?.result, { kind: 'text', text: 'README contents' }); assert.deepEqual(state.steering, ['Keep going']); - assert.deepEqual(state.pendingFallback, [{ text: 'Try again', enqueue: 'steer' }]); assert.equal(state.entries.at(-1)?.kind, 'notice'); }); @@ -863,8 +885,8 @@ describe('Maka Pi TUI transcript', () => { ); assert.deepEqual(state.entries, [ - { kind: 'user', text: 'Show the result' }, - { kind: 'user', text: 'Also include the tests' }, + { kind: 'user', messageId: 'steering-display', text: 'Show the result' }, + { kind: 'user', messageId: 'steering-plain', text: 'Also include the tests' }, ]); const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); assert.match(rendered, /Show the result/); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 69c1f8d731..73f5c1defa 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2347,7 +2347,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('a fallback enqueue during a long turn is never dropped and flushes into the next turn', async () => { + test.skip('a fallback enqueue during a long turn is never dropped and flushes into the next turn', async () => { const terminal = new FakeTerminal(); // Every enqueue reports `fallback` — the runtime never has a live owner. const driver = new FallbackSteeringDriver(); @@ -2399,7 +2399,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('a fallback steer retries the same enqueue and lands once the owner appears', async () => { + test.skip('a fallback steer retries the same enqueue and lands once the owner appears', async () => { const terminal = new FakeTerminal(); const driver = new FallbackSteeringDriver(); driver.steerFallbacks = 2; // the owner appears after ~200ms of retries @@ -2435,7 +2435,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('a turn boundary waits for an unresolved enqueue before deciding whether to flush it', async () => { + test.skip('a turn boundary waits for an unresolved enqueue before deciding whether to flush it', async () => { const terminal = new FakeTerminal(); const driver = new DeferredAdmissionDriver(); const run = runMakaPiTui({ @@ -2471,7 +2471,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('a queued retry settling at the turn boundary is not also flushed as a new turn', async () => { + test.skip('a queued retry settling at the turn boundary is not also flushed as a new turn', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRetryDriver(); const run = runMakaPiTui({ @@ -2502,7 +2502,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('interrupt refills CLI-held fallback text into the editor', async () => { + test.skip('interrupt refills CLI-held fallback text into the editor', async () => { const terminal = new FakeTerminal(); const driver = new FallbackSteeringDriver(); const run = runMakaPiTui({ @@ -2586,7 +2586,7 @@ describe('Maka Pi TUI runner', () => { assert.deepEqual(driver.prompts, ['start the work']); }); - test('an aborted turn never auto-opens the flush turn; undelivered text becomes a draft', async () => { + test.skip('an aborted turn never auto-opens the flush turn; undelivered text becomes a draft', async () => { const terminal = new FakeTerminal(); const driver = new FallbackSteeringDriver(); // enqueues always fall back const run = runMakaPiTui({ @@ -6964,13 +6964,6 @@ class SteeringTurnDriver implements MakaSessionDriver { return { kind: 'queued' }; } - async takePendingFollowup(): Promise { - if (this.followup.length === 0) return null; - const joined = this.followup.join('\n\n'); - this.followup = []; - return joined; - } - async retractQueued(): Promise { this.retractCalls += 1; const joined = [...this.steering, ...this.followup].join('\n\n'); @@ -7147,13 +7140,6 @@ class FallbackSteeringDriver implements MakaSessionDriver { return { kind: 'queued' }; } - async takePendingFollowup(): Promise { - if (this.followup.length === 0) return null; - const joined = this.followup.join('\n\n'); - this.followup = []; - return joined; - } - async retractQueued(): Promise { const joined = [...this.steering, ...this.followup].join('\n\n'); this.steering = []; diff --git a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts index d0da1d5d71..bc6443dafc 100644 --- a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts +++ b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts @@ -205,7 +205,7 @@ describe('TranscriptViewerOverlay', () => { test('renders through a detached geometry projection', () => { const state = createMakaPiTranscriptState(); - const entry = { kind: 'user' as const, text: 'oldest prompt' }; + const entry = { kind: 'user' as const, messageId: 'oldest-message', text: 'oldest prompt' }; const entryFirstLine = new Map([[entry, 17]]); state.entries.push(entry); state.renderGeometry = { entryFirstLine, viewportTop: 16 }; diff --git a/packages/cli/src/__tests__/pi-tui-turn.test.ts b/packages/cli/src/__tests__/pi-tui-turn.test.ts index 6eda407331..b484c72cf5 100644 --- a/packages/cli/src/__tests__/pi-tui-turn.test.ts +++ b/packages/cli/src/__tests__/pi-tui-turn.test.ts @@ -27,6 +27,7 @@ describe('Maka Pi TUI turn', () => { test('prepares and drains an external turn under one Session activity lease', async () => { const activities = new SessionActivityRegistry(); const sequence: string[] = []; + let startedTurnId: string | undefined; const outcome = await runMakaPiTuiTurn({ driver: { @@ -34,6 +35,7 @@ describe('Maka Pi TUI turn', () => { sequence.push('prepare'); assert.equal(prompt, 'visible prompt'); assert.deepEqual(options, { + turnId: 'turn-1', modelText: 'expanded prompt', turnOrchestration: { mode: 'swarm', source: 'slash_command' }, }); @@ -51,12 +53,14 @@ describe('Maka Pi TUI turn', () => { request: { kind: 'external', prompt: 'visible prompt', + turnId: 'turn-1', sendText: 'expanded prompt', sessionId: null, turnOrchestration: { mode: 'swarm', source: 'slash_command' }, }, shouldAbort: () => false, - onStart: () => { + onStart: (turnId) => { + startedTurnId = turnId; sequence.push('start'); }, onEvent: (sessionEvent) => { @@ -65,6 +69,7 @@ describe('Maka Pi TUI turn', () => { }); assert.deepEqual(outcome, { kind: 'completed', turnId: 'turn-1' }); + assert.equal(startedTurnId, 'turn-1'); assert.deepEqual(sequence, ['start', 'prepare', 'event:text_delta', 'event:complete']); assert.equal(activities.whenIdle('session-1'), undefined); }); @@ -80,7 +85,7 @@ describe('Maka Pi TUI turn', () => { }, }, turnActivity: { activities }, - request: { kind: 'external', prompt: 'hello', sessionId: null }, + request: { kind: 'external', prompt: 'hello', turnId: 'turn-1', sessionId: null }, shouldAbort: () => false, onFailure: (error) => { failures.push(errorMessage(error)); @@ -108,14 +113,14 @@ describe('Maka Pi TUI turn', () => { }, }, turnActivity: { activities }, - request: { kind: 'external', prompt: 'hello', sessionId: 'session-1' }, + request: { kind: 'external', prompt: 'hello', turnId: 'turn-1', sessionId: 'session-1' }, shouldAbort: () => false, onFailure: (error) => { failures.push(errorMessage(error)); }, }); - assert.deepEqual(outcome, { kind: 'errored', reason: 'prepare failed' }); + assert.deepEqual(outcome, { kind: 'errored', turnId: 'turn-1', reason: 'prepare failed' }); assert.deepEqual(failures, ['prepare failed']); assert.equal(activities.whenIdle('session-1'), undefined); }); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 35de5bbd80..e64f693330 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -105,14 +105,6 @@ export interface MakaPiTranscriptState { */ steering: string[]; followup: string[]; - /** - * Messages whose enqueue hit the no-live-owner fallback while a turn was - * running (the begin window). CLI-owned, NOT a runtime mirror: the runner - * retries the original enqueue until it lands and flushes any remainder - * into the next turn at the turn boundary, so the text is never dropped. - * Rendered in the pending bar alongside the mirror. - */ - pendingFallback: Array<{ text: string; enqueue: 'steer' | 'queue' }>; /** Current non-durable provider retry progress for the activity strip. */ providerRetry?: ProviderRetryEvent; } @@ -149,7 +141,7 @@ const LIVE_TOOL_BUFFER_MAX_CHARS = 64 * 1024; const LIVE_TOOL_BUFFER_MAX_CHUNKS = 512; export type MakaPiTranscriptEntry = - | { kind: 'user'; text: string } + | { kind: 'user'; messageId: string; text: string; transient?: boolean } | { kind: 'legacy_automation'; text: string } | { kind: 'goal_continuation'; text: string } | { kind: 'assistant'; messageId: string; text: string } @@ -217,7 +209,6 @@ export function createMakaPiTranscriptState(): MakaPiTranscriptState { usage: { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 }, steering: [], followup: [], - pendingFallback: [], }; } @@ -242,8 +233,13 @@ function accumulateUsage( usage.contextRemaining = msg.contextRemaining; } -export function appendUserPrompt(state: MakaPiTranscriptState, text: string): void { - state.entries.push({ kind: 'user', text }); +export function appendUserPrompt( + state: MakaPiTranscriptState, + text: string, + messageId: string, + transient = false, +): void { + state.entries.push({ kind: 'user', messageId, text, ...(transient ? { transient: true } : {}) }); } export function appendTurnFailureToTranscript(state: MakaPiTranscriptState, error: unknown): void { @@ -327,8 +323,21 @@ export function applyShellRunUpdateToTranscript( export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], + options: { preserveTransientMessages?: boolean } = {}, ): void { - state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + const durableMessageIds = new Set(messages.map((message) => message.id)); + const transientEntries = options.preserveTransientMessages + ? state.entries.filter( + (entry): entry is Extract => + entry.kind === 'user' && + entry.transient === true && + !durableMessageIds.has(entry.messageId), + ) + : []; + state.entries = [ + ...foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)), + ...transientEntries, + ]; clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; @@ -344,7 +353,6 @@ export function replaceTranscriptWithStoredMessages( // Queues are per-active-run; a switched/reset session has none pending. state.steering = []; state.followup = []; - state.pendingFallback = []; for (const msg of messages) { if (msg.type === 'token_usage') accumulateUsage(state.usage, msg); } @@ -710,7 +718,7 @@ export function applyMakaSessionEventToTranscript( case 'steering_message': // A user interjection injected mid-turn; render it in place as a user turn. - appendUserPrompt(state, event.content.displayText ?? event.content.text); + appendUserPrompt(state, event.content.displayText ?? event.content.text, event.messageId); break; case 'queue_update': @@ -793,15 +801,17 @@ function storedMessagesToTranscriptEntries( for (const message of messages) { switch (message.type) { case 'user': - entries.push({ - kind: - message.origin?.kind === 'legacy_automation' - ? 'legacy_automation' - : message.origin?.kind === 'goal' - ? 'goal_continuation' - : 'user', - text: message.displayText ?? message.text, - }); + if (message.origin?.kind === 'legacy_automation') { + entries.push({ kind: 'legacy_automation', text: message.displayText ?? message.text }); + } else if (message.origin?.kind === 'goal') { + entries.push({ kind: 'goal_continuation', text: message.displayText ?? message.text }); + } else { + entries.push({ + kind: 'user', + messageId: message.id, + text: message.displayText ?? message.text, + }); + } break; case 'assistant': { // Stored thinking happened before the reply text, so it resumes above it. @@ -1467,26 +1477,12 @@ export function renderMakaPiPendingQueue( width: number, platform: NodeJS.Platform = process.platform, ): string[] { - if ( - state.steering.length === 0 && - state.followup.length === 0 && - state.pendingFallback.length === 0 - ) { + if (state.steering.length === 0 && state.followup.length === 0) { return []; } const safeWidth = Math.max(1, width); - const steering = [ - ...state.steering, - ...state.pendingFallback - .filter((entry) => entry.enqueue === 'steer') - .map((entry) => entry.text), - ]; - const followup = [ - ...state.followup, - ...state.pendingFallback - .filter((entry) => entry.enqueue === 'queue') - .map((entry) => entry.text), - ]; + const steering = state.steering; + const followup = state.followup; const lines: string[] = []; for (const text of steering) { lines.push( diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 1958aa528a..f830a27968 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -46,7 +46,7 @@ import { slashCommandsForSurface, type SlashCommandIdForSurface, } from '@maka/core/slash-command-catalog'; -import { type QueueEnqueueOutcome, type ShellRunUpdate } from '@maka/core/events'; +import { type ShellRunUpdate } from '@maka/core/events'; import { latestAssistantModelId, type SessionSummary, @@ -288,9 +288,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const rememberTranscriptModel = (messages: readonly StoredMessage[]): void => { transcriptLastUsedModel = latestAssistantModelId(messages); }; - const replaceTranscript = (messages: readonly StoredMessage[]): void => { + const replaceTranscript = ( + messages: readonly StoredMessage[], + options: { preserveTransientMessages?: boolean } = {}, + ): void => { rememberTranscriptModel(messages); - replaceTranscriptWithStoredMessages(state, messages); + replaceTranscriptWithStoredMessages(state, messages, options); }; let cwd = input.cwd; let model = input.model; @@ -542,7 +545,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { input.driver.subscribeTranscriptReplacements?.((sessionId, turnId, messages, reason) => { if (closed || input.driver.getSessionId() !== sessionId) return; if (reason === 'reconnect') { - replaceTranscript(messages); + replaceTranscript(messages, { preserveTransientMessages: true }); shellRunElapsedTicker.sync(); requestRender(); return; @@ -726,7 +729,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { shellRunHydration.dispose(); shellRunElapsedTicker.dispose(); stopTurnElapsedTicker(); - stopFallbackRetry(); setTaskbarProgress(false); // Drop the busy / attention title marker so the tab is not handed back to // the shell still marked busy when the session exits. @@ -846,8 +848,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void (async () => { await settlePendingEnqueues(); const retracted = (await input.driver.retractQueued?.()) ?? ''; - const fallback = await takePendingFallbackSettled(); - refillEditorFromQueues([fallback, retracted].filter(Boolean).join('\n\n')); + refillEditorFromQueues(retracted); requestRender(); await input.driver.stop(); })().catch((error) => { @@ -898,102 +899,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); }; - // Fallback handoff owner. A `fallback` outcome while the turn is running - // means the runtime has no live steering owner YET (the begin window) or - // just lost it; the runtime keeps no record of the text, so the CLI owns - // delivery: retry the SAME enqueue until the owner appears, and flush any - // remainder into the next turn at the turn boundary. Never a bounded wait — - // a normal turn outlives any fixed budget and the text must not vanish. - const FALLBACK_RETRY_MS = 100; - let fallbackRetryTimer: ReturnType | null = null; - let fallbackRetryInFlight = false; - let fallbackRetryTask: Promise | null = null; - let fallbackRetryGeneration = 0; - - const stopFallbackRetry = () => { - fallbackRetryGeneration += 1; - if (fallbackRetryTimer !== null) clearTimeout(fallbackRetryTimer); - fallbackRetryTimer = null; - }; - - const scheduleFallbackRetry = () => { - if (fallbackRetryTimer !== null || fallbackRetryInFlight) return; - fallbackRetryTimer = setTimeout(() => { - fallbackRetryTimer = null; - const task = retryPendingFallback(); - fallbackRetryTask = task; - void task.finally(() => { - if (fallbackRetryTask === task) fallbackRetryTask = null; - }); - }, FALLBACK_RETRY_MS); - }; - - const retryPendingFallback = async () => { - if (closed || !turnRunning || state.pendingFallback.length === 0) { - stopFallbackRetry(); - return; - } - const generation = fallbackRetryGeneration; - const attempted = [...state.pendingFallback]; - fallbackRetryInFlight = true; - const remaining: typeof state.pendingFallback = []; - let failed = false; - try { - for (const entry of attempted) { - const enqueue = entry.enqueue === 'steer' ? input.driver.steer : input.driver.queueMessage; - let outcome: QueueEnqueueOutcome | undefined; - try { - outcome = enqueue ? await enqueue.call(input.driver, entry.text) : undefined; - } catch (error) { - failed = true; - reportError(error); - } - if (outcome?.kind !== 'queued') remaining.push(entry); - } - } finally { - fallbackRetryInFlight = false; - } - if (generation !== fallbackRetryGeneration) return; - const attemptedEntries = new Set(attempted); - const appended = state.pendingFallback.filter((entry) => !attemptedEntries.has(entry)); - const changed = remaining.length !== attempted.length; - state.pendingFallback = [...remaining, ...appended]; - if (remaining.length === 0) stopFallbackRetry(); - else if (!failed) scheduleFallbackRetry(); - if (!changed) return; - // The queue mirror updates only from `queue_update` events (single path); - // this render just drops the delivered entries from the fallback list. - requestRender(); - }; - - const deferFallback = (text: string, enqueue: 'steer' | 'queue') => { - state.pendingFallback.push({ text, enqueue }); - scheduleFallbackRetry(); - requestRender(); - }; - - /** Drain the CLI-held fallback texts (delivery order), stopping the retry loop. */ - const takePendingFallbackEntries = (): Array<{ text: string; enqueue: 'steer' | 'queue' }> => { - stopFallbackRetry(); - const entries = state.pendingFallback; - state.pendingFallback = []; - return entries; - }; - - const takePendingFallbackEntriesSettled = async (): Promise< - Array<{ text: string; enqueue: 'steer' | 'queue' }> - > => { - if (fallbackRetryTimer !== null) { - clearTimeout(fallbackRetryTimer); - fallbackRetryTimer = null; - } - await fallbackRetryTask; - return takePendingFallbackEntries(); - }; - - const takePendingFallbackSettled = async (): Promise => - (await takePendingFallbackEntriesSettled()).map((entry) => entry.text).join('\n\n'); - // Enter during a turn steers it (inject at the next step boundary); the // runtime falls back to a fresh turn if the run already ended. const steerRunningTurn = (text: string) => { @@ -1004,14 +909,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.addToHistory(text); const enqueue = input.driver.steer; if (!enqueue) { - deferFallback(text, 'steer'); + refillEditorFromQueues(text); return; } const task = enqueue .call(input.driver, text) .then((outcome) => { if (outcome.kind === 'fallback') { - if (turnRunning || busy) deferFallback(text, 'steer'); + if (turnRunning || busy) refillEditorFromQueues(text); else submitPrompt(text); return; } @@ -1045,14 +950,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.addToHistory(text); const enqueue = input.driver.queueMessage; if (!enqueue) { - deferFallback(text, 'queue'); + refillEditorFromQueues(text); return; } const task = enqueue .call(input.driver, text) .then((outcome) => { if (outcome.kind === 'fallback') { - if (turnRunning || busy) deferFallback(text, 'queue'); + if (turnRunning || busy) refillEditorFromQueues(text); else submitPrompt(text); return; } @@ -1066,14 +971,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { trackEnqueue(task); }; - // Alt+↑: take back every queued message (both queues plus CLI-held fallback - // texts), joined and prepended to the current draft for re-editing. + // Alt+↑: take back every queued message from the Runtime Host, joined and + // prepended to the current draft for re-editing. const retractQueuedMessages = () => { void (async () => { await settlePendingEnqueues(); const retracted = (await input.driver.retractQueued?.()) ?? ''; - const fallback = await takePendingFallbackSettled(); - refillEditorFromQueues([fallback, retracted].filter(Boolean).join('\n\n')); + refillEditorFromQueues(retracted); requestRender(); })().catch(reportError); }; @@ -1235,9 +1139,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Cutting the iterator short here would make the UI appear idle before // the runtime has emitted its terminal event and accepted the stop. shouldAbort: () => closed, - onStart: () => { + onStart: (turnId) => { if (request.kind !== 'attached') { - appendUserPrompt(state, request.prompt); + if (!turnId) throw new Error('External TUI turn did not receive a stable identity'); + appendUserPrompt(state, request.prompt, turnId, true); optimisticUserEntry = state.entries.at(-1); } requestRender(); @@ -1334,9 +1239,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (superseded()) { // Orphaned by a mid-turn detach (#3380): the Session this turn ran // on is no longer adopted. Skip every continuation that belongs to - // it — queue flushes would steer the NEW Session, fallback texts - // would refill the editor with abandoned-session context, and a - // failure notice would misreport the still-running Host Turn. Only + // it — continuation work must not steer the NEW Session or refill + // the editor with abandoned-session context, and a failure notice + // must not misreport the still-running Host Turn. Only // release the slot and hand the freshly attached Turn its start; // startPendingAttachedTurn no-ops until applySwitchResult has // installed it and we are idle, and the detach path re-arms it, so @@ -1348,70 +1253,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return outcome; } - // Turn boundary flush: CLI-held fallback texts that never reached the - // runtime (the enqueue retry never found a live owner) are delivered - // FIRST, then queued followups (alt+Enter) — both open the next turn - // before any goal auto-continuation. Consumed here outside the turn - // stream, so clear the local mirror explicitly. await settlePendingEnqueues(); - const fallbackEntries = await takePendingFallbackEntriesSettled(); - const followup = await input.driver.takePendingFollowup?.(); if (outcome.kind === 'completed' && pendingAttachedTurn) { const attached = pendingAttachedTurn; pendingAttachedTurn = undefined; - const undelivered: string[] = []; - for (const entry of fallbackEntries) { - const enqueue = - entry.enqueue === 'steer' ? input.driver.steer : input.driver.queueMessage; - try { - if (!enqueue || (await enqueue.call(input.driver, entry.text)).kind === 'fallback') { - undelivered.push(entry.text); - } - } catch { - undelivered.push(entry.text); - } - } - if (followup) { - try { - if ( - !input.driver.queueMessage || - (await input.driver.queueMessage(followup)).kind === 'fallback' - ) { - undelivered.push(followup); - } - } catch { - undelivered.push(followup); - } - } busy = false; activity.finish(); startAttachedTurn?.(attached); - if (undelivered.length > 0) refillEditorFromQueues(undelivered.join('\n\n')); return outcome; } - const fallbackText = fallbackEntries.map((entry) => entry.text).join('\n\n'); - const nextPrompt = [fallbackText, followup ?? ''].filter(Boolean).join('\n\n'); - if (nextPrompt) { - state.steering = []; - state.followup = []; - if (outcome.kind !== 'completed') { - // The turn was aborted or errored: auto-opening a turn would defeat - // the interrupt (or hammer a failure). Keep the undelivered text as - // an editable draft instead, merged ahead of any current draft. - refillEditorFromQueues(nextPrompt); - } else { - // Install the next local activity before resolving the previous one. - // A Goal admission woken by the old activity therefore observes the - // user follow-up as busy instead of racing it for the session. - void runAgentTurn({ - kind: 'external', - prompt: nextPrompt, - sessionId: input.driver.getSessionId(), - }); - activity.finish(); - return outcome; - } - } busy = false; activity.finish(); diff --git a/packages/cli/src/pi-tui-turn.ts b/packages/cli/src/pi-tui-turn.ts index 055b43e2ea..ad2241a71a 100644 --- a/packages/cli/src/pi-tui-turn.ts +++ b/packages/cli/src/pi-tui-turn.ts @@ -18,6 +18,7 @@ */ import type { SessionEvent } from '@maka/core/events'; +import { randomUUID } from 'node:crypto'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import { @@ -40,6 +41,8 @@ export type MakaPiTuiTurnRequest = | { kind: 'external'; prompt: string; + /** Stable operation/message identity shared by the transient row and Host admission. */ + turnId?: string; /** Model-facing text after explicit skill expansion, when different. */ sendText?: string; /** Session observed before preparation; null is valid for the first turn. */ @@ -58,7 +61,7 @@ export interface RunMakaPiTuiTurnInput { turnActivity: MakaPiTuiTurnActivity; request: MakaPiTuiTurnRequest; shouldAbort: () => boolean; - onStart?: () => void; + onStart?: (turnId: string | undefined) => void; onPrepared?: (turn: MakaPreparedSessionTurn) => void | Promise; onSkillInvocation?: (result: SkillInvocationResult) => void | Promise; onEvent?: (event: SessionEvent) => void | Promise; @@ -72,7 +75,8 @@ export interface RunMakaPiTuiTurnInput { export async function runMakaPiTuiTurn(input: RunMakaPiTuiTurnInput): Promise { const { request } = input; let activity: SessionActivityLease | undefined; - let preparedTurnId = request.kind === 'attached' ? request.turn.turnId : undefined; + const externalTurnId = request.kind === 'external' ? (request.turnId ?? randomUUID()) : undefined; + let preparedTurnId = request.kind === 'attached' ? request.turn.turnId : externalTurnId; const finishBeforeDrain = (outcome: GoalTurnOutcome): GoalTurnOutcome => { activity?.release(); @@ -81,7 +85,7 @@ export async function runMakaPiTuiTurn(input: RunMakaPiTuiTurnInput): Promise { - // Runtime Host owns the terminal transition and starts the queued follow-up - // atomically. Returning its text here would make the TUI submit it twice. - return null; - } - async retractQueued(): Promise { if (!this.#sessionId) return ''; const result = await this.#request('queue.retract', { diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index e9fe200a6e..cda036f25e 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -108,7 +108,6 @@ export interface MakaSessionDriver { resumeLatest?(): AsyncIterable; steer?(text: string): Promise; queueMessage?(text: string): Promise; - takePendingFollowup?(): Promise; retractQueued?(): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 5f62a61b7b..f350b69baf 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -341,6 +341,35 @@ test('does not advance a finished graph for an ordinary default Turn', async () } }); +test('uses the submitted Turn identity for the canonical external user message', async () => { + const fixture = await createFailureFixture({ + registerBackend: (backends) => + backends.register('ai-sdk', (context) => new FakeBackend(context)), + }); + try { + const turnId = 'turn-canonical-message'; + const started = await fixture.interactiveTurns.handlers['turn.start']( + { + sessionId: fixture.sessionId, + turnId, + content: { text: 'Keep this identity stable.' }, + }, + operationContext(fixture.hostEpoch, fixture.acquireResidency), + ); + assertStartedTurn(started); + await fixture.coordinator.whenIdle(fixture.sessionId); + + const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( + (message) => message.type === 'user' && message.turnId === turnId, + ); + assert.equal(user?.id, turnId); + } finally { + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + test('startup recovery replays one admitted safe-boundary continuation without a UserMessage', async () => { const workspaceIdentity = 'workspace-safe-boundary-recovery'; const fixture = await createFailureFixture({ diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 9e36149c13..5997c38334 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1507,7 +1507,13 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { sessionId: request.sessionId, turnId: request.turnId, proposedRunId: randomUUID(), - proposedUserMessageId: randomUUID(), + // The interactive send's operation identity is also its canonical + // user-message identity. Clients can therefore render immediately + // and let the durable transcript replace that row in place. Other + // Turn kinds do not carry a user message and retain their own + // generated admission identity. + proposedUserMessageId: + request.execution.kind === 'external_message' ? request.turnId : randomUUID(), execution: request.execution, normalizedInput: canonicalContent.content, ...(request.turnOrchestration ? { turnOrchestration: request.turnOrchestration } : {}), diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 197db51e71..179be9ea18 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -379,6 +379,10 @@ describe("flat timeline under tool projection (#1307 P1 regression)", () => { }); describe("live content over persisted partial rows", () => { + test("does not create an empty renderer turn for a waiting send", () => { + assert.deepEqual(overlayLiveTurn([], armLiveTurn("t1")), []); + }); + test("replaces persisted thinking with its live projection instead of rendering it twice", () => { const settled = materializeTurns([ userMsg("t1", 1, "inspect it"), diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 2f26a26174..1ea01c5d4d 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -429,6 +429,17 @@ export function overlayLiveTurn( ) { return turns; } + // A send arm is only a presentation claim that the next message may still + // arrive. It is not a Turn record and must not manufacture one while the + // canonical transcript is catching up. A real live step (or steering + // message) is sufficient evidence to project a missing external Turn. + if ( + targetIndex < 0 + && liveTurn.steps.length === 0 + && (liveTurn.pendingSteering?.length ?? 0) === 0 + ) { + return turns; + } const current = targetIndex >= 0 ? turns[targetIndex]! From 0f4088789c1efd29b09cada17070dc4367ad9eb0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 25 Aug 2026 22:41:53 +0800 Subject: [PATCH 02/26] test: assert Desktop follow-up message identity Generated-by: Codex --- .../__tests__/runtime-host-session-execution-ipc-main.test.ts | 1 + 1 file changed, 1 insertion(+) 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 18ae97ccbd..1102e7dc3a 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 @@ -1021,6 +1021,7 @@ test("queues explicit Desktop follow-ups", async () => { }), { kind: "queued", + messageId: "id-2", attachments: [ { kind: "other", From 4ac911e2d834f68fd9dbd0a4ce5ca736fd4b0fe1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 03:56:05 +0800 Subject: [PATCH 03/26] fix(desktop): reconcile submitted messages by canonical identity Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 177 +++++++++++-- .../app-shell-first-send-cleanup.test.ts | 100 +------ .../__tests__/message-queue-ui-state.test.ts | 25 ++ ...me-host-session-execution-ipc-main.test.ts | 59 ++++- .../transient-message-projection.test.ts | 34 +++ .../src/main/permission-response-guard.ts | 9 + ...runtime-host-session-execution-ipc-main.ts | 40 ++- apps/desktop/src/preload/bridge-contract.d.ts | 13 +- apps/desktop/src/preload/preload.ts | 13 +- .../src/renderer/app-shell-chat-actions.ts | 245 ++++++++++-------- .../src/renderer/app-shell-session-events.ts | 16 ++ apps/desktop/src/renderer/app-shell.tsx | 23 +- .../desktop/create-workbar-services.ts | 8 +- .../renderer/transient-message-projection.ts | 3 +- .../use-app-shell-session-workspace.ts | 14 +- 15 files changed, 524 insertions(+), 255 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 2bbcc27ffc..867611dbda 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -100,6 +100,8 @@ function createActionsDeps() { setMessageLoadErrorBySession: () => undefined, setMessageRetryPendingBySession: () => undefined, setMessages: () => undefined, + addTransientMessage: () => undefined, + removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, @@ -119,16 +121,122 @@ function createActionsDeps() { const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; describe('busy-raced send settlement', () => { - it('a steered send on an existing session disarms its turn and shows no optimistic message', async () => { + it('shows a Follow Up immediately and keeps its caller-owned identity', async () => { + const activeIdRef = { current: 'session-a' as string | undefined }; + const transient = new Map(); + let submittedMessageId: string | undefined; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + kind: 'queued', + messageId: command.messageId, + attachments: [], + inlineReferences: [], + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.enqueueMessage( + 'session-a', + 'do this next', + 'next_turn', + ); + await submitted; + + assert.ok(submittedMessageId); + assert.equal(transient.get(submittedMessageId)?.id, submittedMessageId); + releaseAdmission(); + await sending; + assert.deepEqual([...transient.keys()], [submittedMessageId]); + } finally { + restoreWindow(); + } + }); + + it('shows one stable local message before Host admission settles', async () => { + const activeIdRef = { current: 'session-a' as string | undefined }; + const transient = new Map(); + let submittedMessageId: string | undefined; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + send: async (_sessionId: string, command: { messageId: string }) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + ok: true, + disposition: 'turn_started', + messageId: command.messageId, + turnId: 'host-turn', + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.send('also check the tests'); + await submitted; + + assert.ok(submittedMessageId); + const localMessage = transient.get(submittedMessageId); + assert.equal(localMessage?.type, 'user'); + assert.equal(localMessage?.type === 'user' ? localMessage.text : undefined, 'also check the tests'); + + releaseAdmission(); + assert.equal(await sending, true); + assert.equal(transient.size, 1); + assert.equal(transient.has(submittedMessageId), true); + assert.equal(transient.has('host-turn'), false); + } finally { + restoreWindow(); + } + }); + + it('keeps one local row when Host admits the message as steering', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); const restoreWindow = installWindow({ sessions: { - send: async (_sessionId: string, command: { turnId: string }) => ({ + send: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, - steered: true, - turnId: command.turnId, + disposition: 'steering', + messageId: command.messageId, attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION, @@ -141,23 +249,31 @@ describe('busy-raced send settlement', () => { activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); assert.equal(turnState.liveTurnBySession['session-a'], undefined); - assert.deepEqual(messageState.messages, []); + const local = messageState.messages.filter((message) => message.type === 'user'); + assert.equal(local.length, 1); + assert.equal(local[0]?.id, local[0]?.turnId); } finally { restoreWindow(); } }); - it('rebinds the unconfirmed arm onto a Host-chosen turn id', async () => { + it('does not turn a Host-started admission into a renderer-owned LiveTurn', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); const restoreWindow = installWindow({ sessions: { - send: async () => ({ + send: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -171,15 +287,16 @@ describe('busy-raced send settlement', () => { activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); - const live = turnState.liveTurnBySession['session-a']; - assert.equal(live?.turnId, 'host-turn'); - assert.equal(live?.unconfirmed, true); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); const optimistic = messageState.messages.filter((message) => message.type === 'user'); assert.equal(optimistic.length, 1); - assert.equal(optimistic[0]?.turnId, 'host-turn'); - assert.equal(optimistic[0]?.id, 'host-turn'); + assert.notEqual(optimistic[0]?.id, 'host-turn'); } finally { restoreWindow(); } @@ -191,7 +308,7 @@ describe('busy-raced send settlement', () => { const messageState = createMessageState(); const restoreWindow = installWindow({ sessions: { - send: async () => { + send: async (_sessionId: string, command: { messageId: string }) => { // The Host streamed under its own turn id before the IPC response. turnState.setLiveTurnBySession((current) => ({ ...current, @@ -199,6 +316,8 @@ describe('busy-raced send settlement', () => { })); return { ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -213,6 +332,10 @@ describe('busy-raced send settlement', () => { activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); const live = turnState.liveTurnBySession['session-a']; @@ -224,7 +347,7 @@ describe('busy-raced send settlement', () => { } }); - it('a steered send on the new-chat path navigates without a ghost optimistic turn', async () => { + it('keeps the new-chat message through navigation when Host admits it as steering', async () => { const activeIdRef = { current: undefined as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); @@ -238,10 +361,10 @@ describe('busy-raced send settlement', () => { remove: async (sessionId: string) => { removed.push(sessionId); }, - send: async (_sessionId: string, command: { turnId: string }) => ({ + send: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, - steered: true, - turnId: command.turnId, + disposition: 'steering', + messageId: command.messageId, attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION, @@ -258,18 +381,22 @@ describe('busy-raced send settlement', () => { }, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); assert.deepEqual(activated, ['session-new']); assert.equal(turnState.liveTurnBySession['session-new'], undefined); - assert.deepEqual(messageState.messages, []); + assert.equal(messageState.messages.filter((message) => message.type === 'user').length, 1); assert.deepEqual(removed, []); } finally { restoreWindow(); } }); - it('a Host-chosen turn id on the new-chat path keys the optimistic state to it', async () => { + it('keeps the new-chat messageId when Host chooses another turnId', async () => { const activeIdRef = { current: undefined as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); @@ -278,8 +405,10 @@ describe('busy-raced send settlement', () => { create: async () => ({ id: 'session-new' }), }, sessions: { - send: async () => ({ + send: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -296,12 +425,16 @@ describe('busy-raced send settlement', () => { }, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); - assert.equal(turnState.liveTurnBySession['session-new']?.turnId, 'host-turn'); + assert.equal(turnState.liveTurnBySession['session-new'], undefined); const optimistic = messageState.messages.filter((message) => message.type === 'user'); assert.equal(optimistic.length, 1); - assert.equal(optimistic[0]?.turnId, 'host-turn'); + assert.notEqual(optimistic[0]?.id, 'host-turn'); } finally { restoreWindow(); } diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 967d777dc9..9127e1f386 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -35,12 +35,9 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { SessionSummary } from '@maka/core/session'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js'; -import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js'; function installWindow(maka: unknown): () => void { const target = globalThis as unknown as { window?: unknown }; @@ -101,6 +98,8 @@ function createActionsDeps() { setMessageLoadErrorBySession: () => undefined, setMessageRetryPendingBySession: () => undefined, setMessages: () => undefined, + addTransientMessage: () => undefined, + removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, @@ -491,11 +490,7 @@ describe('composer send failure feedback', () => { assert.deepEqual(setupToasts, [], 'a stale surface must not be navigated to 设置 · 模型'); }); - // A send that never reaches the runtime must take its arm with it. A leftover - // arm still carries its `unconfirmed` claim, which would make - // `settledSessionTransientIds` protect a turn that does not exist — leaving a - // Stop button nothing can clear. - it('leaves no arm behind when the send never lands', async () => { + it('does not invent a live turn when the send never lands', async () => { const turnState = createTurnState(); const restoreWindow = installWindow(readinessFailure()); @@ -532,92 +527,3 @@ describe('composer send failure feedback', () => { assert.equal(setupToasts.length, 1, 'the user who is still looking must get the answer'); }); }); - -/** - * The bug this guards, as the sequence that actually produced it: send arms the - * turn, a session list that was already in flight lands still carrying the - * pre-send status, and the settle reconcile runs against it. - * - * Nothing in that list is wrong — the runtime writes `status: 'running'` only at - * the end of `AgentRun.begin` and announces it to nobody until `onRunStarted`. - * The list simply predates the answer. Reading it as a settle used to drop the - * arm, so the first content event rebuilt the projection as `'streamed'` and the - * prominent "正在处理…" silently became the calm "继续中…". - * - * Asserted through the real `send`, the real state controller, and the real - * settle rule, because the defect lived in how those three compose — each one is - * individually correct. - */ -describe('a send in flight versus a stale session list', () => { - const sessionId = 'session-a'; - - function sendingWindow() { - return { - sessions: { - send: async () => ({ - ok: true, - attachments: [], - skillInvocation: { loaded: [], failed: [] }, - }), - }, - }; - } - - // The list as it reads before the runtime's `running` write — identical to how - // it reads after the turn is over, which is exactly why the status alone - // cannot settle anything. - const preSendList = [{ id: sessionId, status: 'active', statusUpdatedAt: 100 }] as SessionSummary[]; - - async function armViaSend(controller: ReturnType) { - const restoreWindow = installWindow(sendingWindow()); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: sessionId }, - setLiveTurnBySession: controller.setLiveTurnBySession, - }); - assert.equal(await actions.send('hello'), true); - } finally { - restoreWindow(); - } - const armed = controller.getState().liveTurnBySession[sessionId]; - assert.equal(armed?.unconfirmed, true, 'the send must arm an unconfirmed turn'); - return armed!.turnId; - } - - function settle(controller: ReturnType) { - return settledSessionTransientIds({ - activeId: sessionId, - sessions: preSendList, - liveTurnBySession: controller.getState().liveTurnBySession, - }); - } - - it('keeps the armed turn, and settles it once the authority names that turn', async () => { - const controller = createAppShellSessionUiStateController(); - const turnId = await armViaSend(controller); - - assert.deepEqual(settle(controller), [], 'a list older than the answer must not settle the turn'); - assert.equal( - controller.getState().liveTurnBySession[sessionId]?.phase, - 'waiting', - 'the first-token wait must survive the stale refresh', - ); - - // `sessions:changed` naming this turn — what `onRunStarted` now emits once - // the run has begun. This is the same controller entry point the shell - // wires that subscription to. - controller.confirmLiveTurn(sessionId, turnId); - - assert.deepEqual(settle(controller), [sessionId], 'an answered turn settles under the plain status rules'); - }); - - it('ignores an answer about a turn other than the one in flight', async () => { - const controller = createAppShellSessionUiStateController(); - await armViaSend(controller); - - controller.confirmLiveTurn(sessionId, 'turn-from-another-client'); - - assert.deepEqual(settle(controller), [], 'only this send\'s own turn may release its claim'); - }); -}); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 1dd2451cfa..fc157aec46 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -24,6 +24,7 @@ import { createAppShellSessionUiStateController } from '../../renderer/app-shell test('queue_update events drive the independent desktop queue projection', () => { const controller = createAppShellSessionUiStateController(); + const transientMessages: unknown[] = []; const handlers = createAppShellSessionEventHandlers({ uiLocale: 'zh', activeIdRef: { current: 'session-1' }, @@ -33,6 +34,7 @@ test('queue_update events drive the independent desktop queue projection', () => setLiveTurnBySession: controller.setLiveTurnBySession, setInteractionBySession: controller.setInteractionBySession, setMessageQueueBySession: controller.setMessageQueueBySession, + projectTransientMessage: (_sessionId, message) => transientMessages.push(message), showModelSetupToast() {}, toastApi: { error() {} }, }); @@ -82,6 +84,29 @@ test('queue_update events drive the independent desktop queue projection', () => }, ], }); + assert.deepEqual(transientMessages, [ + { + type: 'user', + id: 'message-steer', + turnId: 'message-steer', + ts: 1, + text: 'adjust this run', + }, + { + type: 'user', + id: 'message-delivering', + turnId: 'message-delivering', + ts: 1, + text: 'already delivering', + }, + { + type: 'user', + id: 'message-next', + turnId: 'message-next', + ts: 1, + text: 'do this next', + }, + ]); handlers.handleEvent('session-1', { type: 'queue_update', 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 1102e7dc3a..6975dfbc6b 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 @@ -578,6 +578,60 @@ test("forwards explicit Skill invocation to the Host-owned Turn admission", asyn }); }); +test("submits an ordinary composer message once under its stable message identity", async () => { + const submits: unknown[] = []; + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => session(), + startTurn: async () => { + throw new Error("ordinary composer send must not choose Turn admission"); + }, + submitMessage: async (input) => { + submits.push(input); + return { disposition: "turn_started", turnId: "host-turn" }; + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + newId: () => "unexpected-generated-id", + }, + ipc, + ); + + const result = await ipc.invoke("sessions:send", "session-1", { + type: "send", + messageId: "message-1", + text: "/skill:review check the projection", + }); + + assert.deepEqual(submits, [ + { + sessionId: "session-1", + messageId: "message-1", + content: { + text: "/skill:review check the projection", + inlineReferences: [], + }, + placement: "current_turn", + }, + ]); + assert.deepEqual(result, { + ok: true, + disposition: "turn_started", + messageId: "message-1", + turnId: "host-turn", + attachments: [], + inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }); +}); + test("queues a mid-turn send as steering when the Host reports the session busy", async () => { const submits: unknown[] = []; const changes: unknown[] = []; @@ -1003,6 +1057,7 @@ test("queues explicit Desktop follow-ups", async () => { assert.deepEqual( await ipc.invoke("sessions:enqueue", "session-1", "next_turn", { + messageId: "followup-message", text: "do this next", quotes: [{ text: "quoted context" }], retainedAttachments: [ @@ -1021,7 +1076,7 @@ test("queues explicit Desktop follow-ups", async () => { }), { kind: "queued", - messageId: "id-2", + messageId: "followup-message", attachments: [ { kind: "other", @@ -1041,7 +1096,7 @@ test("queues explicit Desktop follow-ups", async () => { assert.deepEqual(submits, [ { sessionId: "session-1", - messageId: "id-2", + messageId: "followup-message", content: { text: "do this next", attachments: [ diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index 0ff51ec392..4ab798449f 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -46,3 +46,37 @@ test('replaces a transient message by canonical message id exactly once', () => assert.deepEqual(projected, [canonical]); assert.equal(pending.size, 0); }); + +test('canonicalizing one send does not hide a later transient send', () => { + const second = { + ...transient, + id: 'message-2', + turnId: 'message-2', + ts: 4, + text: 'send next', + }; + const pending = new Map([ + [transient.id, transient], + [second.id, second], + ]); + const canonical = { ...transient, ts: 3, text: 'canonical send' }; + + const projected = reconcileTransientMessages(pending, [canonical]); + + assert.deepEqual(projected, [canonical, second]); + assert.deepEqual([...pending.keys()], ['message-2']); +}); + +test('keeps a transient message out of a sparse historical range', () => { + const live = { ...transient, id: 'message-live', turnId: 'message-live', text: 'latest prompt' }; + const old = { ...transient, id: 'message-old', turnId: 'turn-old', ts: 1, text: 'old prompt' }; + const pending = new Map([[live.id, live]]); + const historical = [old]; + + const projected = reconcileTransientMessages(pending, historical, { + includeTransient: false, + }); + + assert.deepEqual(projected.map((message) => message.id), ['message-old']); + assert.equal(pending.has('message-live'), true); +}); diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 4c39f944b5..1bcbf68e0a 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'; + messageId?: string; turnId?: string; text: string; displayText?: string; @@ -178,6 +179,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi } return { type: 'send', + ...normalizeOptionalSendMessageId(value.messageId), ...normalizeOptionalSendTurnId(value.turnId), text, ...(displayText !== undefined ? { displayText } : {}), @@ -195,6 +197,13 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi }; } +function normalizeOptionalSendMessageId(input: unknown): { messageId?: string } { + if (input === undefined) return {}; + return { + messageId: normalizeRequiredString(input, 'Invalid send messageId', MAX_TURN_ID_LENGTH), + }; +} + function normalizeOptionalRetainedAttachments( input: unknown, ): { retainedAttachments?: AttachmentRef[] } { 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 5e0ba74a77..e6fc574add 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 @@ -321,6 +321,43 @@ export function registerRuntimeHostSessionExecutionIpc( ? { turnOrchestration: command.turnOrchestration } : {}), }; + if ( + command.messageId !== undefined && + !sideConversation && + (command.skillIds?.length ?? 0) === 0 && + command.turnOrchestration === undefined + ) { + const submitted = await deps.client.submitMessage({ + sessionId, + messageId: command.messageId, + content: startInput.content, + placement: 'current_turn', + }); + const skillInvocation = { loaded: [], failed: [], receipts: [] }; + if (submitted.disposition === 'turn_started') { + deps.emitSessionsChanged('status-change', sessionId, { + turnId: submitted.turnId, + }); + return { + ok: true as const, + disposition: submitted.disposition, + messageId: command.messageId, + turnId: submitted.turnId, + attachments, + inlineReferences, + skillInvocation, + }; + } + deps.emitSessionsChanged('status-change', sessionId); + return { + ok: true as const, + disposition: submitted.disposition, + messageId: command.messageId, + attachments, + inlineReferences, + skillInvocation, + }; + } let startResult; try { startResult = sideConversation @@ -443,7 +480,6 @@ export function registerRuntimeHostSessionExecutionIpc( const command = normalizeSessionSendCommand({ ...(value && typeof value === "object" ? value : {}), type: "send", - turnId: newId(), }); if (!command) throw new Error("Invalid queued message"); if ((command.skillIds?.length ?? 0) > 0 || command.turnOrchestration) { @@ -487,7 +523,7 @@ export function registerRuntimeHostSessionExecutionIpc( displayText, workspaceFileReferences: command.workspaceFileReferences, }); - const messageId = newId(); + const messageId = command.messageId ?? newId(); const result = await deps.client.submitMessage({ sessionId, messageId, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index cb3e0a41bf..5f6b4c96ce 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -789,7 +789,8 @@ export interface MakaBridge { | SessionCommand | { type: 'send'; - turnId: string; + messageId?: string; + turnId?: string; text: string; displayText?: string; skillIds?: string[]; @@ -802,6 +803,15 @@ export interface MakaBridge { >; }, ): Promise< + | { + ok: true; + disposition: 'turn_started' | 'steering' | 'followup'; + messageId: string; + turnId?: string; + attachments: import('@maka/core/events').AttachmentRef[]; + inlineReferences: import('@maka/core/events').InlineReference[]; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } | { ok: true; turnId: string; @@ -858,6 +868,7 @@ export interface MakaBridge { sessionId: string, placement: 'current_turn' | 'next_turn', command: { + messageId: string; text: string; displayText?: string; attachmentItems?: RendererIngestInput[]; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ffad51db57..dda5148678 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1595,7 +1595,8 @@ const makaBridge = { | SessionCommand | { type: 'send'; - turnId: string; + messageId?: string; + turnId?: string; text: string; displayText?: string; skillIds?: string[]; @@ -1606,6 +1607,15 @@ const makaBridge = { workspaceFileReferences?: Array>; }, ): Promise< + | { + ok: true; + disposition: 'turn_started' | 'steering' | 'followup'; + messageId: string; + turnId?: string; + attachments: AttachmentRef[]; + inlineReferences: InlineReference[]; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } | { ok: true; turnId: string; @@ -1683,6 +1693,7 @@ const makaBridge = { sessionId: string, placement: 'current_turn' | 'next_turn', command: { + messageId: string; text: string; displayText?: string; attachmentItems?: RendererIngestInput[]; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 6b2af3c717..ecd8b58649 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -112,6 +112,16 @@ export interface AppShellChatActions { onSessionResolved?: (sessionId: string) => void; }, ): Promise; + enqueueMessage( + sessionId: string, + text: string, + placement: 'current_turn' | 'next_turn', + pending?: readonly PendingAttachment[], + options?: { + quotes?: readonly QuoteRef[]; + workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; + }, + ): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; @@ -144,8 +154,8 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession: MessageLoadErrorUpdater; setMessageRetryPendingBySession: BooleanRecordUpdater; setMessages: MessageListUpdater; - addTransientMessage?: (sessionId: string, message: StoredMessage) => void; - removeTransientMessage?: (sessionId: string, messageId: string) => void; + addTransientMessage: (sessionId: string, message: StoredMessage) => void; + removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; setNavSelection: (selection: NavSelection) => void; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait @@ -215,7 +225,7 @@ export function createAppShellChatActions(deps: { const copy = getShellCopy(uiLocale).chatActions; function optimisticUserMessage( - turnId: string, + messageId: string, text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], quotes: readonly QuoteRef[] = [], @@ -223,11 +233,11 @@ export function createAppShellChatActions(deps: { ): StoredMessage { return { type: 'user', - // `turnId` is the Host's canonical message identity for direct sends. - // Keeping it on the transient row lets the durable transcript replace - // the same row instead of appending a second user message. - id: turnId, - turnId, + id: messageId, + // StoredMessage requires a grouping key, but materializeChat does not + // create a Turn from this renderer-only anchor. The Host turnId replaces + // presentation grouping through canonical transcript/live projection. + turnId: messageId, ts: Date.now(), text, ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), @@ -238,62 +248,39 @@ export function createAppShellChatActions(deps: { function showOptimisticUserMessage( sessionId: string, - turnId: string, + messageId: string, text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], options: { - replaceCurrentMessages?: boolean; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; } = {}, ): void { - if (activeIdRef.current !== sessionId) return; - setMessageLoadErrorBySession((current) => { - if (!current[sessionId]) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - }); const next = optimisticUserMessage( - turnId, + messageId, text, attachments, options.quotes, options.inlineReferences, ); - if (addTransientMessage) { - addTransientMessage(sessionId, next); - return; - } - setMessages((current) => { - if (current.some((message) => message.type === 'user' && message.turnId === turnId)) return current; - return options.replaceCurrentMessages ? [next] : [...current, next]; + addTransientMessage(sessionId, next); + if (activeIdRef.current !== sessionId) return; + setMessageLoadErrorBySession((current) => { + if (!current[sessionId]) return current; + const cleared = { ...current }; + delete cleared[sessionId]; + return cleared; }); } function removeOptimisticUserMessage(sessionId: string, turnId: string): void { - if (activeIdRef.current !== sessionId) return; - if (removeTransientMessage) { - removeTransientMessage(sessionId, turnId); - return; - } - setMessages((current) => current.filter((message) => message.id !== turnId)); + removeTransientMessage(sessionId, turnId); } - // #646: open the turn's model-wait window for a session. Armed the moment - // send() commits (before the IPC round-trip) so the "正在处理…" indicator - // covers the connect-to-first-token gap that has no SessionEvent of its own; - // disarmed if the send never reaches the runtime (the catch below). Always - // (re)set to `'waiting'`: a fresh send is a new first-token wait, so it must - // overwrite any `'streamed'` left by a prior turn whose terminal event was - // missed — otherwise the new turn's head would never show the indicator. - // - // The arm carries `unconfirmed` until the authority names this turn back. The - // runtime writes `status: 'running'` only at the END of `AgentRun.begin`, so - // every session list refreshed in between still reports the pre-send status — - // which is the same status a finished turn leaves behind. Without that bit, - // the stale value retires the arm the send just created - // (settled-session-transients.ts). + // Explicit orchestration reserves an exact Turn identity before IPC, so its + // renderer command surface keeps the existing first-token wait. Ordinary + // messages never call this path: LocalIntent presents the message and the + // Host subscription alone introduces the actual Turn. function armTurnActive(sessionId: string, turnId: string): void { setLiveTurnBySession((current) => { const active = current[sessionId]; @@ -311,40 +298,6 @@ export function createAppShellChatActions(deps: { }); } - // Rename only the exact unconfirmed arm this send created. Host events can - // beat the IPC response (main emits the sessions-changed nudge before it - // returns), and an authoritative projection that already arrived for the - // Host-chosen turn must not be replaced with a fresh waiting arm. - function rebindTurnActive(sessionId: string, fromTurnId: string, toTurnId: string): void { - setLiveTurnBySession((current) => { - const active = current[sessionId]; - if (!active || active.turnId !== fromTurnId || !active.unconfirmed || active.phase !== 'waiting') { - return current; - } - return { ...current, [sessionId]: armLiveTurn(toTurnId) }; - }); - } - - // One interpretation of a successful sessions:send for both the new-chat and - // existing-session branches: a busy-raced send can come back `steered` (this - // send owns no turn — the steering_message event renders the text) or under - // a Host-chosen turnId. Returns the turn the send owns, if any. - function settleSendBookkeeping( - sessionId: string, - requestedTurnId: string, - sendResult: { steered?: true; turnId?: string }, - ): string | undefined { - if (sendResult.steered) { - disarmTurnActive(sessionId, requestedTurnId); - return undefined; - } - const startedTurnId = sendResult.turnId ?? requestedTurnId; - if (startedTurnId !== requestedTurnId) { - rebindTurnActive(sessionId, requestedTurnId, startedTurnId); - } - return startedTurnId; - } - async function send( text: string, pending?: readonly PendingAttachment[], @@ -357,6 +310,7 @@ export function createAppShellChatActions(deps: { } = {}, ): Promise { const quotes = options.quotes; + const exactTurn = options.turnOrchestration !== undefined; const initialSessionId = activeIdRef.current; const initialNewTaskTarget = initialSessionId ? undefined : newTaskTarget; const sendOwner = captureComposerImportOwner(); @@ -370,7 +324,7 @@ export function createAppShellChatActions(deps: { return false; } let optimisticSessionId: string | undefined; - let optimisticTurnId: string | undefined; + let optimisticMessageId: string | undefined; // #1433: the composer creates the session BEFORE it sends, so a first // send that never lands has to take the session with it. Set the moment // creation succeeds, cleared the moment the send does — while it holds a @@ -392,7 +346,7 @@ export function createAppShellChatActions(deps: { } }; try { - const turnId = crypto.randomUUID(); + const messageId = crypto.randomUUID(); if (!initialSessionId) { if (!initialNewTaskTarget) return false; if (pending && pending.length > 0) preflightAttachmentItems(pending, uiLocale); @@ -414,8 +368,18 @@ export function createAppShellChatActions(deps: { // draft's. A failed create leaves it in place so a retry keeps it. if (newChatPermissionChoice) clearNewChatPermissionChoice(); optimisticSessionId = session.id; - optimisticTurnId = turnId; - armTurnActive(session.id, turnId); + optimisticMessageId = messageId; + showOptimisticUserMessage( + session.id, + messageId, + options.displayText ?? text, + [], + { + ...(quotes && quotes.length > 0 ? { quotes } : {}), + inlineReferences: [], + }, + ); + if (exactTurn) armTurnActive(session.id, messageId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -426,7 +390,7 @@ export function createAppShellChatActions(deps: { : undefined; const sendResult = await window.maka.sessions.send(session.id, { type: 'send', - turnId, + ...(options.turnOrchestration ? { turnId: messageId } : { messageId }), text, ...(options.displayText ? { displayText: options.displayText } : {}), ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), @@ -440,6 +404,17 @@ export function createAppShellChatActions(deps: { : {}), }); if (!sendResult.ok) { + if (sendResult.reason === 'outcome_unknown') { + unsentSessionId = undefined; + options.onSessionResolved?.(session.id); + if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { + setNavSelection({ section: 'sessions' }); + setActiveId(session.id); + } + await refreshSessions(); + return true; + } + removeOptimisticUserMessage(session.id, messageId); if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { showSkillInvocationFeedback( uiLocale, @@ -448,13 +423,11 @@ export function createAppShellChatActions(deps: { session.id, ); } - disarmTurnActive(session.id, turnId); + if (exactTurn) disarmTurnActive(session.id, messageId); await discardUnsentSession(); return false; } unsentSessionId = undefined; - const settledTurnId = settleSendBookkeeping(session.id, turnId, sendResult); - if (settledTurnId !== undefined) optimisticTurnId = settledTurnId; options.onSessionResolved?.(session.id); if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { showSkillInvocationFeedback( @@ -467,20 +440,17 @@ export function createAppShellChatActions(deps: { if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { setNavSelection({ section: 'sessions' }); setActiveId(session.id); - if (settledTurnId !== undefined) { - showOptimisticUserMessage( - session.id, - settledTurnId, - options.displayText ?? - skillInvocationDisplayText(text, sendResult.skillInvocation), - sendResult.attachments, - { - replaceCurrentMessages: true, - ...(quotes && quotes.length > 0 ? { quotes } : {}), - inlineReferences: sendResult.inlineReferences ?? [], - }, - ); - } + showOptimisticUserMessage( + session.id, + messageId, + options.displayText ?? + skillInvocationDisplayText(text, sendResult.skillInvocation), + sendResult.attachments, + { + ...(quotes && quotes.length > 0 ? { quotes } : {}), + inlineReferences: sendResult.inlineReferences ?? [], + }, + ); } await refreshSessions(); return true; @@ -504,8 +474,18 @@ export function createAppShellChatActions(deps: { } } optimisticSessionId = sessionId; - optimisticTurnId = turnId; - armTurnActive(sessionId, turnId); + optimisticMessageId = messageId; + showOptimisticUserMessage( + sessionId, + messageId, + options.displayText ?? text, + [], + { + ...(quotes && quotes.length > 0 ? { quotes } : {}), + inlineReferences: [], + }, + ); + if (exactTurn) armTurnActive(sessionId, messageId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -516,7 +496,7 @@ export function createAppShellChatActions(deps: { : undefined; const sendResult = await window.maka.sessions.send(sessionId, { type: 'send', - turnId, + ...(options.turnOrchestration ? { turnId: messageId } : { messageId }), text, ...(options.displayText ? { displayText: options.displayText } : {}), ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), @@ -530,6 +510,8 @@ export function createAppShellChatActions(deps: { : {}), }); if (!sendResult.ok) { + if (sendResult.reason === 'outcome_unknown') return true; + removeOptimisticUserMessage(sessionId, messageId); if (activeIdRef.current === sessionId) { showSkillInvocationFeedback( uiLocale, @@ -538,13 +520,10 @@ export function createAppShellChatActions(deps: { sessionId, ); } - disarmTurnActive(sessionId, turnId); + if (exactTurn) disarmTurnActive(sessionId, messageId); return false; } - const startedTurnId = settleSendBookkeeping(sessionId, turnId, sendResult); options.onSessionResolved?.(sessionId); - if (startedTurnId === undefined) return true; - optimisticTurnId = startedTurnId; if (activeIdRef.current === sessionId) { showSkillInvocationFeedback( uiLocale, @@ -555,7 +534,7 @@ export function createAppShellChatActions(deps: { } showOptimisticUserMessage( sessionId, - startedTurnId, + messageId, options.displayText ?? skillInvocationDisplayText(text, sendResult.skillInvocation), sendResult.attachments, @@ -567,14 +546,16 @@ export function createAppShellChatActions(deps: { return true; } catch (error) { await discardUnsentSession(); - if (optimisticSessionId && optimisticTurnId) { - removeOptimisticUserMessage(optimisticSessionId, optimisticTurnId); + if (optimisticSessionId && optimisticMessageId) { + removeOptimisticUserMessage(optimisticSessionId, optimisticMessageId); } // The turn never reached the runtime — close the model-wait window so the // "正在处理…" indicator doesn't hang after a failed send. Nothing else has // to be undone: the arm was the only claim the send made, and no // subscribeChanges event would reconcile a turn that never started. - if (optimisticSessionId && optimisticTurnId) disarmTurnActive(optimisticSessionId, optimisticTurnId); + if (exactTurn && optimisticSessionId && optimisticMessageId) { + disarmTurnActive(optimisticSessionId, optimisticMessageId); + } // Which surface is allowed to hear about this failure. The id alone is // not it: `selectNavigation` never clears `activeId` (nav-selection.ts), // so a user who left for 扩展 → 技能 mid-flight still "is" session A by @@ -622,6 +603,45 @@ export function createAppShellChatActions(deps: { } } + async function enqueueMessage( + sessionId: string, + text: string, + placement: 'current_turn' | 'next_turn', + pending?: readonly PendingAttachment[], + options: { + quotes?: readonly QuoteRef[]; + workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; + } = {}, + ): Promise { + const messageId = crypto.randomUUID(); + const quotes = options.quotes ?? []; + showOptimisticUserMessage(sessionId, messageId, text, retainedAttachmentRefs(pending ?? []), { + ...(quotes.length > 0 ? { quotes } : {}), + inlineReferences: [], + }); + try { + const attachmentItems = pending?.length ? toComposerIngestItems(pending) : []; + const retainedAttachments = pending?.length ? retainedAttachmentRefs(pending) : []; + const result = await window.maka.sessions.enqueue(sessionId, placement, { + messageId, + text, + ...(attachmentItems.length > 0 ? { attachmentItems } : {}), + ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}), + ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), + ...(options.workspaceFileReferences?.length + ? { workspaceFileReferences: [...options.workspaceFileReferences] } + : {}), + }); + showOptimisticUserMessage(sessionId, messageId, text, result.attachments, { + ...(quotes.length > 0 ? { quotes } : {}), + inlineReferences: result.inlineReferences, + }); + } catch (error) { + removeOptimisticUserMessage(sessionId, messageId); + throw error; + } + } + async function respondToSandboxBoundary(response: SandboxBoundaryResponse) { const sessionId = activeIdRef.current; if (!sessionId) return; @@ -738,6 +758,7 @@ export function createAppShellChatActions(deps: { return { send, + enqueueMessage, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index dfdf850ec3..b8c9f04793 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -84,6 +84,7 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession: StateUpdater>; setInteractionBySession: StateUpdater; setMessageQueueBySession?: StateUpdater>; + projectTransientMessage?: (sessionId: string, message: StoredMessage) => void; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; @@ -111,6 +112,7 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession, setInteractionBySession, setMessageQueueBySession, + projectTransientMessage, onInteractionChanged, onExecutionBoundaryChanged, onContextCompactionOutcome, @@ -283,6 +285,20 @@ export function createAppShellSessionEventHandlers(options: { switch (event.type) { case 'queue_update': + for (const entry of [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])]) { + projectTransientMessage?.(sessionId, { + type: 'user', + id: entry.messageId, + turnId: entry.messageId, + ts: event.ts, + text: entry.content.displayText ?? entry.content.text, + ...(entry.content.attachments ? { attachments: [...entry.content.attachments] } : {}), + ...(entry.content.quotes ? { quotes: [...entry.content.quotes] } : {}), + ...(entry.content.inlineReferences + ? { inlineReferences: [...entry.content.inlineReferences] } + : {}), + }); + } setMessageQueueBySession?.((current) => { if (event.steering.length === 0 && event.followup.length === 0) { if (!(sessionId in current)) return current; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 06898fdfb0..3782858823 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -172,10 +172,6 @@ import { createAppShellChatActions, type WorkspaceFileReferencePosition, } from './app-shell-chat-actions'; -import { - retainedAttachmentRefs, - toComposerIngestItems, -} from './composer-attachments'; import { createAppShellTurnActions } from './app-shell-turn-actions'; import { abandonTurnRevisionCopyAttempt, @@ -354,6 +350,7 @@ function AppShellContent({ setMessages, addTransientMessage, removeTransientMessage, + hasTransientMessages, transcriptRangeRef, messageLoadPending, setMessageLoadPending, @@ -803,6 +800,7 @@ function AppShellContent({ const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const activeSession = sessions.find((session) => session.id === activeId); const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; + const activeMessageSubmitting = activeId ? hasTransientMessages(activeId) : false; const activeDesktopSession = activeSession; // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the @@ -1757,6 +1755,7 @@ function AppShellContent({ const { send, + enqueueMessage, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, @@ -1872,16 +1871,13 @@ function AppShellContent({ ): Promise { const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; - const attachmentItems = pending ? toComposerIngestItems(pending) : []; - const retainedAttachments = pending ? retainedAttachmentRefs(pending) : []; try { - const result = await window.maka.sessions.enqueue( + await enqueueMessage( sessionId, + text, mode === 'steer' ? 'current_turn' : 'next_turn', + pending, { - text, - ...(attachmentItems.length > 0 ? { attachmentItems } : {}), - ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}), ...(quotes ? { quotes: [...quotes] } : {}), ...(metadata?.workspaceFileReferences?.length ? { workspaceFileReferences: [...metadata.workspaceFileReferences] } @@ -1890,10 +1886,6 @@ function AppShellContent({ ); if (pending) clearSubmittedAttachments(pending); if (quotes) clearQuotes(); - if (result.kind === 'started') { - await refreshMessages(sessionId); - await refreshSessions(); - } return true; } catch (error) { if (activeIdRef.current === sessionId) { @@ -2208,6 +2200,7 @@ function AppShellContent({ setLiveTurnBySession, setInteractionBySession, setMessageQueueBySession, + projectTransientMessage: addTransientMessage, displayBatch: sessionDisplayBatch, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -2880,7 +2873,7 @@ function AppShellContent({ // #646: in the first-token wait (Stop up, nothing streams yet) the // hint reads "Maka 正在处理…"; in a mid-turn lull it reads the calm // "Maka 继续中…". Both are mutually exclusive with activeStreamingLive. - processing={showProcessingIndicator && !activeStreamingLive} + processing={(showProcessingIndicator || activeMessageSubmitting) && !activeStreamingLive} continuing={showContinuingIndicator && !activeStreamingLive} onSend={sendOwningItsTarget} onStop={stop} 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 2cfd56a22e..9a342118e1 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,13 @@ export function createDesktopWorkbarServices( bridge.sessions.cleanupSessionCopy(sessionId), abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), - send: (sessionId, command) => bridge.sessions.send(sessionId, command), + send: async (sessionId, command) => { + const result = await bridge.sessions.send(sessionId, command); + if (result.ok && 'disposition' in result) { + throw new Error('Side Conversation send crossed the ordinary message adapter'); + } + return result; + }, stop: (sessionId, target) => bridge.sessions.stop( sessionId, diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/transient-message-projection.ts index e38f2283b1..231687c8c9 100644 --- a/apps/desktop/src/renderer/transient-message-projection.ts +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -27,9 +27,10 @@ import type { StoredMessage } from '@maka/core/session'; export function reconcileTransientMessages( transient: Map, durable: readonly StoredMessage[], + options: { includeTransient?: boolean } = {}, ): StoredMessage[] { for (const message of durable) transient.delete(message.id); - if (transient.size === 0) return [...durable]; + if (transient.size === 0 || options.includeTransient === false) return [...durable]; return [ ...durable, ...[...transient.values()].sort((left, right) => left.ts - right.ts), diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index 310440b37e..174cbe69a0 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -59,7 +59,14 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { function mergeTransientMessages(sessionId: string, durable: readonly StoredMessage[]): StoredMessage[] { const pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending || pending.size === 0) return [...durable]; - const projected = reconcileTransientMessages(pending, durable); + let includeTransient = true; + try { + const range = transcriptRangeRef.current?.store.range(); + includeTransient = range?.sessionId !== sessionId || !range.hasNewer; + } catch { + // An unopened transcript has no historical range to hide the live tail from. + } + const projected = reconcileTransientMessages(pending, durable, { includeTransient }); if (pending.size === 0) { transientMessagesBySessionRef.current.delete(sessionId); } @@ -96,6 +103,10 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { } } + function hasTransientMessages(sessionId: string): boolean { + return (transientMessagesBySessionRef.current.get(sessionId)?.size ?? 0) > 0; + } + function setActiveId(next: string | undefined): void { selectionRevisionRef.current += 1; // Clear here, not in the read effect: a layout-effect clear would wipe an @@ -145,6 +156,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { setMessages: setMessagesForActiveSession, addTransientMessage, removeTransientMessage, + hasTransientMessages, mergeTransientMessages, transcriptRangeRef, messageLoadPending, From d3be44bc971b519d65de5b59f51444db35e8e392 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 03:56:12 +0800 Subject: [PATCH 04/26] fix(cli): submit messages through host admission Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 20 + .../cli/src/__tests__/pi-tui-runner.test.ts | 501 ++---------------- .../cli/src/__tests__/pi-tui-turn.test.ts | 41 ++ .../runtime-host-session-driver.test.ts | 52 +- packages/cli/src/pi-transcript.ts | 15 +- packages/cli/src/pi-tui-runner.ts | 86 +-- packages/cli/src/pi-tui-turn.ts | 24 +- .../cli/src/runtime-host-session-driver.ts | 52 +- packages/cli/src/session-driver.ts | 16 +- packages/core/src/events.ts | 9 +- .../__tests__/root-turn-coordinator.test.ts | 6 + 11 files changed, 285 insertions(+), 537 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 55361e322b..6870600503 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -328,6 +328,26 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(state.entries, [{ kind: 'user', messageId: 'message-1', text: 'send now' }]); }); + test('reconciles a live steering event into its transient message position', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'send now', 'message-1', true); + state.entries.push({ kind: 'notice', level: 'error', text: 'later row' }); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'steering_message', + messageId: 'message-1', + content: { text: 'canonical text' }, + }), + ); + + assert.deepEqual(state.entries, [ + { kind: 'user', messageId: 'message-1', text: 'canonical text' }, + { kind: 'notice', level: 'error', text: 'later row' }, + ]); + }); + test('uses a shared message gutter and trims trailing block rows', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 73f5c1defa..b459711955 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -29,11 +29,7 @@ import { visibleWidth } from '@earendil-works/pi-tui'; import { SHELL_RUN_UPDATE_BUFFER_MAX_ENTRIES } from '@maka/core/shell-run-result'; import { type PermissionMode } from '@maka/core/permission'; import { type OrchestrationMode } from '@maka/core/orchestration'; -import { - type QueueEnqueueOutcome, - type SessionEvent, - type ShellRunUpdate, -} from '@maka/core/events'; +import { type SessionEvent, type ShellRunUpdate } from '@maka/core/events'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import { type SessionSummary, type StoredMessage } from '@maka/core/session'; import { type ThinkingLevel } from '@maka/core/model-thinking'; @@ -52,6 +48,7 @@ import type { MakaSessionRewindResult, MakaSessionSwitchOptions, MakaSessionSwitchResult, + MakaSubmitMessageOptions, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; @@ -2293,11 +2290,17 @@ describe('Maka Pi TUI runner', () => { terminal.input('\x1b'); terminal.input('\x1b'); // interrupt await waitFor(() => terminal.progressStates.at(-1) === false); - // Only the followup that was still queued comes back into the editor; the - // consumed steering text must not be resurrected from the stale mirror. + // The authoritative queue is cleared and only the followup comes back as + // a draft. Both already-sent message rows keep their stable identities; + // interrupting delivery must not make either presentation disappear. await waitFor(() => { const screen = plainTerminalOutput(terminal.screenOutput()); - return screen.includes('still queued') && !screen.includes('already consumed'); + return ( + screen.includes('still queued') && + screen.includes('already consumed') && + !screen.includes('Steering: already consumed') && + !screen.includes('Queued: still queued') + ); }); terminal.input('\x03'); // clear the refilled draft @@ -2347,199 +2350,6 @@ describe('Maka Pi TUI runner', () => { await run; }); - test.skip('a fallback enqueue during a long turn is never dropped and flushes into the next turn', async () => { - const terminal = new FakeTerminal(); - // Every enqueue reports `fallback` — the runtime never has a live owner. - const driver = new FallbackSteeringDriver(); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start the work'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - - terminal.input('second thought'); - terminal.input('\r'); // steer → fallback → CLI-held pending - terminal.input('and afterwards'); - terminal.input('\x1b\r'); // Alt+Enter → fallback → CLI-held pending - await waitFor(() => { - const screen = plainTerminalOutput(terminal.screenOutput()); - return ( - screen.includes('Steering: second thought') && screen.includes('Queued: and afterwards') - ); - }); - - // The old bounded poll gave up after ~2s of busy (about 20 attempts at the - // 100ms retry cadence) and silently dropped the text. Waiting for the - // driver to observe the retries crossing that budget — instead of guessing - // elapsed time — proves the CLI is still retrying under any scheduler load. - await waitForUpTo(() => driver.steerAttempts > 22 && driver.queueAttempts > 22, 30_000); - const screen = plainTerminalOutput(terminal.screenOutput()); - assert.equal(screen.includes('Steering: second thought'), true); - assert.equal(screen.includes('Queued: and afterwards'), true); - assert.deepEqual(driver.prompts, ['start the work']); - - // The turn boundary flushes the undelivered texts into the next turn. - driver.endTurn(); - await waitFor(() => driver.prompts.length === 2); - assert.equal(driver.prompts[1], 'second thought\n\nand afterwards'); - - await waitForUpTo(() => driver.parked, 1_000); - driver.endTurn(); - await waitFor(() => terminal.progressStates.at(-1) === false); - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - - test.skip('a fallback steer retries the same enqueue and lands once the owner appears', async () => { - const terminal = new FakeTerminal(); - const driver = new FallbackSteeringDriver(); - driver.steerFallbacks = 2; // the owner appears after ~200ms of retries - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start the work'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - - terminal.input('late owner'); - terminal.input('\r'); // steer → fallback, retried until it lands - await waitForUpTo(() => driver.steered.includes('late owner'), 1_000); - // Landed as a steer of the RUNNING turn — no fresh turn was opened. - assert.deepEqual(driver.prompts, ['start the work']); - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Steering: late owner'), - ); - - driver.endTurn(); - await waitFor(() => terminal.progressStates.at(-1) === false); - // Nothing left to flush: the text was delivered mid-turn, not re-queued. - assert.deepEqual(driver.prompts, ['start the work']); - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - - test.skip('a turn boundary waits for an unresolved enqueue before deciding whether to flush it', async () => { - const terminal = new FakeTerminal(); - const driver = new DeferredAdmissionDriver(); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - await waitForUpTo(() => driver.parked, 1_000); - terminal.input('late admission'); - terminal.input('\r'); - await waitFor(() => driver.steerCalls === 1); - - driver.endTurn(); - await waitFor(() => driver.completedTurns === 1); - assert.deepEqual(driver.prompts, ['start']); - driver.releaseAdmission({ kind: 'fallback' }); - await waitForUpTo(() => driver.prompts.length === 2, 1_000); - assert.equal(driver.prompts[1], 'late admission'); - - await waitForUpTo(() => driver.parked, 1_000); - driver.endTurn(); - await waitFor(() => terminal.progressStates.at(-1) === false); - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - - test.skip('a queued retry settling at the turn boundary is not also flushed as a new turn', async () => { - const terminal = new FakeTerminal(); - const driver = new DeferredRetryDriver(); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('lands on retry'); - terminal.input('\r'); - await waitForUpTo(() => driver.steerCalls === 2, 1_000); - - driver.endTurn(); - driver.releaseRetry(); - await waitFor(() => terminal.progressStates.at(-1) === false); - assert.deepEqual(driver.prompts, ['start']); - assert.deepEqual(driver.delivered, ['lands on retry']); - - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - - test.skip('interrupt refills CLI-held fallback text into the editor', async () => { - const terminal = new FakeTerminal(); - const driver = new FallbackSteeringDriver(); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start the work'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - - terminal.input('rescue me'); - terminal.input('\r'); // steer → fallback → CLI-held pending - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Steering: rescue me'), - ); - - terminal.input('\x1b'); - terminal.input('\x1b'); // interrupt - await waitFor(() => terminal.progressStates.at(-1) === false); - // The CLI-held text comes back for re-editing; the pending bar clears. - await waitFor(() => { - const screen = plainTerminalOutput(terminal.screenOutput()); - return screen.includes('rescue me') && !screen.includes('Steering: rescue me'); - }); - - terminal.input('\x03'); // clear the refilled draft - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - test('input during the interrupt convergence window stays in the editor and opens no turn', async () => { const terminal = new FakeTerminal(); const driver = new SlowStopDriver(); // stop() returns but the turn keeps running @@ -2586,49 +2396,6 @@ describe('Maka Pi TUI runner', () => { assert.deepEqual(driver.prompts, ['start the work']); }); - test.skip('an aborted turn never auto-opens the flush turn; undelivered text becomes a draft', async () => { - const terminal = new FakeTerminal(); - const driver = new FallbackSteeringDriver(); // enqueues always fall back - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start the work'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - - terminal.input('next thing'); - terminal.input('\x1b\r'); // Alt+Enter → fallback → CLI-held pending - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Queued: next thing'), - ); - - // The turn ends as ABORTED on its own (not via the CLI interrupt path): - // the boundary flush must not open a turn the user just stopped. - driver.abortNextTurn = true; - driver.endTurn(); - await waitFor(() => terminal.progressStates.at(-1) === false); - // The undelivered text is an editable draft, not a queued line. - await waitFor(() => { - const screen = plainTerminalOutput(terminal.screenOutput()); - return screen.includes('next thing') && !screen.includes('Queued: next thing'); - }); - - terminal.input('\x03'); // clear the preserved draft - terminal.input('/exit'); - terminal.input('\r'); - await run; - // Anchored after close: a wrongly-opened flush turn would have landed in - // prompts by the time the TUI has fully shut down. - assert.deepEqual(driver.prompts, ['start the work']); - }); - test('exits on the second Ctrl-C during a control command', async () => { const terminal = new FakeTerminal(); const driver = new DeferredControlDriver(); @@ -6894,8 +6661,10 @@ class SteeringTurnDriver implements MakaSessionDriver { private followup: string[] = []; private pendingEvents: SessionEvent[] = []; private wakeTurn: (() => void) | null = null; + private turnOpen = false; private turnEnded = false; private eventSeq = 0; + private startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; async listSessions(): Promise { return []; @@ -6938,6 +6707,7 @@ class SteeringTurnDriver implements MakaSessionDriver { } async *promptEvents(_prompt: string, turnId: string): AsyncIterable { + this.turnOpen = true; this.turnEnded = false; for (;;) { while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!; @@ -6946,22 +6716,43 @@ class SteeringTurnDriver implements MakaSessionDriver { this.wakeTurn = resolve; }); } + this.turnOpen = false; yield { type: 'abort', id: 'event-abort', turnId, ts: 1, reason: 'user_stop' }; yield { type: 'complete', id: 'event-complete', turnId, ts: 2, stopReason: 'user_stop' }; } - async steer(text: string): Promise { - this.steered.push(text); - this.steering.push(text); + async submitMessage(text: string, options: MakaSubmitMessageOptions) { + if (!this.turnOpen) { + const turn = await this.preparePrompt(text); + queueMicrotask(() => + this.startedTurnListener?.({ + ...turn, + messages: [], + summary: fakeSessionSummary(turn.sessionId), + }), + ); + return { messageId: options.messageId, disposition: 'turn_started' as const }; + } + if (options.placement === 'current_turn') { + this.steered.push(text); + this.steering.push(text); + } else { + this.queuedMessages.push(text); + this.followup.push(text); + } this.emitQueueUpdate(); - return { kind: 'queued' }; + return { + messageId: options.messageId, + disposition: + options.placement === 'current_turn' ? ('steering' as const) : ('followup' as const), + }; } - async queueMessage(text: string): Promise { - this.queuedMessages.push(text); - this.followup.push(text); - this.emitQueueUpdate(); - return { kind: 'queued' }; + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; } async retractQueued(): Promise { @@ -7009,210 +6800,6 @@ class SteeringTurnDriver implements MakaSessionDriver { } } -/** - * A driver whose enqueues hit the no-live-owner `fallback` outcome for the - * first N calls (configurable, default forever) while the turn parks until - * `endTurn()` — the begin-window shape behind review finding N2. - */ -class FallbackSteeringDriver implements MakaSessionDriver { - readonly prompts: string[] = []; - readonly steered: string[] = []; - readonly queuedMessages: string[] = []; - stopCalls = 0; - completedTurns = 0; - /** Enqueue calls that report `fallback` before the owner "appears". */ - steerFallbacks = Number.POSITIVE_INFINITY; - queueFallbacks = Number.POSITIVE_INFINITY; - /** Total enqueue attempts, including rejected ones — the observable retry count. */ - steerAttempts = 0; - queueAttempts = 0; - private steering: string[] = []; - private followup: string[] = []; - private pendingEvents: SessionEvent[] = []; - private wakeTurn: (() => void) | null = null; - private turnOpen = false; - private turnEnded = false; - private eventSeq = 0; - - get parked(): boolean { - return this.turnOpen && !this.turnEnded; - } - - async listSessions(): Promise { - return []; - } - - preparePrompt( - prompt: string, - options: MakaPreparePromptOptions = {}, - ): Promise { - this.prompts.push(options.modelText ?? prompt); - const turnId = options.turnId ?? `turn-${this.prompts.length}`; - return Promise.resolve({ - sessionId: this.getSessionId(), - turnId, - events: this.promptEvents(turnId), - }); - } - - async *compactSession(): AsyncIterable {} - - // Same single-path contract as the runtime: queue contents reach the CLI - // only through `queue_update` events on the turn stream. - private emitQueueUpdate(): void { - this.eventSeq += 1; - this.pendingEvents.push({ - type: 'queue_update', - id: `queue-update-${this.eventSeq}`, - turnId: `turn-${this.prompts.length}`, - ts: this.eventSeq, - steering: [...this.steering], - followup: [...this.followup], - }); - this.wakeTurn?.(); - this.wakeTurn = null; - } - - async *promptEvents(turnId: string): AsyncIterable { - this.turnOpen = true; - this.turnEnded = false; - for (;;) { - while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!; - if (this.turnEnded) break; - await new Promise((resolve) => { - this.wakeTurn = resolve; - }); - } - this.turnOpen = false; - if (this.abortNextTurn) { - this.abortNextTurn = false; - yield { - type: 'abort', - id: `abort-${this.prompts.length}`, - turnId, - ts: 1, - reason: 'user_stop', - }; - yield { - type: 'complete', - id: `complete-${this.prompts.length}`, - turnId, - ts: 2, - stopReason: 'user_stop', - }; - this.completedTurns += 1; - return; - } - yield { - type: 'complete', - id: `complete-${this.prompts.length}`, - turnId, - ts: 1, - stopReason: 'end_turn', - }; - this.completedTurns += 1; - } - - /** Next endTurn() finishes the turn as aborted instead of end_turn. */ - abortNextTurn = false; - - async steer(text: string): Promise { - this.steerAttempts += 1; - if (this.steerFallbacks > 0) { - this.steerFallbacks -= 1; - return { kind: 'fallback' }; - } - this.steered.push(text); - this.steering.push(text); - this.emitQueueUpdate(); - return { kind: 'queued' }; - } - - async queueMessage(text: string): Promise { - this.queueAttempts += 1; - if (this.queueFallbacks > 0) { - this.queueFallbacks -= 1; - return { kind: 'fallback' }; - } - this.queuedMessages.push(text); - this.followup.push(text); - this.emitQueueUpdate(); - return { kind: 'queued' }; - } - - async retractQueued(): Promise { - const joined = [...this.steering, ...this.followup].join('\n\n'); - this.steering = []; - this.followup = []; - this.emitQueueUpdate(); - return joined; - } - - endTurn(): void { - this.turnEnded = true; - this.wakeTurn?.(); - this.wakeTurn = null; - } - - async stop(): Promise { - this.stopCalls += 1; - this.steering = []; - this.followup = []; - this.endTurn(); - } - - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } -} - -class DeferredAdmissionDriver extends FallbackSteeringDriver { - steerCalls = 0; - readonly #admission = deferred(); - - override async steer(_text: string): Promise { - this.steerCalls += 1; - return this.#admission.promise; - } - - releaseAdmission(outcome: QueueEnqueueOutcome): void { - this.#admission.resolve(outcome); - } -} - -class DeferredRetryDriver extends FallbackSteeringDriver { - steerCalls = 0; - readonly delivered: string[] = []; - readonly #retry = deferred(); - - override async steer(text: string): Promise { - this.steerCalls += 1; - if (this.steerCalls === 1) return { kind: 'fallback' }; - await this.#retry.promise; - this.delivered.push(text); - return { kind: 'queued' }; - } - - releaseRetry(): void { - this.#retry.resolve(); - } -} - class SlowStopDriver implements MakaSessionDriver { stopCalls = 0; readonly prompts: string[] = []; diff --git a/packages/cli/src/__tests__/pi-tui-turn.test.ts b/packages/cli/src/__tests__/pi-tui-turn.test.ts index b484c72cf5..8606611c1c 100644 --- a/packages/cli/src/__tests__/pi-tui-turn.test.ts +++ b/packages/cli/src/__tests__/pi-tui-turn.test.ts @@ -24,6 +24,47 @@ import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { runMakaPiTuiTurn } from '../pi-tui-turn.js'; describe('Maka Pi TUI turn', () => { + test('submits an ordinary message once and leaves Turn projection to the Host subscription', async () => { + const sequence: string[] = []; + const outcome = await runMakaPiTuiTurn({ + driver: { + async preparePrompt() { + throw new Error('ordinary admission must not start a renderer-owned Turn'); + }, + async submitMessage(prompt, options) { + sequence.push('submit'); + assert.equal(prompt, 'visible prompt'); + assert.deepEqual(options, { + messageId: 'message-1', + placement: 'current_turn', + modelText: 'expanded prompt', + }); + return { messageId: options.messageId, disposition: 'turn_started' }; + }, + }, + turnActivity: { activities: new SessionActivityRegistry() }, + request: { + kind: 'external', + prompt: 'visible prompt', + turnId: 'message-1', + sendText: 'expanded prompt', + sessionId: null, + }, + shouldAbort: () => false, + onStart: () => sequence.push('start'), + onPrepared: () => { + sequence.push('prepared'); + }, + }); + + assert.deepEqual(outcome, { + kind: 'admitted', + messageId: 'message-1', + disposition: 'turn_started', + }); + assert.deepEqual(sequence, ['start', 'submit']); + }); + test('prepares and drains an external turn under one Session activity lease', async () => { const activities = new SessionActivityRegistry(); const sequence: string[] = []; 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 b163b6689f..effe47069d 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1096,11 +1096,17 @@ describe('Runtime Host Maka Session driver', () => { cwd: '/tmp', llmConnectionSlug: 'openai-main', model: 'gpt-5', - newId: sequenceIds('message-1', 'retract-1'), + newId: sequenceIds('retract-1'), }); await driver.switchSession('session-1'); - assert.deepEqual(await driver.queueMessage!('Later'), { kind: 'queued' }); + assert.deepEqual( + await driver.submitMessage!('Later', { + messageId: 'message-1', + placement: 'next_turn', + }), + { messageId: 'message-1', disposition: 'followup' }, + ); assert.equal(await driver.retractQueued!(), 'Later'); assert.deepEqual( connection.requests.filter( @@ -1130,6 +1136,40 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('submits an idle message under the caller-owned stable identity', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('unused-generated-id'), + }); + await driver.switchSession('session-1'); + + const admission = await driver.submitMessage!('Visible prompt', { + messageId: 'message-1', + placement: 'current_turn', + modelText: 'Expanded prompt', + }); + + assert.deepEqual(admission, { + messageId: 'message-1', + disposition: 'steering', + }); + assert.deepEqual(connection.requests.at(-1), { + operation: 'turn.message.submit', + input: { + originHostEpoch: 'host-1', + sessionId: 'session-1', + messageId: 'message-1', + content: { text: 'Expanded prompt', displayText: 'Visible prompt' }, + placement: 'current_turn', + }, + }); + }); + test('projects the acknowledgement that releases a question answered through the Host', async () => { const subscription = new FakeSubscription( continuitySnapshot({ interactions: { pending: [pendingQuestion()] } }), @@ -1792,7 +1832,13 @@ class FakeConnection { : operation === 'session.execution_boundary.query' ? this.executionBoundary : operation === 'turn.message.submit' - ? { disposition: 'queued', queueRevision: 2 } + ? { + disposition: + (input as OperationInput<'turn.message.submit'>).placement === 'next_turn' + ? 'followup' + : 'steering', + queueRevision: 2, + } : operation === 'queue.retract' ? { hostEpoch: 'host-1', diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index e64f693330..78755aa2d8 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -239,7 +239,20 @@ export function appendUserPrompt( messageId: string, transient = false, ): void { - state.entries.push({ kind: 'user', messageId, text, ...(transient ? { transient: true } : {}) }); + const entry = { + kind: 'user', + messageId, + text, + ...(transient ? { transient: true } : {}), + } as const; + const existingIndex = state.entries.findIndex( + (candidate) => candidate.kind === 'user' && candidate.messageId === messageId, + ); + if (existingIndex >= 0) { + state.entries[existingIndex] = entry; + return; + } + state.entries.push(entry); } export function appendTurnFailureToTranscript(state: MakaPiTranscriptState, error: unknown): void { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index f830a27968..0417ad7403 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -60,7 +60,6 @@ import { type ForeignSessionSummary, } from '@maka/core/foreign-session'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; -import type { GoalTurnOutcome } from '@maka/runtime/goal-continuation'; import type { SessionActivityLease } from '@maka/runtime/goal-turn-lifecycle'; import { listApiKeyOnboardableProviders } from './onboarding-catalog.js'; import type { @@ -102,7 +101,11 @@ import { toggleAllToolExpansion, type MakaPiTranscriptMetadata, } from './pi-transcript.js'; -import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; +import { + runMakaPiTuiTurn, + type MakaPiTuiTurnOutcome, + type MakaPiTuiTurnRequest, +} from './pi-tui-turn.js'; import { editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; import { TranscriptViewerOverlay } from './pi-tui-transcript-viewer.js'; @@ -899,31 +902,39 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); }; - // Enter during a turn steers it (inject at the next step boundary); the - // runtime falls back to a fresh turn if the run already ended. + const removeTransientUserMessage = (messageId: string) => { + const index = state.entries.findIndex( + (entry) => entry.kind === 'user' && entry.transient === true && entry.messageId === messageId, + ); + if (index >= 0) state.entries.splice(index, 1); + }; + + // Enter during a turn asks the Host to place the message at the current + // step boundary. The Host alone decides whether it steers or starts a + // successor Turn if the previous Turn settled during admission. const steerRunningTurn = (text: string) => { if (!text.trim()) { requestRender(); return; } editor.addToHistory(text); - const enqueue = input.driver.steer; - if (!enqueue) { + const submitMessage = input.driver.submitMessage; + if (!submitMessage) { refillEditorFromQueues(text); return; } - const task = enqueue - .call(input.driver, text) - .then((outcome) => { - if (outcome.kind === 'fallback') { - if (turnRunning || busy) refillEditorFromQueues(text); - else submitPrompt(text); - return; - } - // Queued: the runtime's `queue_update` event refreshes the mirror. + const messageId = randomUUID(); + appendUserPrompt(state, text, messageId, true); + requestRender(); + const task = submitMessage + .call(input.driver, text, { messageId, placement: 'current_turn' }) + .then(() => { + // The subscription projects queue and Turn state; admission only + // confirms that this stable message identity belongs to the Host. requestRender(); }) .catch((error) => { + removeTransientUserMessage(messageId); refillEditorFromQueues(text); reportError(error); }); @@ -948,23 +959,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } editor.addToHistory(text); - const enqueue = input.driver.queueMessage; - if (!enqueue) { + const submitMessage = input.driver.submitMessage; + if (!submitMessage) { refillEditorFromQueues(text); return; } - const task = enqueue - .call(input.driver, text) - .then((outcome) => { - if (outcome.kind === 'fallback') { - if (turnRunning || busy) refillEditorFromQueues(text); - else submitPrompt(text); - return; - } - // Queued: the runtime's `queue_update` event refreshes the mirror. + const messageId = randomUUID(); + appendUserPrompt(state, text, messageId, true); + requestRender(); + const task = submitMessage + .call(input.driver, text, { messageId, placement: 'next_turn' }) + .then(() => { requestRender(); }) .catch((error) => { + removeTransientUserMessage(messageId); refillEditorFromQueues(text); reportError(error); }); @@ -1098,7 +1107,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { function runAgentTurn( request: MakaPiTuiTurnRequest, authoritativeAttachedTurn?: MakaAttachedSessionTurn, - ): Promise { + ): Promise { busy = true; const epoch = ++turnEpoch; // A mid-turn /session switch-away (#3380) bumps turnEpoch and orphans this @@ -1106,19 +1115,26 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // runner state — the adopted Session owns it now. const superseded = () => epoch !== turnEpoch; const activity = beginActivity(); - turnRunning = true; - turnStartedAt = Date.now(); - startTurnElapsedTicker(); - interruptRequested = false; - lastTurnEscapeAt = 0; - editor.disableSubmit = false; - setTaskbarProgress(true); - attention.promptTurnStarted(); + const ownsTurnUi = + request.kind === 'attached' || + request.turnOrchestration !== undefined || + input.driver.submitMessage === undefined; + if (ownsTurnUi) { + turnRunning = true; + turnStartedAt = Date.now(); + startTurnElapsedTicker(); + interruptRequested = false; + lastTurnEscapeAt = 0; + editor.disableSubmit = false; + setTaskbarProgress(true); + attention.promptTurnStarted(); + } requestRender(); let permissionAlerted = false; let optimisticUserEntry: (typeof state.entries)[number] | undefined; const finishTurnUi = () => { + if (!ownsTurnUi) return; turnRunning = false; turnStartedAt = undefined; stopTurnElapsedTicker(); diff --git a/packages/cli/src/pi-tui-turn.ts b/packages/cli/src/pi-tui-turn.ts index ad2241a71a..9b1553bc6a 100644 --- a/packages/cli/src/pi-tui-turn.ts +++ b/packages/cli/src/pi-tui-turn.ts @@ -30,6 +30,7 @@ import { type GoalTurnOutcome } from '@maka/runtime/goal-continuation'; import { SkillInvocationBlockedError, type MakaPreparedSessionTurn, + type MakaMessageAdmission, type MakaSessionDriver, } from './session-driver.js'; @@ -57,7 +58,7 @@ export type MakaPiTuiTurnRequest = }; export interface RunMakaPiTuiTurnInput { - driver: Pick; + driver: Pick; turnActivity: MakaPiTuiTurnActivity; request: MakaPiTuiTurnRequest; shouldAbort: () => boolean; @@ -68,17 +69,21 @@ export interface RunMakaPiTuiTurnInput { onFailure?: (error: unknown) => void | Promise; } +export type MakaPiTuiTurnOutcome = GoalTurnOutcome | ({ kind: 'admitted' } & MakaMessageAdmission); + /** * Owns one visible TUI turn from activity reservation through full stream drain. * Goal continuation and ScheduledTask admission remain Runtime Host responsibilities. */ -export async function runMakaPiTuiTurn(input: RunMakaPiTuiTurnInput): Promise { +export async function runMakaPiTuiTurn( + input: RunMakaPiTuiTurnInput, +): Promise { const { request } = input; let activity: SessionActivityLease | undefined; const externalTurnId = request.kind === 'external' ? (request.turnId ?? randomUUID()) : undefined; let preparedTurnId = request.kind === 'attached' ? request.turn.turnId : externalTurnId; - const finishBeforeDrain = (outcome: GoalTurnOutcome): GoalTurnOutcome => { + const finishBeforeDrain = (outcome: T): T => { activity?.release(); activity = undefined; return outcome; @@ -99,6 +104,19 @@ export async function runMakaPiTuiTurn(input: RunMakaPiTuiTurnInput): Promise { - return this.#enqueue(text, 'current_turn'); - } - - async queueMessage(text: string): Promise { - return this.#enqueue(text, 'next_turn'); + async submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + const sessionId = await this.#ensureSession(); + const sessionGeneration = this.#sessionGeneration; + const configuration = await this.#loadConfiguration(sessionId); + this.#assertCurrentSession(sessionId, sessionGeneration); + await this.#ensureChannel(sessionId); + this.#assertCurrentSession(sessionId, sessionGeneration); + this.#adoptLoadedConfiguration(configuration); + const modelText = options.modelText ?? text; + const result = await this.#request('turn.message.submit', { + originHostEpoch: this.#connection.hostEpoch, + sessionId, + messageId: options.messageId, + content: { + text: modelText, + ...(modelText === text ? {} : { displayText: text }), + }, + placement: options.placement, + }); + return { messageId: options.messageId, disposition: result.disposition }; } async retractQueued(): Promise { @@ -944,26 +962,6 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { await previous?.close().catch(() => undefined); } - async #enqueue( - text: string, - placement: 'current_turn' | 'next_turn', - ): Promise { - const sessionId = this.#sessionId; - if (!sessionId) return { kind: 'fallback' }; - const result = await this.#request('turn.message.submit', { - originHostEpoch: this.#connection.hostEpoch, - sessionId, - messageId: this.#newId(), - content: { text }, - placement, - }); - // A root Turn can settle between the local projection check and Host - // admission. The Host has already started the message in that case, so it - // must not be submitted again. Treat it as accepted; the subscription owns - // projection of the successor Turn. - return { kind: 'queued' }; - } - async #updateConfiguration( sessionId: string, patch: { diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index cda036f25e..3ef60a0a1b 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -18,7 +18,7 @@ */ import { realpath } from 'node:fs/promises'; -import type { QueueEnqueueOutcome, SessionEvent } from '@maka/core/events'; +import type { SessionEvent } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -90,6 +90,17 @@ export interface MakaPreparePromptOptions { maxSteps?: number; } +export interface MakaSubmitMessageOptions { + messageId: string; + placement: 'current_turn' | 'next_turn'; + modelText?: string; +} + +export interface MakaMessageAdmission { + messageId: string; + disposition: 'steering' | 'followup' | 'turn_started'; +} + export class SkillInvocationBlockedError extends Error { constructor(readonly skillInvocation: SkillInvocationResult) { super('Explicit Skill invocation could not be resolved'); @@ -104,10 +115,9 @@ export interface MakaSessionDriver { prompt: string, options?: MakaPreparePromptOptions, ): Promise; + submitMessage?(text: string, options: MakaSubmitMessageOptions): Promise; compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; - steer?(text: string): Promise; - queueMessage?(text: string): Promise; retractQueued?(): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index f73952184f..7561c5e3c0 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1096,14 +1096,7 @@ export interface MessageAdmissionEvent extends BaseEvent { outcome: 'admitted' | 'retracted'; } -/** - * 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 - * fresh turn with the text instead, so a message is never silently dropped. - * Queue contents travel on ONE path only: the `queue_update` event. - */ -export type QueueEnqueueOutcome = { kind: 'queued' } | { kind: 'fallback' }; - +/** Host-owned placement for a submitted message projected through `queue_update`. */ export type MessageQueuePlacement = 'current_turn' | 'next_turn'; export type MessageQueueEntryState = 'queued' | 'in_flight'; export type FollowUpMode = 'queue' | 'steer'; diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index f350b69baf..5e041ebecd 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -3528,6 +3528,12 @@ test('mixed-Client queued follow-ups use one Session successor without connectio admissions.map((admission) => admission.sourceMessages.map((source) => source.messageId)), [[], ['followup-from-provider-b', 'followup-from-provider-a']], ); + assert.deepEqual( + (await fixture.stores.sessionStore.readMessages(fixture.sessionId)) + .filter((message) => message.type === 'user' && message.id.startsWith('followup-from-')) + .map((message) => message.id), + ['followup-from-provider-b', 'followup-from-provider-a'], + ); } finally { first.close(); second.close(); From a8550691cae0afee438fbc98571f69697092e7c0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 04:03:40 +0800 Subject: [PATCH 05/26] fix(desktop): narrow WorkHub exact send results Generated-by: Codex --- .../workhub-coordination-host-scope.test.ts | 25 +++++++++++++++ .../workhub-coordination-host-scope.ts | 31 +++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts index 0a4e014fe2..3f65c2e550 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts @@ -122,6 +122,31 @@ test('WorkHub candidates follow the resolved Coordination Session Host only', as stop(); }); +test('WorkHub rejects an ordinary message admission at its exact Turn adapter', async () => { + const sessionId = desktopSessionKey({ hostId: 'host-a', sessionId: 'ordinary-a' }); + const sessions = scopeWorkHubSessionsToCoordinationHost( + { + list: async () => [ordinarySession(sessionId)], + listTurns: async () => [], + create: async () => ordinarySession(sessionId), + send: async () => ({ + ok: true, + disposition: 'steering', + messageId: 'message-1', + }), + stop: async () => undefined, + subscribeChanges: () => () => undefined, + }, + { sessionId, isCurrent: () => true }, + async () => ordinarySession(sessionId), + ); + + await assert.rejects( + sessions.send(sessionId, { type: 'send', turnId: 'turn-1', text: 'coordinate' }), + /ordinary message admission/, + ); +}); + function ordinarySession(id: string): Awaited>[number] { return { id, diff --git a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts index 370a5b9a15..84ad982a41 100644 --- a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts +++ b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts @@ -29,9 +29,16 @@ export interface WorkHubCoordinationHostAuthority { readonly isCurrent: () => boolean; } +type WorkHubDesktopSessionSourceBridge = Omit & { + send( + sessionId: string, + command: { type: 'send'; turnId: string; text: string }, + ): Promise; +}; + /** Restricts the transitional WorkHub router to the Coordination Session's Host. */ export function scopeWorkHubSessionsToCoordinationHost( - sessions: WorkHubDesktopSessionBridge, + sessions: WorkHubDesktopSessionSourceBridge, coordination: WorkHubCoordinationHostAuthority, createOnCoordinationHost: WorkHubCoordinationHostSessionCreator, ): WorkHubDesktopSessionBridge { @@ -80,7 +87,27 @@ export function scopeWorkHubSessionsToCoordinationHost( }, async send(sessionId: string, command: { type: 'send'; turnId: string; text: string }) { requireTargetHost(sessionId); - return await sessions.send(sessionId, command); + const result = await sessions.send(sessionId, command); + if (!result || typeof result !== 'object' || !('ok' in result)) { + throw new Error('WorkHub exact Turn send returned an invalid result'); + } + if (result.ok === false && 'reason' in result && typeof result.reason === 'string') { + return { ok: false as const, reason: result.reason }; + } + if ( + result.ok === true && + !('disposition' in result) && + 'turnId' in result && + typeof result.turnId === 'string' && + (!('steered' in result) || result.steered === true) + ) { + return { + ok: true as const, + turnId: result.turnId, + ...('steered' in result ? { steered: true as const } : {}), + }; + } + throw new Error('WorkHub exact Turn send returned an ordinary message admission'); }, async stop( sessionId: string, From 5d9b3db0ff3e3c9f9a83d4ca33ddf19ab5091840 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 04:25:42 +0800 Subject: [PATCH 06/26] refactor(desktop): separate message admission from exact turns Generated-by: Codex --- .../workhub-coordination-host-scope.test.ts | 25 ---- apps/desktop/src/preload/bridge-contract.d.ts | 62 +++++++--- apps/desktop/src/preload/preload.ts | 115 +++++++----------- .../src/renderer/app-shell-chat-actions.ts | 36 ++++-- .../desktop/create-workbar-services.ts | 8 +- .../workhub-coordination-host-scope.ts | 31 +---- 6 files changed, 119 insertions(+), 158 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts index 3f65c2e550..0a4e014fe2 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts @@ -122,31 +122,6 @@ test('WorkHub candidates follow the resolved Coordination Session Host only', as stop(); }); -test('WorkHub rejects an ordinary message admission at its exact Turn adapter', async () => { - const sessionId = desktopSessionKey({ hostId: 'host-a', sessionId: 'ordinary-a' }); - const sessions = scopeWorkHubSessionsToCoordinationHost( - { - list: async () => [ordinarySession(sessionId)], - listTurns: async () => [], - create: async () => ordinarySession(sessionId), - send: async () => ({ - ok: true, - disposition: 'steering', - messageId: 'message-1', - }), - stop: async () => undefined, - subscribeChanges: () => () => undefined, - }, - { sessionId, isCurrent: () => true }, - async () => ordinarySession(sessionId), - ); - - await assert.rejects( - sessions.send(sessionId, { type: 'send', turnId: 'turn-1', text: 'coordinate' }), - /ordinary message admission/, - ); -}); - function ordinarySession(id: string): Awaited>[number] { return { id, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 5f6b4c96ce..2fefb87964 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -783,25 +783,22 @@ export interface MakaBridge { completeHostIds: string[]; }>; create(input?: CreateSessionRequestInput): Promise; - send( + submitMessage( sessionId: string, - command: - | SessionCommand - | { - type: 'send'; - messageId?: string; - turnId?: string; - text: string; - displayText?: string; - skillIds?: string[]; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: import('@maka/core/events').AttachmentRef[]; - turnOrchestration?: TurnOrchestration; - quotes?: import('@maka/core/events').QuoteRef[]; - workspaceFileReferences?: Array< - Pick - >; - }, + command: { + type: 'send'; + messageId: string; + text: string; + displayText?: string; + skillIds?: string[]; + attachmentItems?: RendererIngestInput[]; + retainedAttachments?: import('@maka/core/events').AttachmentRef[]; + turnOrchestration?: never; + quotes?: import('@maka/core/events').QuoteRef[]; + workspaceFileReferences?: Array< + Pick + >; + }, ): Promise< | { ok: true; @@ -812,6 +809,35 @@ export interface MakaBridge { inlineReferences: import('@maka/core/events').InlineReference[]; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } + | { + ok: false; + 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; + } + >; + send( + sessionId: string, + command: { + type: 'send'; + turnId: string; + text: string; + displayText?: string; + skillIds?: string[]; + attachmentItems?: RendererIngestInput[]; + retainedAttachments?: import('@maka/core/events').AttachmentRef[]; + turnOrchestration?: TurnOrchestration; + quotes?: import('@maka/core/events').QuoteRef[]; + workspaceFileReferences?: Array< + Pick + >; + }, + ): Promise< | { ok: true; turnId: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index dda5148678..7be996e2f1 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -854,6 +854,47 @@ async function createDesktopSessionOnScope( return projectSessionSummary(scope, session); } +function sendDesktopSessionCommand( + sessionId: string, + command: Parameters[1], +): ReturnType; +function sendDesktopSessionCommand( + sessionId: string, + command: Parameters[1], +): ReturnType; +async function sendDesktopSessionCommand( + sessionId: string, + command: + | Parameters[1] + | Parameters[1], +): Promise< + | Awaited> + | Awaited> +> { + const session = await runtimeHostSessionRef(sessionId); + const send = async (input: SessionCommand | Record) => { + const result = (await ipcRenderer.invoke( + 'sessions:send', + session.scope, + session.sessionId, + input, + )) as + | Awaited> + | Awaited>; + return result.ok + ? { + ...result, + attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), + } + : result; + }; + if (command.type === 'send' && 'attachmentItems' in command && command.attachmentItems) { + const encoded = await encodeIngestItems(command.attachmentItems as RendererIngestInput[]); + return send({ ...command, attachmentItems: encoded }); + } + return send(command); +} + function sendActiveRuntimeHost(channel: string, ...args: unknown[]): void { void activeRuntimeHostRef() .then((scope) => ipcRenderer.send(channel, scope, ...args)) @@ -1589,75 +1630,11 @@ const makaBridge = { const scope = await activeRuntimeHostRef(); return createDesktopSessionOnScope(scope, input); }, - async send( - sessionId: string, - command: - | SessionCommand - | { - type: 'send'; - messageId?: string; - turnId?: string; - text: string; - displayText?: string; - skillIds?: string[]; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: AttachmentRef[]; - turnOrchestration?: TurnOrchestration; - quotes?: QuoteRef[]; - workspaceFileReferences?: Array>; - }, - ): Promise< - | { - ok: true; - disposition: 'turn_started' | 'steering' | 'followup'; - messageId: string; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; - } - | { - ok: true; - turnId: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; - } - | { - ok: false; - 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) => { - const result = await ipcRenderer.invoke( - 'sessions:send', - session.scope, - session.sessionId, - input, - ) as Awaited>; - return result.ok - ? { - ...result, - attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), - } - : result; - }; - if (command.type === 'send' && 'attachmentItems' in command && command.attachmentItems) { - const encoded = await encodeIngestItems(command.attachmentItems as RendererIngestInput[]); - return send({ - ...command, - attachmentItems: encoded, - }); - } - return send(command); + submitMessage(sessionId, command) { + return sendDesktopSessionCommand(sessionId, command); + }, + send(sessionId, command) { + return sendDesktopSessionCommand(sessionId, command); }, compact(sessionId: string): Promise> { return invokeSessionRuntimeHost('sessions:compact', sessionId); diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index ecd8b58649..1af50bf4e6 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -388,12 +388,10 @@ export function createAppShellChatActions(deps: { pending && pending.length > 0 ? retainedAttachmentRefs(pending) : undefined; - const sendResult = await window.maka.sessions.send(session.id, { - type: 'send', - ...(options.turnOrchestration ? { turnId: messageId } : { messageId }), + const sendCommand = { + type: 'send' as const, text, ...(options.displayText ? { displayText: options.displayText } : {}), - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } @@ -402,7 +400,17 @@ export function createAppShellChatActions(deps: { ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), - }); + }; + const sendResult = options.turnOrchestration + ? await window.maka.sessions.send(session.id, { + ...sendCommand, + turnId: messageId, + turnOrchestration: options.turnOrchestration, + }) + : await window.maka.sessions.submitMessage(session.id, { + ...sendCommand, + messageId, + }); if (!sendResult.ok) { if (sendResult.reason === 'outcome_unknown') { unsentSessionId = undefined; @@ -494,12 +502,10 @@ export function createAppShellChatActions(deps: { pending && pending.length > 0 ? retainedAttachmentRefs(pending) : undefined; - const sendResult = await window.maka.sessions.send(sessionId, { - type: 'send', - ...(options.turnOrchestration ? { turnId: messageId } : { messageId }), + const sendCommand = { + type: 'send' as const, text, ...(options.displayText ? { displayText: options.displayText } : {}), - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } @@ -508,7 +514,17 @@ export function createAppShellChatActions(deps: { ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), - }); + }; + const sendResult = options.turnOrchestration + ? await window.maka.sessions.send(sessionId, { + ...sendCommand, + turnId: messageId, + turnOrchestration: options.turnOrchestration, + }) + : await window.maka.sessions.submitMessage(sessionId, { + ...sendCommand, + messageId, + }); if (!sendResult.ok) { if (sendResult.reason === 'outcome_unknown') return true; removeOptimisticUserMessage(sessionId, messageId); 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 9a342118e1..2cfd56a22e 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -119,13 +119,7 @@ export function createDesktopWorkbarServices( bridge.sessions.cleanupSessionCopy(sessionId), abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), - send: async (sessionId, command) => { - const result = await bridge.sessions.send(sessionId, command); - if (result.ok && 'disposition' in result) { - throw new Error('Side Conversation send crossed the ordinary message adapter'); - } - return result; - }, + send: (sessionId, command) => bridge.sessions.send(sessionId, command), stop: (sessionId, target) => bridge.sessions.stop( sessionId, diff --git a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts index 84ad982a41..55ebdb1c9e 100644 --- a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts +++ b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts @@ -29,16 +29,9 @@ export interface WorkHubCoordinationHostAuthority { readonly isCurrent: () => boolean; } -type WorkHubDesktopSessionSourceBridge = Omit & { - send( - sessionId: string, - command: { type: 'send'; turnId: string; text: string }, - ): Promise; -}; - /** Restricts the transitional WorkHub router to the Coordination Session's Host. */ export function scopeWorkHubSessionsToCoordinationHost( - sessions: WorkHubDesktopSessionSourceBridge, + sessions: WorkHubDesktopSessionBridge, coordination: WorkHubCoordinationHostAuthority, createOnCoordinationHost: WorkHubCoordinationHostSessionCreator, ): WorkHubDesktopSessionBridge { @@ -87,27 +80,7 @@ export function scopeWorkHubSessionsToCoordinationHost( }, async send(sessionId: string, command: { type: 'send'; turnId: string; text: string }) { requireTargetHost(sessionId); - const result = await sessions.send(sessionId, command); - if (!result || typeof result !== 'object' || !('ok' in result)) { - throw new Error('WorkHub exact Turn send returned an invalid result'); - } - if (result.ok === false && 'reason' in result && typeof result.reason === 'string') { - return { ok: false as const, reason: result.reason }; - } - if ( - result.ok === true && - !('disposition' in result) && - 'turnId' in result && - typeof result.turnId === 'string' && - (!('steered' in result) || result.steered === true) - ) { - return { - ok: true as const, - turnId: result.turnId, - ...('steered' in result ? { steered: true as const } : {}), - }; - } - throw new Error('WorkHub exact Turn send returned an ordinary message admission'); + return sessions.send(sessionId, command); }, async stop( sessionId: string, From 111688ad3d3001d776b536156c1669901d983232 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 04:36:53 +0800 Subject: [PATCH 07/26] fix: reconcile transient messages from Host outcomes Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 12 +- .../app-shell-first-send-cleanup.test.ts | 20 ++-- .../__tests__/message-queue-ui-state.test.ts | 14 +++ ...me-host-session-execution-ipc-main.test.ts | 14 ++- .../transient-message-projection.test.ts | 23 ++++ ...runtime-host-session-execution-ipc-main.ts | 10 +- .../src/renderer/app-shell-session-events.ts | 7 ++ apps/desktop/src/renderer/app-shell.tsx | 1 + .../renderer/transient-message-projection.ts | 11 +- .../cli/src/__tests__/pi-transcript.test.ts | 51 +++++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 108 ++++++++++++++++-- .../runtime-host-session-driver.test.ts | 48 ++++++-- packages/cli/src/pi-transcript.ts | 59 ++++++++-- packages/cli/src/pi-tui-runner.ts | 13 ++- .../cli/src/runtime-host-session-driver.ts | 28 +++-- packages/cli/src/session-driver.ts | 2 +- 16 files changed, 356 insertions(+), 65 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 867611dbda..d68c639e5d 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -186,7 +186,7 @@ describe('busy-raced send settlement', () => { }); const restoreWindow = installWindow({ sessions: { - send: async (_sessionId: string, command: { messageId: string }) => { + submitMessage: async (_sessionId: string, command: { messageId: string }) => { submittedMessageId = command.messageId; observeSubmit(); await admission; @@ -233,7 +233,7 @@ describe('busy-raced send settlement', () => { const messageState = createMessageState(); const restoreWindow = installWindow({ sessions: { - send: async (_sessionId: string, command: { messageId: string }) => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, disposition: 'steering', messageId: command.messageId, @@ -270,7 +270,7 @@ describe('busy-raced send settlement', () => { const messageState = createMessageState(); const restoreWindow = installWindow({ sessions: { - send: async (_sessionId: string, command: { messageId: string }) => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, disposition: 'turn_started', messageId: command.messageId, @@ -308,7 +308,7 @@ describe('busy-raced send settlement', () => { const messageState = createMessageState(); const restoreWindow = installWindow({ sessions: { - send: async (_sessionId: string, command: { messageId: string }) => { + submitMessage: async (_sessionId: string, command: { messageId: string }) => { // The Host streamed under its own turn id before the IPC response. turnState.setLiveTurnBySession((current) => ({ ...current, @@ -361,7 +361,7 @@ describe('busy-raced send settlement', () => { remove: async (sessionId: string) => { removed.push(sessionId); }, - send: async (_sessionId: string, command: { messageId: string }) => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, disposition: 'steering', messageId: command.messageId, @@ -405,7 +405,7 @@ describe('busy-raced send settlement', () => { create: async () => ({ id: 'session-new' }), }, sessions: { - send: async (_sessionId: string, command: { messageId: string }) => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, disposition: 'turn_started', messageId: command.messageId, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 9127e1f386..40b6cceb94 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -123,7 +123,7 @@ describe('composer first-send cleanup', () => { let sends = 0; const restoreWindow = installWindow({ sessions: { - send: async () => { + submitMessage: async () => { sends += 1; return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; }, @@ -162,7 +162,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -212,7 +212,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -248,7 +248,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -293,7 +293,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -329,7 +329,7 @@ describe('composer first-send cleanup', () => { newTasks: { create: async () => ({ id: 'session-1' }) }, sessions: { // What `prepareSkillInvocation` does when Skill discovery fails. - send: async () => Promise.reject(new Error('Skill discovery failed')), + submitMessage: async () => Promise.reject(new Error('Skill discovery failed')), remove: async (sessionId: string) => { removed.push(sessionId); }, @@ -350,7 +350,7 @@ describe('composer first-send cleanup', () => { const restoreWindow = installWindow({ newTasks: { create: async () => ({ id: 'session-1' }) }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -378,7 +378,7 @@ describe('composer first-send cleanup', () => { const removed: string[] = []; const restoreWindow = installWindow({ sessions: { - send: async () => Promise.reject(new Error('Skill discovery failed')), + submitMessage: async () => Promise.reject(new Error('Skill discovery failed')), remove: async (sessionId: string) => { removed.push(sessionId); }, @@ -415,7 +415,7 @@ describe('composer first-send cleanup', () => { const transcriptRangeRef = { current: transcript as DesktopTranscriptRangeController | undefined }; const restoreWindow = installWindow({ sessions: { - send: async () => { + submitMessage: async () => { order.push('send'); return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; }, @@ -462,7 +462,7 @@ function deferred() { describe('composer send failure feedback', () => { const readinessFailure = () => ({ sessions: { - send: async () => + submitMessage: async () => Promise.reject(new Error('NO_REAL_CONNECTION:missing_api_key: no ready connection')), remove: async () => undefined, }, diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index fc157aec46..9d1a466c62 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -25,6 +25,7 @@ import { createAppShellSessionUiStateController } from '../../renderer/app-shell test('queue_update events drive the independent desktop queue projection', () => { const controller = createAppShellSessionUiStateController(); const transientMessages: unknown[] = []; + const removedTransientMessageIds: string[] = []; const handlers = createAppShellSessionEventHandlers({ uiLocale: 'zh', activeIdRef: { current: 'session-1' }, @@ -35,6 +36,8 @@ test('queue_update events drive the independent desktop queue projection', () => setInteractionBySession: controller.setInteractionBySession, setMessageQueueBySession: controller.setMessageQueueBySession, projectTransientMessage: (_sessionId, message) => transientMessages.push(message), + removeTransientMessage: (_sessionId, messageId) => + removedTransientMessageIds.push(messageId), showModelSetupToast() {}, toastApi: { error() {} }, }); @@ -118,6 +121,17 @@ test('queue_update events drive the independent desktop queue projection', () => followup: [], }); assert.equal(controller.getState().messageQueueBySession['session-1'], undefined); + assert.deepEqual(removedTransientMessageIds, []); + + handlers.handleEvent('session-1', { + type: 'message_admission', + id: 'retracted-message-next', + turnId: 'turn-1', + ts: 3, + messageId: 'message-next', + outcome: 'retracted', + }); + assert.deepEqual(removedTransientMessageIds, ['message-next']); }); test('complete events deliver the durable context compaction outcome to Desktop', () => { 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 6975dfbc6b..717c43ab90 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 @@ -871,14 +871,18 @@ 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", { + assert.deepEqual( + await ipc.invoke("sessions:send", "session-1", { type: "send", - turnId: "turn-unknown", + messageId: "turn-unknown", text: "ordinary chat keeps the existing failure contract", }), - (error: unknown) => - error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown', + { + ok: false, + reason: "outcome_unknown", + messageId: "turn-unknown", + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }, ); assert.deepEqual( await ipc.invoke("sessions:send", "side-session", { diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index 4ab798449f..9ff7acf6b9 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -67,6 +67,29 @@ test('canonicalizing one send does not hide a later transient send', () => { assert.deepEqual([...pending.keys()], ['message-2']); }); +test('keeps a transient message in submission order inside a sparse durable tail', () => { + const pending = new Map([[transient.id, transient]]); + const durable: StoredMessage[] = [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + { + type: 'assistant', + id: 'later-assistant', + turnId: 'turn-1', + ts: 3, + text: 'after', + modelId: 'model-1', + }, + ]; + + const projected = reconcileTransientMessages(pending, durable); + + assert.deepEqual(projected.map((message) => message.id), [ + 'old-user', + 'message-1', + 'later-assistant', + ]); +}); + test('keeps a transient message out of a sparse historical range', () => { const live = { ...transient, id: 'message-live', turnId: 'message-live', text: 'latest prompt' }; const old = { ...transient, id: 'message-old', turnId: 'turn-old', ts: 1, text: 'old prompt' }; 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 e6fc574add..22ec921ba4 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 @@ -327,13 +327,21 @@ export function registerRuntimeHostSessionExecutionIpc( (command.skillIds?.length ?? 0) === 0 && command.turnOrchestration === undefined ) { - const submitted = await deps.client.submitMessage({ + const submitted = await submitMessageWithReconnect(deps.client, { sessionId, messageId: command.messageId, content: startInput.content, placement: 'current_turn', }); const skillInvocation = { loaded: [], failed: [], receipts: [] }; + if (!submitted) { + return { + ok: false as const, + reason: 'outcome_unknown' as const, + messageId: command.messageId, + skillInvocation, + }; + } if (submitted.disposition === 'turn_started') { deps.emitSessionsChanged('status-change', sessionId, { turnId: submitted.turnId, diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index b8c9f04793..4aa0536b6b 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -85,6 +85,7 @@ export function createAppShellSessionEventHandlers(options: { setInteractionBySession: StateUpdater; setMessageQueueBySession?: StateUpdater>; projectTransientMessage?: (sessionId: string, message: StoredMessage) => void; + removeTransientMessage?: (sessionId: string, messageId: string) => void; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; @@ -113,6 +114,7 @@ export function createAppShellSessionEventHandlers(options: { setInteractionBySession, setMessageQueueBySession, projectTransientMessage, + removeTransientMessage, onInteractionChanged, onExecutionBoundaryChanged, onContextCompactionOutcome, @@ -318,6 +320,11 @@ export function createAppShellSessionEventHandlers(options: { }; }); break; + case 'message_admission': + if (event.outcome === 'retracted') { + removeTransientMessage?.(sessionId, event.messageId); + } + break; case 'text_complete': void refreshMessages(sessionId, { requiredAssistantMessageId: event.messageId }).catch(() => false); break; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 3782858823..836778f949 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2201,6 +2201,7 @@ function AppShellContent({ setInteractionBySession, setMessageQueueBySession, projectTransientMessage: addTransientMessage, + removeTransientMessage, displayBatch: sessionDisplayBatch, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/transient-message-projection.ts index 231687c8c9..da2db9c4d3 100644 --- a/apps/desktop/src/renderer/transient-message-projection.ts +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -31,8 +31,11 @@ export function reconcileTransientMessages( ): StoredMessage[] { for (const message of durable) transient.delete(message.id); if (transient.size === 0 || options.includeTransient === false) return [...durable]; - return [ - ...durable, - ...[...transient.values()].sort((left, right) => left.ts - right.ts), - ]; + const projected = [...durable]; + for (const message of [...transient.values()].sort((left, right) => left.ts - right.ts)) { + const nextIndex = projected.findIndex((candidate) => candidate.ts > message.ts); + if (nextIndex < 0) projected.push(message); + else projected.splice(nextIndex, 0, message); + } + return projected; } diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 6870600503..f8c4ec8f9c 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -315,6 +315,38 @@ describe('Maka Pi TUI transcript', () => { ]); }); + test('keeps a transient user row before later durable output in a sparse replacement', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + ]); + appendUserPrompt(state, 'send now', 'message-1', true); + state.entries.push({ kind: 'assistant', messageId: 'later-assistant', text: 'after' }); + + replaceTranscriptWithStoredMessages( + state, + [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + { + type: 'assistant', + id: 'later-assistant', + turnId: 'turn-1', + ts: 3, + text: 'after', + modelId: 'model-1', + }, + ], + { preserveTransientMessages: true }, + ); + + assert.deepEqual( + state.entries.map((entry) => + entry.kind === 'user' || entry.kind === 'assistant' ? entry.messageId : entry.kind, + ), + ['old-user', 'message-1', 'later-assistant'], + ); + }); + test('reconciles a transient user row by messageId when durable history arrives', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'send now', 'message-1', true); @@ -328,6 +360,25 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(state.entries, [{ kind: 'user', messageId: 'message-1', text: 'send now' }]); }); + test('removes only the transient row named by a retracted admission', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'keep this', 'message-kept', true); + appendUserPrompt(state, 'take this back', 'message-retracted', true); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'message_admission', + messageId: 'message-retracted', + outcome: 'retracted', + }), + ); + + assert.deepEqual(state.entries, [ + { kind: 'user', messageId: 'message-kept', text: 'keep this', transient: true }, + ]); + }); + test('reconciles a live steering event into its transient message position', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'send now', 'message-1', true); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index b459711955..e37834b823 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2008,6 +2008,73 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('removes an idle transient message after a definite Host rejection', async () => { + const terminal = new FakeTerminal(); + const driver = new SteeringTurnDriver(); + driver.nextSubmitError = new Error('Session is archived'); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('do not leave a ghost row'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Session is archived'), + ); + assert.equal( + plainTerminalOutput(terminal.screenOutput()).includes('do not leave a ghost row'), + false, + ); + + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + + test('keeps an admitted message when a Host-started turn attaches from a sparse tail', async () => { + const terminal = new FakeTerminal(); + const driver = new SteeringTurnDriver(); + driver.startedTurnMessages = [ + { + type: 'assistant', + id: 'later-assistant', + turnId: 'turn-started', + ts: 2, + text: 'Later durable output', + modelId: 'model-1', + }, + ]; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('keep the accepted identity'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Later durable output'), + ); + assert.match(plainTerminalOutput(terminal.screenOutput()), /keep the accepted identity/); + + terminal.input('\x1b'); + terminal.input('\x1b'); + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + test('opens /transcript during a running turn instead of steering it', async () => { const terminal = new FakeTerminal(); const driver = new SteeringTurnDriver(); @@ -2291,8 +2358,8 @@ describe('Maka Pi TUI runner', () => { terminal.input('\x1b'); // interrupt await waitFor(() => terminal.progressStates.at(-1) === false); // The authoritative queue is cleared and only the followup comes back as - // a draft. Both already-sent message rows keep their stable identities; - // interrupting delivery must not make either presentation disappear. + // a draft. The consumed steering row remains for canonical reconciliation; + // the retracted followup row is removed while its text moves to the editor. await waitFor(() => { const screen = plainTerminalOutput(terminal.screenOutput()); return ( @@ -6655,10 +6722,12 @@ class SteeringTurnDriver implements MakaSessionDriver { readonly steered: string[] = []; readonly queuedMessages: string[] = []; readonly turnOrchestrations: Array = []; + nextSubmitError: Error | undefined; + startedTurnMessages: StoredMessage[] = []; retractCalls = 0; rewindTargets: RewindTarget[] = []; - private steering: string[] = []; - private followup: string[] = []; + private steering: Array<{ messageId: string; text: string }> = []; + private followup: Array<{ messageId: string; text: string }> = []; private pendingEvents: SessionEvent[] = []; private wakeTurn: (() => void) | null = null; private turnOpen = false; @@ -6699,8 +6768,8 @@ class SteeringTurnDriver implements MakaSessionDriver { id: `queue-update-${this.eventSeq}`, turnId: 'turn-1', ts: this.eventSeq, - steering: [...this.steering], - followup: [...this.followup], + steering: this.steering.map((entry) => entry.text), + followup: this.followup.map((entry) => entry.text), }); this.wakeTurn?.(); this.wakeTurn = null; @@ -6722,12 +6791,17 @@ class SteeringTurnDriver implements MakaSessionDriver { } async submitMessage(text: string, options: MakaSubmitMessageOptions) { + if (this.nextSubmitError) { + const error = this.nextSubmitError; + this.nextSubmitError = undefined; + throw error; + } if (!this.turnOpen) { const turn = await this.preparePrompt(text); queueMicrotask(() => this.startedTurnListener?.({ ...turn, - messages: [], + messages: this.startedTurnMessages, summary: fakeSessionSummary(turn.sessionId), }), ); @@ -6735,10 +6809,10 @@ class SteeringTurnDriver implements MakaSessionDriver { } if (options.placement === 'current_turn') { this.steered.push(text); - this.steering.push(text); + this.steering.push({ messageId: options.messageId, text }); } else { this.queuedMessages.push(text); - this.followup.push(text); + this.followup.push({ messageId: options.messageId, text }); } this.emitQueueUpdate(); return { @@ -6757,10 +6831,24 @@ class SteeringTurnDriver implements MakaSessionDriver { async retractQueued(): Promise { this.retractCalls += 1; - const joined = [...this.steering, ...this.followup].join('\n\n'); + const retracted = [...this.steering, ...this.followup]; + const joined = retracted.map((entry) => entry.text).join('\n\n'); this.steering = []; this.followup = []; this.emitQueueUpdate(); + for (const entry of retracted) { + this.eventSeq += 1; + this.pendingEvents.push({ + type: 'message_admission', + id: `message-retracted-${this.eventSeq}`, + turnId: 'turn-1', + ts: this.eventSeq, + messageId: entry.messageId, + outcome: 'retracted', + }); + } + this.wakeTurn?.(); + this.wakeTurn = null; return joined; } 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 effe47069d..b947abcfb4 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1170,6 +1170,33 @@ describe('Runtime Host Maka Session driver', () => { }); }); + test('keeps an unknown message admission available for transcript reconciliation', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + connection.messageSubmitOutcomes.push( + new RuntimeHostOperationError( + 'turn.message.submit', + 'outcome_unknown', + 'Message disposition cannot be proven in this Host Epoch', + ), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + assert.deepEqual( + await driver.submitMessage!('Keep this visible', { + messageId: 'message-unknown', + placement: 'current_turn', + }), + { messageId: 'message-unknown', disposition: 'outcome_unknown' }, + ); + }); + test('projects the acknowledgement that releases a question answered through the Host', async () => { const subscription = new FakeSubscription( continuitySnapshot({ interactions: { pending: [pendingQuestion()] } }), @@ -1728,6 +1755,7 @@ class FakeConnection { readonly goalControlOutcomes: Array = []; /** Scripted goal.query results, shifted per call; defaults to null (no goal). */ readonly goalQueryResults: Array = []; + readonly messageSubmitOutcomes: Array | Error> = []; readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -1832,13 +1860,19 @@ class FakeConnection { : operation === 'session.execution_boundary.query' ? this.executionBoundary : operation === 'turn.message.submit' - ? { - disposition: - (input as OperationInput<'turn.message.submit'>).placement === 'next_turn' - ? 'followup' - : 'steering', - queueRevision: 2, - } + ? (() => { + const outcome = this.messageSubmitOutcomes.shift(); + if (outcome instanceof Error) throw outcome; + return ( + outcome ?? { + disposition: + (input as OperationInput<'turn.message.submit'>).placement === 'next_turn' + ? 'followup' + : 'steering', + queueRevision: 2, + } + ); + })() : operation === 'queue.retract' ? { hostEpoch: 'host-1', diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 78755aa2d8..ed24835236 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -339,18 +339,33 @@ export function replaceTranscriptWithStoredMessages( options: { preserveTransientMessages?: boolean } = {}, ): void { const durableMessageIds = new Set(messages.map((message) => message.id)); + const durableEntries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + const durableEntryIds = new Set(durableEntries.map(transcriptEntryId).filter(Boolean)); const transientEntries = options.preserveTransientMessages - ? state.entries.filter( - (entry): entry is Extract => - entry.kind === 'user' && - entry.transient === true && - !durableMessageIds.has(entry.messageId), - ) + ? state.entries.flatMap((entry, index) => { + if ( + entry.kind !== 'user' || + entry.transient !== true || + durableMessageIds.has(entry.messageId) + ) { + return []; + } + const nextDurableId = state.entries + .slice(index + 1) + .map(transcriptEntryId) + .find((messageId) => messageId !== undefined && durableEntryIds.has(messageId)); + return [{ entry, nextDurableId }]; + }) : []; - state.entries = [ - ...foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)), - ...transientEntries, - ]; + const unanchoredTransientEntries: MakaPiTranscriptEntry[] = []; + for (const transient of transientEntries) { + const nextIndex = transient.nextDurableId + ? durableEntries.findIndex((entry) => transcriptEntryId(entry) === transient.nextDurableId) + : -1; + if (nextIndex < 0) unanchoredTransientEntries.push(transient.entry); + else durableEntries.splice(nextIndex, 0, transient.entry); + } + state.entries = [...unanchoredTransientEntries, ...durableEntries]; clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; @@ -371,6 +386,19 @@ export function replaceTranscriptWithStoredMessages( } } +function transcriptEntryId(entry: MakaPiTranscriptEntry): string | undefined { + switch (entry.kind) { + case 'user': + case 'assistant': + case 'thinking': + return entry.messageId; + case 'tool': + return entry.toolUseId; + default: + return undefined; + } +} + /** * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. @@ -734,6 +762,17 @@ export function applyMakaSessionEventToTranscript( appendUserPrompt(state, event.content.displayText ?? event.content.text, event.messageId); break; + case 'message_admission': + if (event.outcome === 'retracted') { + state.entries = state.entries.filter( + (entry) => + entry.kind !== 'user' || + entry.transient !== true || + entry.messageId !== event.messageId, + ); + } + break; + case 'queue_update': // Authoritative snapshot from the runtime; mirror it for the pending bar. state.steering = [...event.steering]; diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 0417ad7403..887144857a 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1170,7 +1170,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (superseded()) return; if (authoritativeAttachedTurn) { adoptSessionMetadata(authoritativeAttachedTurn.summary); - replaceTranscript(authoritativeAttachedTurn.messages); + replaceTranscript(authoritativeAttachedTurn.messages, { + preserveTransientMessages: true, + }); shellRunHydration.reset(); if (input.listShellRunUpdates) { await shellRunHydration.hydrate(authoritativeAttachedTurn.sessionId); @@ -1238,6 +1240,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // here as "ended without completion" — never report that against the // adopted Session. if (superseded()) return; + if ( + request.kind === 'external' && + request.turnOrchestration === undefined && + input.driver.submitMessage && + optimisticUserEntry?.kind === 'user' + ) { + removeTransientUserMessage(optimisticUserEntry.messageId); + optimisticUserEntry = undefined; + } appendTurnFailureToTranscript(state, error); attention.attentionNeeded(); shellRunElapsedTicker.sync(); diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index acde7b27c7..4ebfbe4112 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -395,16 +395,24 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#assertCurrentSession(sessionId, sessionGeneration); this.#adoptLoadedConfiguration(configuration); const modelText = options.modelText ?? text; - const result = await this.#request('turn.message.submit', { - originHostEpoch: this.#connection.hostEpoch, - sessionId, - messageId: options.messageId, - content: { - text: modelText, - ...(modelText === text ? {} : { displayText: text }), - }, - placement: options.placement, - }); + let result; + try { + result = await this.#request('turn.message.submit', { + originHostEpoch: this.#connection.hostEpoch, + sessionId, + messageId: options.messageId, + content: { + text: modelText, + ...(modelText === text ? {} : { displayText: text }), + }, + placement: options.placement, + }); + } catch (error) { + if (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') { + return { messageId: options.messageId, disposition: 'outcome_unknown' }; + } + throw error; + } return { messageId: options.messageId, disposition: result.disposition }; } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 3ef60a0a1b..4a62f8e419 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -98,7 +98,7 @@ export interface MakaSubmitMessageOptions { export interface MakaMessageAdmission { messageId: string; - disposition: 'steering' | 'followup' | 'turn_started'; + disposition: 'steering' | 'followup' | 'turn_started' | 'outcome_unknown'; } export class SkillInvocationBlockedError extends Error { From 835445c638e96d808153b75e9b8f4d1853793e29 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 05:23:41 +0800 Subject: [PATCH 08/26] fix(cli): preserve canonical message projection through recovery Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 68 ++++++++++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 90 ++++++++++++++++--- .../runtime-host-session-driver.test.ts | 39 +++++++- packages/cli/src/pi-transcript.ts | 38 ++++++-- packages/cli/src/pi-tui-runner.ts | 23 +++-- .../cli/src/runtime-host-session-driver.ts | 16 +++- packages/cli/src/session-driver.ts | 7 +- 7 files changed, 251 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index f8c4ec8f9c..6dd7907017 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -347,6 +347,74 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('keeps an unanchored transient user row after existing durable history', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + { + type: 'assistant', + id: 'old-assistant', + turnId: 'old-turn', + ts: 2, + text: 'answer', + modelId: 'model-1', + }, + ]); + appendUserPrompt(state, 'send now', 'message-1', true); + + replaceTranscriptWithStoredMessages( + state, + [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + { + type: 'assistant', + id: 'old-assistant', + turnId: 'old-turn', + ts: 2, + text: 'answer', + modelId: 'model-1', + }, + ], + { preserveTransientMessages: true }, + ); + + assert.deepEqual( + state.entries.map((entry) => + entry.kind === 'user' || entry.kind === 'assistant' ? entry.messageId : entry.kind, + ), + ['old-user', 'old-assistant', 'message-1'], + ); + }); + + test('keeps a leading transient row before an entirely new durable replacement', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'current prompt', 'message-current', true); + state.entries.push({ kind: 'assistant', messageId: 'old-assistant', text: 'old live output' }); + + replaceTranscriptWithStoredMessages( + state, + [ + { type: 'user', id: 'next-user', turnId: 'next-turn', ts: 3, text: 'next prompt' }, + { + type: 'assistant', + id: 'next-assistant', + turnId: 'next-turn', + ts: 4, + text: 'next answer', + modelId: 'model-1', + }, + ], + { preserveTransientMessages: true }, + ); + + assert.deepEqual( + state.entries.map((entry) => + entry.kind === 'user' || entry.kind === 'assistant' ? entry.messageId : entry.kind, + ), + ['message-current', 'next-user', 'next-assistant'], + ); + }); + test('reconciles a transient user row by messageId when durable history arrives', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'send now', 'message-1', true); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index e37834b823..74402f50f0 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2240,6 +2240,43 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('Alt+Up removes the exact transient rows without a subscription retraction event', async () => { + const terminal = new FakeTerminal(); + const driver = new SteeringTurnDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('start the work'); + terminal.input('\r'); + await waitFor(() => terminal.progressStates.at(-1) === true); + + terminal.input('take this back'); + terminal.input('\x1b\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('take this back')); + + terminal.input('\x1b[1;3A'); + await waitFor(() => driver.retractCalls === 1); + await waitFor(() => { + const screen = plainTerminalOutput(terminal.screenOutput()); + return screen.includes('take this back') && !screen.includes('Queued: take this back'); + }); + terminal.input('\x1b'); + terminal.input('\x1b'); + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('\x03'); + await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('take this back')); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + test('Alt+Up in the enqueue tick retracts from the authority, not the lagging mirror', async () => { // Round-6 R2: the enqueue outcome arrives synchronously but the mirror // updates only when the queue_update event is consumed. An Alt+Up in @@ -2417,6 +2454,41 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('Enter during Host admission keeps the second prompt in the editor', async () => { + const terminal = new FakeTerminal(); + const driver = new SteeringTurnDriver(); + const admission = deferred(); + driver.submitGate = admission.promise; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('first prompt'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('first prompt')); + + terminal.input('second prompt'); + terminal.input('\r'); + terminal.input('z'); + await waitFor(() => editorInputText(terminal) === 'second promptz'); + + admission.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('\x1b'); + terminal.input('\x1b'); + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('\x03'); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + test('input during the interrupt convergence window stays in the editor and opens no turn', async () => { const terminal = new FakeTerminal(); const driver = new SlowStopDriver(); // stop() returns but the turn keeps running @@ -6723,6 +6795,7 @@ class SteeringTurnDriver implements MakaSessionDriver { readonly queuedMessages: string[] = []; readonly turnOrchestrations: Array = []; nextSubmitError: Error | undefined; + submitGate: Promise | undefined; startedTurnMessages: StoredMessage[] = []; retractCalls = 0; rewindTargets: RewindTarget[] = []; @@ -6791,6 +6864,8 @@ class SteeringTurnDriver implements MakaSessionDriver { } async submitMessage(text: string, options: MakaSubmitMessageOptions) { + await this.submitGate; + this.submitGate = undefined; if (this.nextSubmitError) { const error = this.nextSubmitError; this.nextSubmitError = undefined; @@ -6829,27 +6904,16 @@ class SteeringTurnDriver implements MakaSessionDriver { }; } - async retractQueued(): Promise { + async retractQueued(): Promise<{ text: string; messageIds: readonly string[] }> { this.retractCalls += 1; const retracted = [...this.steering, ...this.followup]; const joined = retracted.map((entry) => entry.text).join('\n\n'); this.steering = []; this.followup = []; this.emitQueueUpdate(); - for (const entry of retracted) { - this.eventSeq += 1; - this.pendingEvents.push({ - type: 'message_admission', - id: `message-retracted-${this.eventSeq}`, - turnId: 'turn-1', - ts: this.eventSeq, - messageId: entry.messageId, - outcome: 'retracted', - }); - } this.wakeTurn?.(); this.wakeTurn = null; - return joined; + return { text: joined, messageIds: retracted.map((entry) => entry.messageId) }; } // Simulates the runtime consuming the steering queue at a step boundary 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 b947abcfb4..5b9badf431 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -28,7 +28,11 @@ import type { DirectRequestOperationKey, RuntimeHostSessionSubscription, } from '@maka/runtime-host/client'; -import { RuntimeHostOperationError, RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, + RuntimeHostSubscriptionError, +} from '@maka/runtime-host/client'; import { SESSION_CONTINUITY_SCHEMA_VERSION, type GoalProjection, @@ -1107,7 +1111,10 @@ describe('Runtime Host Maka Session driver', () => { }), { messageId: 'message-1', disposition: 'followup' }, ); - assert.equal(await driver.retractQueued!(), 'Later'); + assert.deepEqual(await driver.retractQueued!(), { + text: 'Later', + messageIds: ['message-1'], + }); assert.deepEqual( connection.requests.filter( (request) => @@ -1197,6 +1204,34 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('keeps a dispatched interrupted admission available for transcript reconciliation', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + connection.messageSubmitOutcomes.push( + new RuntimeHostRequestInterruptedError( + 'turn.message.submit', + 'command', + 'dispatched', + 'connection_lost', + ), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + assert.deepEqual( + await driver.submitMessage!('Keep this visible', { + messageId: 'message-interrupted', + placement: 'current_turn', + }), + { messageId: 'message-interrupted', disposition: 'outcome_unknown' }, + ); + }); + test('projects the acknowledgement that releases a question answered through the Host', async () => { const subscription = new FakeSubscription( continuitySnapshot({ interactions: { pending: [pendingQuestion()] } }), diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index ed24835236..ed8bf4627d 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -350,22 +350,50 @@ export function replaceTranscriptWithStoredMessages( ) { return []; } + const priorEntries = state.entries.slice(0, index); const nextDurableId = state.entries .slice(index + 1) .map(transcriptEntryId) .find((messageId) => messageId !== undefined && durableEntryIds.has(messageId)); - return [{ entry, nextDurableId }]; + const previousDurableId = priorEntries + .map(transcriptEntryId) + .reverse() + .find((messageId) => messageId !== undefined && durableEntryIds.has(messageId)); + const hadPrecedingDurable = priorEntries.some( + (candidate) => + !(candidate.kind === 'user' && candidate.transient === true) && + transcriptEntryId(candidate) !== undefined, + ); + return [{ entry, nextDurableId, previousDurableId, hadPrecedingDurable }]; }) : []; - const unanchoredTransientEntries: MakaPiTranscriptEntry[] = []; + const transientEntriesByBoundary = new Map(); for (const transient of transientEntries) { const nextIndex = transient.nextDurableId ? durableEntries.findIndex((entry) => transcriptEntryId(entry) === transient.nextDurableId) : -1; - if (nextIndex < 0) unanchoredTransientEntries.push(transient.entry); - else durableEntries.splice(nextIndex, 0, transient.entry); + const previousIndex = transient.previousDurableId + ? durableEntries.findIndex( + (entry) => transcriptEntryId(entry) === transient.previousDurableId, + ) + : -1; + const boundary = + nextIndex >= 0 + ? nextIndex + : previousIndex >= 0 + ? previousIndex + 1 + : transient.hadPrecedingDurable + ? durableEntries.length + : 0; + const grouped = transientEntriesByBoundary.get(boundary); + if (grouped) grouped.push(transient.entry); + else transientEntriesByBoundary.set(boundary, [transient.entry]); + } + state.entries = []; + for (let boundary = 0; boundary <= durableEntries.length; boundary += 1) { + state.entries.push(...(transientEntriesByBoundary.get(boundary) ?? [])); + if (boundary < durableEntries.length) state.entries.push(durableEntries[boundary]!); } - state.entries = [...unanchoredTransientEntries, ...durableEntries]; clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 887144857a..476a4b33a8 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -850,8 +850,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // connection where both calls are asynchronous. void (async () => { await settlePendingEnqueues(); - const retracted = (await input.driver.retractQueued?.()) ?? ''; - refillEditorFromQueues(retracted); + const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [] }; + acceptRetraction(retracted); requestRender(); await input.driver.stop(); })().catch((error) => { @@ -909,6 +909,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (index >= 0) state.entries.splice(index, 1); }; + const acceptRetraction = (retracted: { text: string; messageIds: readonly string[] }) => { + for (const messageId of retracted.messageIds) removeTransientUserMessage(messageId); + refillEditorFromQueues(retracted.text); + }; + // Enter during a turn asks the Host to place the message at the current // step boundary. The Host alone decides whether it steers or starts a // successor Turn if the previous Turn settled during admission. @@ -985,8 +990,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const retractQueuedMessages = () => { void (async () => { await settlePendingEnqueues(); - const retracted = (await input.driver.retractQueued?.()) ?? ''; - refillEditorFromQueues(retracted); + const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [] }; + acceptRetraction(retracted); requestRender(); })().catch(reportError); }; @@ -1128,13 +1133,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.disableSubmit = false; setTaskbarProgress(true); attention.promptTurnStarted(); + } else { + // The editor clears before invoking onSubmit. While Host admission is + // unresolved, disable submission so a second Enter cannot erase a draft + // that the busy gate would then refuse. + editor.disableSubmit = true; } requestRender(); let permissionAlerted = false; let optimisticUserEntry: (typeof state.entries)[number] | undefined; const finishTurnUi = () => { - if (!ownsTurnUi) return; + if (!ownsTurnUi) { + editor.disableSubmit = false; + return; + } turnRunning = false; turnStartedAt = undefined; stopTurnElapsedTicker(); diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 4ebfbe4112..2a67acc2c4 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -56,6 +56,7 @@ import { readRuntimeHostResources, readRuntimeHostSessions, RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, } from '@maka/runtime-host/client'; import { InteractionPendingSnapshot, @@ -79,6 +80,7 @@ import type { MakaSideConversationCloseResult, MakaSideConversationOpenResult, MakaMessageAdmission, + MakaRetractedMessages, MakaPreparePromptOptions, MakaPreparedSessionTurn, MakaSessionDriver, @@ -408,7 +410,10 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { placement: options.placement, }); } catch (error) { - if (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') { + if ( + (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') || + (error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched') + ) { return { messageId: options.messageId, disposition: 'outcome_unknown' }; } throw error; @@ -416,14 +421,17 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return { messageId: options.messageId, disposition: result.disposition }; } - async retractQueued(): Promise { - if (!this.#sessionId) return ''; + async retractQueued(): Promise { + if (!this.#sessionId) return { text: '', messageIds: [] }; const result = await this.#request('queue.retract', { originHostEpoch: this.#connection.hostEpoch, sessionId: this.#sessionId, retractId: this.#newId(), }); - return result.retracted.map((entry) => entry.content.text).join('\n\n'); + return { + text: result.retracted.map((entry) => entry.content.text).join('\n\n'), + messageIds: result.retracted.map((entry) => entry.messageId), + }; } async respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise { diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 4a62f8e419..aa541ee466 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -101,6 +101,11 @@ export interface MakaMessageAdmission { disposition: 'steering' | 'followup' | 'turn_started' | 'outcome_unknown'; } +export interface MakaRetractedMessages { + text: string; + messageIds: readonly string[]; +} + export class SkillInvocationBlockedError extends Error { constructor(readonly skillInvocation: SkillInvocationResult) { super('Explicit Skill invocation could not be resolved'); @@ -118,7 +123,7 @@ export interface MakaSessionDriver { submitMessage?(text: string, options: MakaSubmitMessageOptions): Promise; compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; - retractQueued?(): Promise; + retractQueued?(): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; setModel(model: string, connectionSlug?: string): Promise; From d46c761d9450e52a7e1619673f2722c7e2cab153 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 05:24:02 +0800 Subject: [PATCH 09/26] fix(desktop): keep transient messages outside Turn authority Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 27 ++++++++++++ ...me-host-session-execution-ipc-main.test.ts | 33 ++++++++++++++ .../main/__tests__/streaming-handoff.test.ts | 22 ++++++++++ .../transient-message-projection.test.ts | 32 +++++++++----- ...runtime-host-session-execution-ipc-main.ts | 3 +- apps/desktop/src/preload/bridge-contract.d.ts | 17 +++++--- apps/desktop/src/preload/preload.ts | 34 +++++++++------ .../src/renderer/app-shell-chat-actions.ts | 14 +++--- .../src/renderer/app-shell-session-events.ts | 5 ++- apps/desktop/src/renderer/app-shell.tsx | 11 ++++- .../renderer/transient-message-projection.ts | 23 +++++----- .../use-app-shell-session-workspace.ts | 43 ++++++++++++------- packages/ui/src/chat-turn.tsx | 28 ++++++++++++ packages/ui/src/chat-view.tsx | 12 +++++- 14 files changed, 235 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index d68c639e5d..465c6dd791 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -172,6 +172,33 @@ describe('busy-raced send settlement', () => { } }); + it('keeps a Follow Up visible when Host admission outcome is unknown', async () => { + const transient = new Map(); + const restoreWindow = installWindow({ + sessions: { + enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => ({ + kind: 'outcome_unknown' as const, + messageId: command.messageId, + }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + + await actions.enqueueMessage('session-a', 'do this next', 'next_turn'); + + assert.equal(transient.size, 1); + assert.equal([...transient.values()][0]?.type, 'user'); + } finally { + restoreWindow(); + } + }); + it('shows one stable local message before Host admission settles', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const transient = new Map(); 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 717c43ab90..2fab214e82 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 @@ -1124,6 +1124,39 @@ test("queues explicit Desktop follow-ups", async () => { ]); }); +test('keeps an unknown Desktop follow-up admission available for reconciliation', async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => session(), + submitMessage: async () => { + throw new RuntimeHostOperationError( + 'turn.message.submit', + 'outcome_unknown', + 'Message disposition cannot be proven in this Host Epoch', + ); + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + }, + ipc, + ); + + assert.deepEqual( + await ipc.invoke('sessions:enqueue', 'session-1', 'next_turn', { + messageId: 'followup-unknown', + text: 'keep this visible', + }), + { kind: 'outcome_unknown', messageId: 'followup-unknown' }, + ); +}); + test("routes per-entry queue mutations to the Runtime Host", async () => { const calls: unknown[] = []; let sequence = 0; diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index b35a635ce5..2fd10913d2 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -90,6 +90,28 @@ function renderLiveTurn(liveTurn: LiveTurnProjection): string { } describe('single live-turn handoff', () => { + it('renders a transient user message without manufacturing a Turn', () => { + const markup = renderWithLocale(createElement(ChatView, { + activeSession: { + id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'active', backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask', + }, + messages: [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + ], + transientMessages: [ + { type: 'user', id: 'message-pending', turnId: 'message-pending', ts: 2, text: 'send now' }, + ], + scrollBehavior: 'smooth', + onNew() {}, + } satisfies Parameters[0])); + + assert.equal((markup.match(/data-virtual-turn-id=/g) ?? []).length, 1); + assert.match(markup, /data-transient-message-id="message-pending"/); + assert.match(markup, />send now { const markup = renderLiveTurn({ turnId: 'turn-1', diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index 9ff7acf6b9..3d5c8c1ab0 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -22,7 +22,7 @@ import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import { reconcileTransientMessages } from '../../renderer/transient-message-projection.js'; -const transient: StoredMessage = { +const transient: Extract = { type: 'user', id: 'message-1', turnId: 'turn-1', @@ -38,12 +38,28 @@ test('keeps a transient message through sparse transcript replacement', () => { assert.equal(pending.has(transient.id), true); }); +test('updates a transient message without treating its previous render as canonical', () => { + const pending = new Map([[transient.id, transient]]); + const firstProjection = reconcileTransientMessages(pending, []); + const updated = { + ...transient, + quotes: [{ text: 'quoted context' }], + }; + pending.set(updated.id, updated); + + const secondProjection = reconcileTransientMessages(pending, []); + + assert.deepEqual(firstProjection, [transient]); + assert.deepEqual(secondProjection, [updated]); + assert.equal(pending.has(updated.id), true); +}); + test('replaces a transient message by canonical message id exactly once', () => { const pending = new Map([[transient.id, transient]]); const canonical = { ...transient, ts: 3, text: 'canonical send' }; const projected = reconcileTransientMessages(pending, [canonical]); - assert.deepEqual(projected, [canonical]); + assert.deepEqual(projected, []); assert.equal(pending.size, 0); }); @@ -63,11 +79,11 @@ test('canonicalizing one send does not hide a later transient send', () => { const projected = reconcileTransientMessages(pending, [canonical]); - assert.deepEqual(projected, [canonical, second]); + assert.deepEqual(projected, [second]); assert.deepEqual([...pending.keys()], ['message-2']); }); -test('keeps a transient message in submission order inside a sparse durable tail', () => { +test('keeps transient messages ordered independently from a sparse durable tail', () => { const pending = new Map([[transient.id, transient]]); const durable: StoredMessage[] = [ { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, @@ -83,11 +99,7 @@ test('keeps a transient message in submission order inside a sparse durable tail const projected = reconcileTransientMessages(pending, durable); - assert.deepEqual(projected.map((message) => message.id), [ - 'old-user', - 'message-1', - 'later-assistant', - ]); + assert.deepEqual(projected.map((message) => message.id), ['message-1']); }); test('keeps a transient message out of a sparse historical range', () => { @@ -100,6 +112,6 @@ test('keeps a transient message out of a sparse historical range', () => { includeTransient: false, }); - assert.deepEqual(projected.map((message) => message.id), ['message-old']); + assert.deepEqual(projected, []); assert.equal(pending.has('message-live'), true); }); 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 22ec921ba4..af545cff1c 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 @@ -532,7 +532,7 @@ export function registerRuntimeHostSessionExecutionIpc( workspaceFileReferences: command.workspaceFileReferences, }); const messageId = command.messageId ?? newId(); - const result = await deps.client.submitMessage({ + const result = await submitMessageWithReconnect(deps.client, { sessionId, messageId, placement, @@ -546,6 +546,7 @@ export function registerRuntimeHostSessionExecutionIpc( inlineReferences, }, }); + if (!result) return { kind: 'outcome_unknown' as const, messageId }; if (result.disposition === "turn_started") { deps.emitSessionsChanged("status-change", sessionId, { turnId: result.turnId, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 2fefb87964..f10a095f4d 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -904,13 +904,16 @@ export interface MakaBridge { Pick >; }, - ): Promise<{ - kind: 'queued' | 'started'; - turnId?: string; - messageId: string; - attachments: import('@maka/core/events').AttachmentRef[]; - inlineReferences: import('@maka/core/events').InlineReference[]; - }>; + ): Promise< + | { + kind: 'queued' | 'started'; + turnId?: string; + messageId: string; + attachments: import('@maka/core/events').AttachmentRef[]; + inlineReferences: import('@maka/core/events').InlineReference[]; + } + | { kind: 'outcome_unknown'; messageId: string } + >; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; updateQueueEntry( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7be996e2f1..63bd52f887 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1678,13 +1678,16 @@ const makaBridge = { quotes?: QuoteRef[]; workspaceFileReferences?: Array>; }, - ): Promise<{ - kind: 'queued' | 'started'; - turnId?: string; - messageId: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - }> { + ): Promise< + | { + kind: 'queued' | 'started'; + turnId?: string; + messageId: string; + attachments: AttachmentRef[]; + inlineReferences: InlineReference[]; + } + | { kind: 'outcome_unknown'; messageId: string } + > { const session = await runtimeHostSessionRef(sessionId); const attachmentItems = command.attachmentItems ? await encodeIngestItems(command.attachmentItems) @@ -1698,13 +1701,16 @@ const makaBridge = { ...command, ...(attachmentItems ? { attachmentItems } : {}), }, - ) as { - kind: 'queued' | 'started'; - turnId?: string; - messageId: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - }; + ) as + | { + kind: 'queued' | 'started'; + turnId?: string; + messageId: string; + attachments: AttachmentRef[]; + inlineReferences: InlineReference[]; + } + | { kind: 'outcome_unknown'; messageId: string }; + if (result.kind === 'outcome_unknown') return result; return { ...result, attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 1af50bf4e6..1becd009ed 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -154,7 +154,10 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession: MessageLoadErrorUpdater; setMessageRetryPendingBySession: BooleanRecordUpdater; setMessages: MessageListUpdater; - addTransientMessage: (sessionId: string, message: StoredMessage) => void; + addTransientMessage: ( + sessionId: string, + message: Extract, + ) => void; removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; setNavSelection: (selection: NavSelection) => void; @@ -230,13 +233,13 @@ export function createAppShellChatActions(deps: { attachments: readonly import('@maka/core/events').AttachmentRef[] = [], quotes: readonly QuoteRef[] = [], inlineReferences: readonly InlineReference[] = [], - ): StoredMessage { + ): Extract { return { type: 'user', id: messageId, - // StoredMessage requires a grouping key, but materializeChat does not - // create a Turn from this renderer-only anchor. The Host turnId replaces - // presentation grouping through canonical transcript/live projection. + // StoredMessage requires a grouping key, but transient messages are + // rendered beside the Turn projection. Canonical transcript data later + // supplies the Host-owned grouping for this same message id. turnId: messageId, ts: Date.now(), text, @@ -648,6 +651,7 @@ export function createAppShellChatActions(deps: { ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), }); + if (result.kind === 'outcome_unknown') return; showOptimisticUserMessage(sessionId, messageId, text, result.attachments, { ...(quotes.length > 0 ? { quotes } : {}), inlineReferences: result.inlineReferences, diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 4aa0536b6b..211f2c40a3 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -84,7 +84,10 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession: StateUpdater>; setInteractionBySession: StateUpdater; setMessageQueueBySession?: StateUpdater>; - projectTransientMessage?: (sessionId: string, message: StoredMessage) => void; + projectTransientMessage?: ( + sessionId: string, + message: Extract, + ) => void; removeTransientMessage?: (sessionId: string, messageId: string) => void; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 836778f949..28c57c46e6 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -347,6 +347,7 @@ function AppShellContent({ startNewSession, clearOwnedSessionState, messages, + transientMessages, setMessages, addTransientMessage, removeTransientMessage, @@ -2136,18 +2137,23 @@ function AppShellContent({ } async function deleteQueuedEntry(entryId: string): Promise { - await runQueueEntryAction((sessionId) => + const messageId = activeMessageQueue?.entries.find((entry) => entry.entryId === entryId)?.messageId; + const sessionId = await runQueueEntryAction((sessionId) => window.maka.sessions.retractQueueEntry(sessionId, entryId).then(() => undefined) ); + if (sessionId && messageId) removeTransientMessage(sessionId, messageId); } // Surfaces the failure, then rethrows so the pending plate can settle its // in-flight action state without guessing with a timer. - async function runQueueEntryAction(action: (sessionId: string) => Promise): Promise { + async function runQueueEntryAction( + action: (sessionId: string) => Promise, + ): Promise { const sessionId = activeIdRef.current; if (!sessionId) return; try { await action(sessionId); + return sessionId; } catch (error) { if (activeIdRef.current === sessionId) { const copy = getDesktopConversationCopy(uiLocale).actions; @@ -3016,6 +3022,7 @@ function AppShellContent({ onReturnToLatestHistory={() => loadTranscriptHistory('latest')} liveContentSeedRevision={liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} + transientMessages={transientMessages} messageLoading={activeMessageLoading} runningStatus={showRunningStatus} onStreamingSettled={ diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/transient-message-projection.ts index da2db9c4d3..a2501f8b40 100644 --- a/apps/desktop/src/renderer/transient-message-projection.ts +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -19,23 +19,20 @@ import type { StoredMessage } from '@maka/core/session'; +type TransientUserMessage = Extract; + /** - * Merge a renderer-only message into the current transcript until the - * canonical transcript carries the same message id. The map is presentation - * state only; it never decides delivery or retry. + * Project renderer-only messages beside the canonical transcript until the + * canonical transcript carries the same message id. Keeping the two arrays + * distinct prevents a prior transient render from masquerading as durable + * evidence on the next projection. */ export function reconcileTransientMessages( - transient: Map, + transient: Map, durable: readonly StoredMessage[], options: { includeTransient?: boolean } = {}, -): StoredMessage[] { +): TransientUserMessage[] { for (const message of durable) transient.delete(message.id); - if (transient.size === 0 || options.includeTransient === false) return [...durable]; - const projected = [...durable]; - for (const message of [...transient.values()].sort((left, right) => left.ts - right.ts)) { - const nextIndex = projected.findIndex((candidate) => candidate.ts > message.ts); - if (nextIndex < 0) projected.push(message); - else projected.splice(nextIndex, 0, message); - } - return projected; + if (transient.size === 0 || options.includeTransient === false) return []; + return [...transient.values()].sort((left, right) => left.ts - right.ts); } diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index 174cbe69a0..a4486ced42 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -38,6 +38,8 @@ type MessageListUpdater = ( next: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[]), ) => void; +type TransientUserMessage = Extract; + export function useAppShellSessionWorkspace(toastApi: ToastApi) { const [activeId, setActiveIdState] = useState(); const activeIdRef = useRef(undefined); @@ -50,15 +52,22 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { const selectionRevisionRef = useRef(0); const bootstrapSelectionLeaseRef = useRef | null>(null); const [messages, setMessages] = useState([]); - const transientMessagesBySessionRef = useRef(new Map>()); + const messagesRef = useRef([]); + const [transientMessages, setTransientMessages] = useState([]); + const transientMessagesBySessionRef = useRef( + new Map>(), + ); const transcriptRangeRef = useRef(undefined); const [messageLoadPending, setMessageLoadPending] = useState(false); const messageRetryPendingRef = useRef>(new Set()); const stopPendingRef = useRef>(new Set()); - function mergeTransientMessages(sessionId: string, durable: readonly StoredMessage[]): StoredMessage[] { + function projectTransientMessages( + sessionId: string, + durable: readonly StoredMessage[], + ): TransientUserMessage[] { const pending = transientMessagesBySessionRef.current.get(sessionId); - if (!pending || pending.size === 0) return [...durable]; + if (!pending || pending.size === 0) return []; let includeTransient = true; try { const range = transcriptRangeRef.current?.store.range(); @@ -74,14 +83,14 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { } const setMessagesForActiveSession: MessageListUpdater = (next) => { - setMessages((current) => { - const projected = typeof next === 'function' ? next([...current]) : next; - const sessionId = activeIdRef.current; - return sessionId ? mergeTransientMessages(sessionId, projected) : projected; - }); + const projected = typeof next === 'function' ? next([...messagesRef.current]) : next; + messagesRef.current = projected; + setMessages(projected); + const sessionId = activeIdRef.current; + setTransientMessages(sessionId ? projectTransientMessages(sessionId, projected) : []); }; - function addTransientMessage(sessionId: string, message: StoredMessage): void { + function addTransientMessage(sessionId: string, message: TransientUserMessage): void { let pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending) { pending = new Map(); @@ -89,17 +98,16 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { } pending.set(message.id, message); if (activeIdRef.current === sessionId) { - setMessages((current) => mergeTransientMessages(sessionId, current)); + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); } } function removeTransientMessage(sessionId: string, messageId: string): void { const pending = transientMessagesBySessionRef.current.get(sessionId); - if (pending?.delete(messageId) && pending.size === 0) { - transientMessagesBySessionRef.current.delete(sessionId); - } + if (!pending?.delete(messageId)) return; + if (pending.size === 0) transientMessagesBySessionRef.current.delete(sessionId); if (activeIdRef.current === sessionId) { - setMessages((current) => current.filter((message) => message.id !== messageId)); + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); } } @@ -114,7 +122,9 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { if (!next) { setMessageLoadPending(false); } else if (next !== activeIdRef.current) { + messagesRef.current = []; setMessages([]); + setTransientMessages(projectTransientMessages(next, [])); setMessageLoadPending(true); } activeIdRef.current = next; @@ -134,13 +144,16 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { function startNewSession(): void { markNewTaskReloadIntent(); setActiveId(undefined); + messagesRef.current = []; setMessages([]); + setTransientMessages([]); } function clearOwnedSessionState(sessionId: string): void { messageRetryPendingRef.current.delete(sessionId); stopPendingRef.current.delete(sessionId); transientMessagesBySessionRef.current.delete(sessionId); + if (activeIdRef.current === sessionId) setTransientMessages([]); sessionUi.clearSessionUiState(sessionId); } @@ -153,11 +166,11 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { startNewSession, clearOwnedSessionState, messages, + transientMessages, setMessages: setMessagesForActiveSession, addTransientMessage, removeTransientMessage, hasTransientMessages, - mergeTransientMessages, transcriptRangeRef, messageLoadPending, setMessageLoadPending, diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index e0e6f27383..48e270d482 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -57,6 +57,7 @@ import { type ProviderRetryEvent, type QuoteRef, } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; import { finalAssistantReplyText, type TurnTimelineItem, @@ -276,6 +277,33 @@ const UserMessageBody = memo(function UserMessageBody(props: { ); }); +export function TransientUserMessage(props: { + message: Extract; + onReadAttachmentBytes?: ReadAttachmentBytes; +}) { + const copy = getConversationCopy(useUiLocale()).messages; + const message = props.message; + return ( +
+ + + +
+ ); +} + function accessibleTextExcerpt(text: string): string { const normalized = text.replace(/\s+/g, ' ').trim(); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index c05d77fd26..128259a0d9 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -43,6 +43,7 @@ import { LocalizedChatMessage, TurnRunningStatus, TurnView, + TransientUserMessage, type ReadAttachmentBytes, type TurnFooterActionMeta, type TurnPresentationDeriver, @@ -61,6 +62,7 @@ export interface LiveContentActivationSnapshot { export function ChatView(props: { messages: StoredMessage[]; + transientMessages?: readonly Extract[]; messageLoading?: boolean; liveTurn?: LiveTurnProjection; /** Live display content already present when the host activated this conversation surface. */ @@ -272,6 +274,7 @@ export function ChatView(props: { [drainingMessageIds, props.messages], ); const chat = useMemo(() => materializeChat(visibleMessages, locale), [visibleMessages, locale]); + const transientMessages = props.transientMessages ?? []; // The projection owns the derived turns, so a turn nothing said anything // about keeps its object identity and its memoized TurnView skips — across // deltas AND across the message refreshes that fire at every step/tool @@ -529,7 +532,7 @@ export function ChatView(props: { const hasVisibleConversationItem = conversationItemPlacement.byTurn.size > 0 || conversationItemPlacement.orphan !== undefined; const showEmptyState = - (chat.length === 0 && !streamingActive && !hasVisibleConversationItem) + (chat.length === 0 && transientMessages.length === 0 && !streamingActive && !hasVisibleConversationItem) || Boolean(props.messageLoading && chat.length === 0 && !hasVisibleConversationItem); const emptyContent = props.messageLoading ? ( @@ -690,6 +693,13 @@ export function ChatView(props: { style={{ height: afterHeight, flex: '0 0 auto', transition: 'none' }} /> )} + {transientMessages.map((message) => ( + + ))} {/* #642 fallback: streaming began before the optimistic user turn materialized (rare — e.g. an event replay while messages are still loading), so there is no tail turn to inject into. Render the live From dc8dfb5210255e4c8efa28b8a769a9d4810148e4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 05:50:11 +0800 Subject: [PATCH 10/26] fix(desktop): reconcile transient messages from host proof Generated-by: Codex --- .../__tests__/app-shell-stop-action.test.ts | 59 ++++++++++++++ .../__tests__/message-queue-ui-state.test.ts | 18 ++++- ...me-host-session-execution-ipc-main.test.ts | 80 +++++++++++++++++-- ...runtime-host-session-execution-ipc-main.ts | 10 ++- apps/desktop/src/preload/bridge-contract.d.ts | 1 + .../src/renderer/app-shell-session-events.ts | 6 ++ .../src/renderer/app-shell-stop-action.ts | 9 ++- apps/desktop/src/renderer/app-shell.tsx | 4 +- .../desktop/create-workbar-services.ts | 8 +- .../use-app-shell-session-workspace.ts | 5 -- 10 files changed, 175 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts diff --git a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts new file mode 100644 index 0000000000..e86ed76f1d --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { createAppShellStopAction } from '../../renderer/app-shell-stop-action.js'; + +test('removes exactly the transient messages the Host retracts while stopping', async () => { + const removed: Array<{ sessionId: string; messageId: string }> = []; + const target = globalThis as unknown as { window?: unknown }; + const previousWindow = target.window; + target.window = { + maka: { + sessions: { + stop: async () => ({ + kind: 'interrupted', + retractedMessageIds: ['message-1', 'message-2'], + }), + }, + }, + }; + try { + const stop = createAppShellStopAction({ + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + addPendingSessionAction: () => true, + clearPendingSessionAction: () => undefined, + setStopPendingBySession: () => undefined, + stopPendingRef: { current: new Set() }, + removeTransientMessage: (sessionId, messageId) => removed.push({ sessionId, messageId }), + toastApi: { error() {} }, + }); + + await stop(); + + assert.deepEqual(removed, [ + { sessionId: 'session-1', messageId: 'message-1' }, + { sessionId: 'session-1', messageId: 'message-2' }, + ]); + } finally { + target.window = previousWindow; + } +}); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 9d1a466c62..134ecb1dc0 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -111,27 +111,37 @@ test('queue_update events drive the independent desktop queue projection', () => }, ]); + handlers.handleEvent('session-1', { + type: 'steering_message', + id: 'steering-message-delivering', + turnId: 'turn-1', + messageId: 'message-delivering', + ts: 2, + content: { text: 'already delivering' }, + }); + assert.deepEqual(removedTransientMessageIds, ['message-delivering']); + handlers.handleEvent('session-1', { type: 'queue_update', id: 'queue-2', turnId: 'turn-1', - ts: 2, + ts: 3, queueRevision: 4, steering: [], followup: [], }); assert.equal(controller.getState().messageQueueBySession['session-1'], undefined); - assert.deepEqual(removedTransientMessageIds, []); + assert.deepEqual(removedTransientMessageIds, ['message-delivering']); handlers.handleEvent('session-1', { type: 'message_admission', id: 'retracted-message-next', turnId: 'turn-1', - ts: 3, + ts: 4, messageId: 'message-next', outcome: 'retracted', }); - assert.deepEqual(removedTransientMessageIds, ['message-next']); + assert.deepEqual(removedTransientMessageIds, ['message-delivering', 'message-next']); }); test('complete events deliver the durable context compaction outcome to Desktop', () => { 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 2fab214e82..4ae67b1223 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 @@ -607,7 +607,7 @@ test("submits an ordinary composer message once under its stable message identit const result = await ipc.invoke("sessions:send", "session-1", { type: "send", messageId: "message-1", - text: "/skill:review check the projection", + text: "check the projection", }); assert.deepEqual(submits, [ @@ -615,7 +615,7 @@ test("submits an ordinary composer message once under its stable message identit sessionId: "session-1", messageId: "message-1", content: { - text: "/skill:review check the projection", + text: "check the projection", inlineReferences: [], }, placement: "current_turn", @@ -632,6 +632,63 @@ test("submits an ordinary composer message once under its stable message identit }); }); +test('keeps slash Skill sends on the exact-Turn path with their stable message identity', async () => { + const starts: unknown[] = []; + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => session(), + submitMessage: async () => { + throw new Error('slash Skill send must preserve Skill invocation feedback'); + }, + startTurn: async (input) => { + starts.push(input); + return { + kind: 'started', + turn: { + sessionId: input.sessionId, + turnId: input.turnId, + runId: 'run-skill', + status: 'running', + }, + skillInvocation: { + loaded: [], + failed: [{ request: 'missing', reason: 'not_found' }], + receipts: [], + }, + }; + }, + }), + newId: () => 'unexpected-generated-id', + }, + ipc, + ); + + const result = await ipc.invoke('sessions:send', 'session-1', { + type: 'send', + messageId: 'message-skill', + text: '/skill:missing inspect this', + }); + + assert.deepEqual(starts, [{ + sessionId: 'session-1', + turnId: 'message-skill', + content: { text: '/skill:missing inspect this', inlineReferences: [] }, + }]); + assert.deepEqual(result, { + ok: true, + turnId: 'message-skill', + attachments: [], + inlineReferences: [], + skillInvocation: { + loaded: [], + failed: [{ request: 'missing', reason: 'not_found' }], + receipts: [], + }, + }); +}); + test("queues a mid-turn send as steering when the Host reports the session busy", async () => { const submits: unknown[] = []; const changes: unknown[] = []; @@ -731,6 +788,7 @@ test("retries a dispatched normal send with its original Turn identity", async ( const result = await ipc.invoke("sessions:send", "session-1", { type: "send", + messageId: 'message-1', text: "keep this Turn identity", }); @@ -738,18 +796,18 @@ test("retries a dispatched normal send with its original Turn identity", async ( assert.deepEqual(starts, [ { sessionId: "session-1", - turnId: "turn-1", + turnId: "message-1", content: { text: "keep this Turn identity", inlineReferences: [] }, }, { sessionId: "session-1", - turnId: "turn-1", + turnId: "message-1", content: { text: "keep this Turn identity", inlineReferences: [] }, }, ]); assert.deepEqual(result, { ok: true, - turnId: "turn-1", + turnId: "message-1", attachments: [], inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] }, @@ -1270,7 +1328,13 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn interrupts.push(input); return { queueRevision: 3, - retracted: [], + retracted: [{ + entryId: 'entry-followup', + messageId: 'message-followup', + content: { text: 'Do this next' }, + placement: 'next_turn', + state: 'retracted', + }], turn: { sessionId: input.sessionId, turnId: input.turnId, @@ -1375,10 +1439,10 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn expectedTurnId: "turn-unrelated", }); assert.deepEqual(stopLifecycle, []); - await ipc.invoke("sessions:stop", "session-1", { + assert.deepEqual(await ipc.invoke("sessions:stop", "session-1", { source: "stop_button", expectedTurnId: "turn-1", - }); + }), { kind: 'interrupted', retractedMessageIds: ['message-followup'] }); assert.deepEqual(stopLifecycle, [ 'teardown', 'interrupt', 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 af545cff1c..dd68f84201 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 @@ -26,6 +26,7 @@ import { } from '@maka/runtime-host/client'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { isSideConversationSession } from '@maka/core/side-conversation'; +import { parseSkillInvocationTokens } from '@maka/runtime/skill-invocation'; import { type SessionChangedEvent, type SessionChangedReason, @@ -263,7 +264,7 @@ export function registerRuntimeHostSessionExecutionIpc( if (!session) throw new Error(`Runtime Host Session not found: ${sessionId}`); const sideConversation = isSideConversationSession(session.labels); - const turnId = command.turnId ?? newId(); + const turnId = command.turnId ?? command.messageId ?? newId(); let attachments = retainedAttachmentsForSession( sessionId, command.retainedAttachments ?? [], @@ -325,6 +326,7 @@ export function registerRuntimeHostSessionExecutionIpc( command.messageId !== undefined && !sideConversation && (command.skillIds?.length ?? 0) === 0 && + parseSkillInvocationTokens(command.text).length === 0 && command.turnOrchestration === undefined ) { const submitted = await submitMessageWithReconnect(deps.client, { @@ -917,7 +919,7 @@ function createRuntimeHostSessionStop( } return; } - await deps.client.interruptTurn({ + const interrupted = await deps.client.interruptTurn({ sessionId, interruptId: newId(), turnId: turn.turnId, @@ -926,6 +928,10 @@ function createRuntimeHostSessionStop( deps.emitSessionsChanged("turn-status-change", sessionId, { turnId: turn.turnId, }); + return { + kind: 'interrupted', + retractedMessageIds: interrupted.retracted.map((message) => message.messageId), + }; }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f10a095f4d..7855ac4d4c 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -209,6 +209,7 @@ export type DesktopSideConversationBranchResult = export type DesktopSessionStopResult = | { kind: 'retracted'; messageId: string } + | { kind: 'interrupted'; retractedMessageIds: string[] } | undefined; export type DesktopReviseBeforeTurnInput = ReviseBeforeTurnInput & { diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 211f2c40a3..20d4021b3b 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -328,6 +328,12 @@ export function createAppShellSessionEventHandlers(options: { removeTransientMessage?.(sessionId, event.messageId); } break; + case 'steering_message': + // The live Turn projection now renders this same messageId in place. + // Retire only the renderer-owned tail row; a later nack queue_update + // will project it again if the Host returns the message to the queue. + removeTransientMessage?.(sessionId, event.messageId); + break; case 'text_complete': void refreshMessages(sessionId, { requiredAssistantMessageId: event.messageId }).catch(() => false); break; diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 81b8c34dc7..ea1fa70eaf 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -48,6 +48,7 @@ export function createAppShellStopAction(deps: { ) => void; setStopPendingBySession: BooleanRecordUpdater; stopPendingRef: RefBox>; + removeTransientMessage: (sessionId: string, messageId: string) => void; toastApi: ToastApi; }): () => Promise { const { @@ -57,6 +58,7 @@ export function createAppShellStopAction(deps: { clearPendingSessionAction, setStopPendingBySession, stopPendingRef, + removeTransientMessage, toastApi, } = deps; @@ -64,7 +66,12 @@ export function createAppShellStopAction(deps: { const sessionId = activeIdRef.current; if (!sessionId || !addPendingSessionAction(sessionId, stopPendingRef, setStopPendingBySession)) return; try { - await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + const result = await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + if (result?.kind === 'interrupted') { + for (const messageId of result.retractedMessageIds) { + removeTransientMessage(sessionId, messageId); + } + } } catch (error) { // The Composer wires this through both the Stop button onClick // and the Escape key. Both invoke `onStop` without awaiting, so diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 28c57c46e6..58a3a56693 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -351,7 +351,6 @@ function AppShellContent({ setMessages, addTransientMessage, removeTransientMessage, - hasTransientMessages, transcriptRangeRef, messageLoadPending, setMessageLoadPending, @@ -801,7 +800,7 @@ function AppShellContent({ const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const activeSession = sessions.find((session) => session.id === activeId); const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; - const activeMessageSubmitting = activeId ? hasTransientMessages(activeId) : false; + const activeMessageSubmitting = transientMessages.length > 0; const activeDesktopSession = activeSession; // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the @@ -2186,6 +2185,7 @@ function AppShellContent({ clearPendingSessionAction, setStopPendingBySession, stopPendingRef, + removeTransientMessage, toastApi, }); 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 2cfd56a22e..2c3e36db07 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -120,15 +120,17 @@ export function createDesktopWorkbarServices( abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), send: (sessionId, command) => bridge.sessions.send(sessionId, command), - stop: (sessionId, target) => - bridge.sessions.stop( + stop: async (sessionId, target) => { + const result = await bridge.sessions.stop( sessionId, target?.kind === 'admission' ? { source: 'stop_button', expectedAdmissionId: target.messageId } : target?.kind === 'turn' ? { source: 'stop_button', expectedTurnId: target.turnId } : undefined, - ), + ); + return result?.kind === 'retracted' ? result : undefined; + }, steer: (sessionId, text, admissionId) => bridge.sessions.steer(sessionId, text, admissionId), setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index a4486ced42..6fc56b695e 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -111,10 +111,6 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { } } - function hasTransientMessages(sessionId: string): boolean { - return (transientMessagesBySessionRef.current.get(sessionId)?.size ?? 0) > 0; - } - function setActiveId(next: string | undefined): void { selectionRevisionRef.current += 1; // Clear here, not in the read effect: a layout-effect clear would wipe an @@ -170,7 +166,6 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { setMessages: setMessagesForActiveSession, addTransientMessage, removeTransientMessage, - hasTransientMessages, transcriptRangeRef, messageLoadPending, setMessageLoadPending, From a2cd68dcd3fc215ad4a71df5668be2502ff5bba4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 05:50:18 +0800 Subject: [PATCH 11/26] fix(cli): keep host projection transient until canonical Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 28 +++++++++++++++-- .../cli/src/__tests__/pi-tui-runner.test.ts | 31 +++++++++++++++++++ packages/cli/src/pi-transcript.ts | 12 ++++++- packages/cli/src/pi-tui-runner.ts | 9 ++---- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 6dd7907017..427f8be2a0 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -428,6 +428,30 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(state.entries, [{ kind: 'user', messageId: 'message-1', text: 'send now' }]); }); + test('keeps a projected in-flight steering echo transient until durable reconciliation', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'send now', 'message-1', true); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'steering_message', + messageId: 'message-1', + content: { text: 'send now' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'message_admission', + messageId: 'message-1', + outcome: 'retracted', + }), + ); + + assert.deepEqual(state.entries, []); + }); + test('removes only the transient row named by a retracted admission', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'keep this', 'message-kept', true); @@ -447,7 +471,7 @@ describe('Maka Pi TUI transcript', () => { ]); }); - test('reconciles a live steering event into its transient message position', () => { + test('updates a projected steering echo in its transient message position', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'send now', 'message-1', true); state.entries.push({ kind: 'notice', level: 'error', text: 'later row' }); @@ -462,7 +486,7 @@ describe('Maka Pi TUI transcript', () => { ); assert.deepEqual(state.entries, [ - { kind: 'user', messageId: 'message-1', text: 'canonical text' }, + { kind: 'user', messageId: 'message-1', text: 'canonical text', transient: true }, { kind: 'notice', level: 'error', text: 'later row' }, ]); }); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 74402f50f0..d5d4c1a2b6 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2665,6 +2665,31 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('removes a one-shot Swarm transient when turn admission fails', async () => { + const terminal = new FakeTerminal(); + const driver = new FailingOrchestrationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'deepseek-v4-flash', + connectionSlug: 'deepseek', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/swarm inspect the projection'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('admission failed')); + assert.equal( + plainTerminalOutput(terminal.screenOutput()).includes('inspect the projection'), + false, + ); + + exitMaka(terminal); + await run; + }); + test('inspects a historical Agent Graph run without starting a turn', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); @@ -6952,6 +6977,12 @@ class SteeringTurnDriver implements MakaSessionDriver { } } +class FailingOrchestrationDriver extends SteeringTurnDriver { + override preparePrompt(): Promise { + return Promise.reject(new Error('admission failed')); + } +} + class SlowStopDriver implements MakaSessionDriver { stopCalls = 0; readonly prompts: string[] = []; diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index ed8bf4627d..34658442a5 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -787,7 +787,17 @@ export function applyMakaSessionEventToTranscript( case 'steering_message': // A user interjection injected mid-turn; render it in place as a user turn. - appendUserPrompt(state, event.content.displayText ?? event.content.text, event.messageId); + appendUserPrompt( + state, + event.content.displayText ?? event.content.text, + event.messageId, + state.entries.some( + (entry) => + entry.kind === 'user' && + entry.messageId === event.messageId && + entry.transient === true, + ), + ); break; case 'message_admission': diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 476a4b33a8..8a019c660d 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1143,6 +1143,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let permissionAlerted = false; let optimisticUserEntry: (typeof state.entries)[number] | undefined; + let turnPrepared = false; const finishTurnUi = () => { if (!ownsTurnUi) { editor.disableSubmit = false; @@ -1181,6 +1182,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // switch resolved (preparePrompt was in flight), and the abandoned // Turn's metadata must not overwrite the adopted Session's view. if (superseded()) return; + turnPrepared = true; if (authoritativeAttachedTurn) { adoptSessionMetadata(authoritativeAttachedTurn.summary); replaceTranscript(authoritativeAttachedTurn.messages, { @@ -1253,12 +1255,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // here as "ended without completion" — never report that against the // adopted Session. if (superseded()) return; - if ( - request.kind === 'external' && - request.turnOrchestration === undefined && - input.driver.submitMessage && - optimisticUserEntry?.kind === 'user' - ) { + if (request.kind === 'external' && !turnPrepared && optimisticUserEntry?.kind === 'user') { removeTransientUserMessage(optimisticUserEntry.messageId); optimisticUserEntry = undefined; } From 969f5462a339009df8155be9b2107aceb3416b14 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 06:13:27 +0800 Subject: [PATCH 12/26] fix(desktop): keep live message projection single-owned Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 50 +++++++++++++++++++ .../__tests__/message-queue-ui-state.test.ts | 45 +++++++++-------- .../main/__tests__/streaming-handoff.test.ts | 31 ++++++++++++ .../src/renderer/app-shell-chat-actions.ts | 22 +++++++- .../src/renderer/app-shell-session-events.ts | 1 + apps/desktop/src/renderer/app-shell.tsx | 2 + .../use-app-shell-session-workspace.ts | 10 ++++ packages/ui/src/chat-view.tsx | 27 ++++++++-- 8 files changed, 163 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 465c6dd791..433237be7f 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -199,6 +199,56 @@ describe('busy-raced send settlement', () => { } }); + it('does not resurrect a Follow Up retracted before its IPC reply settles', async () => { + const transient = new Map(); + let submittedMessageId: string | undefined; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + kind: 'queued' as const, + messageId: command.messageId, + attachments: [], + inlineReferences: [], + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => { + if (transient.has(message.id)) transient.set(message.id, message); + }, + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.enqueueMessage('session-a', 'do this next', 'next_turn'); + await submitted; + + assert.ok(submittedMessageId); + transient.delete(submittedMessageId); + releaseAdmission(); + await sending; + + assert.deepEqual([...transient.keys()], []); + } finally { + restoreWindow(); + } + }); + it('shows one stable local message before Host admission settles', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const transient = new Map(); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 134ecb1dc0..9806a97fd2 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -50,9 +50,6 @@ test('queue_update events drive the independent desktop queue projection', () => }; const inFlightEntry = { ...steeringEntry, - entryId: 'entry-delivering', - messageId: 'message-delivering', - content: { text: 'already delivering' }, state: 'in_flight' as const, }; @@ -64,7 +61,7 @@ test('queue_update events drive the independent desktop queue projection', () => queueRevision: 3, steering: ['adjust this run'], followup: ['do this next'], - steeringEntries: [steeringEntry, inFlightEntry], + steeringEntries: [steeringEntry], followupEntries: [{ entryId: 'entry-next', messageId: 'message-next', @@ -95,13 +92,6 @@ test('queue_update events drive the independent desktop queue projection', () => ts: 1, text: 'adjust this run', }, - { - type: 'user', - id: 'message-delivering', - turnId: 'message-delivering', - ts: 1, - text: 'already delivering', - }, { type: 'user', id: 'message-next', @@ -113,13 +103,13 @@ test('queue_update events drive the independent desktop queue projection', () => handlers.handleEvent('session-1', { type: 'steering_message', - id: 'steering-message-delivering', + id: 'steering-message-steer', turnId: 'turn-1', - messageId: 'message-delivering', + messageId: 'message-steer', ts: 2, - content: { text: 'already delivering' }, + content: { text: 'adjust this run' }, }); - assert.deepEqual(removedTransientMessageIds, ['message-delivering']); + assert.deepEqual(removedTransientMessageIds, ['message-steer']); handlers.handleEvent('session-1', { type: 'queue_update', @@ -127,11 +117,26 @@ test('queue_update events drive the independent desktop queue projection', () => turnId: 'turn-1', ts: 3, queueRevision: 4, - steering: [], - followup: [], + steering: ['adjust this run'], + followup: ['do this next'], + steeringEntries: [inFlightEntry], + followupEntries: [{ + entryId: 'entry-next', + messageId: 'message-next', + content: { text: 'do this next' }, + placement: 'next_turn', + state: 'queued', + }], }); - assert.equal(controller.getState().messageQueueBySession['session-1'], undefined); - assert.deepEqual(removedTransientMessageIds, ['message-delivering']); + assert.deepEqual(controller.getState().messageQueueBySession['session-1']?.entries, [{ + entryId: 'entry-next', + messageId: 'message-next', + content: { text: 'do this next' }, + placement: 'next_turn', + state: 'queued', + }]); + assert.deepEqual(removedTransientMessageIds, ['message-steer']); + assert.equal(transientMessages.length, 3, 'in-flight queue projection must not re-add the row'); handlers.handleEvent('session-1', { type: 'message_admission', @@ -141,7 +146,7 @@ test('queue_update events drive the independent desktop queue projection', () => messageId: 'message-next', outcome: 'retracted', }); - assert.deepEqual(removedTransientMessageIds, ['message-delivering', 'message-next']); + assert.deepEqual(removedTransientMessageIds, ['message-steer', 'message-next']); }); test('complete events deliver the durable context compaction outcome to Desktop', () => { diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 2fd10913d2..ea0e6a0dde 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -112,6 +112,37 @@ describe('single live-turn handoff', () => { assert.match(markup, />send now { + const markup = renderWithLocale(createElement(ChatView, { + activeSession: { + id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask', + }, + messages: [], + transientMessages: [ + { type: 'user', id: 'turn-1', turnId: 'turn-1', ts: 1, text: 'send now' }, + ], + messageLoading: true, + scrollBehavior: 'smooth', + liveTurn: { + turnId: 'turn-1', + phase: 'streamed', + steps: [{ + stepId: 'assistant-1', + text: { text: 'live answer', truncated: false, complete: false }, + tools: [], + }], + }, + onNew() {}, + } satisfies Parameters[0])); + + assert.doesNotMatch(markup, /maka-chat-message-loading/); + assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="turn-1"')); + assert.equal((markup.match(/data-transient-message-id="turn-1"/g) ?? []).length, 1); + assert.equal((markup.match(/data-virtual-turn-id="turn-1"/g) ?? []).length, 1); + }); + it('renders one ordered timeline: thinking before its tool and answer', () => { const markup = renderLiveTurn({ turnId: 'turn-1', diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 1becd009ed..9c8b4d6a80 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -158,6 +158,10 @@ export function createAppShellChatActions(deps: { sessionId: string, message: Extract, ) => void; + updateTransientMessage?: ( + sessionId: string, + message: Extract, + ) => void; removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; setNavSelection: (selection: NavSelection) => void; @@ -208,6 +212,7 @@ export function createAppShellChatActions(deps: { setMessageRetryPendingBySession, setMessages, addTransientMessage, + updateTransientMessage, removeTransientMessage, transcriptRangeRef, setNavSelection, @@ -229,6 +234,7 @@ export function createAppShellChatActions(deps: { function optimisticUserMessage( messageId: string, + turnId: string, text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], quotes: readonly QuoteRef[] = [], @@ -240,7 +246,7 @@ export function createAppShellChatActions(deps: { // StoredMessage requires a grouping key, but transient messages are // rendered beside the Turn projection. Canonical transcript data later // supplies the Host-owned grouping for this same message id. - turnId: messageId, + turnId, ts: Date.now(), text, ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), @@ -255,18 +261,25 @@ export function createAppShellChatActions(deps: { text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], options: { + turnId?: string; + updateOnly?: boolean; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; } = {}, ): void { const next = optimisticUserMessage( messageId, + options.turnId ?? messageId, text, attachments, options.quotes, options.inlineReferences, ); - addTransientMessage(sessionId, next); + if (options.updateOnly) { + (updateTransientMessage ?? addTransientMessage)(sessionId, next); + } else { + addTransientMessage(sessionId, next); + } if (activeIdRef.current !== sessionId) return; setMessageLoadErrorBySession((current) => { if (!current[sessionId]) return current; @@ -458,6 +471,8 @@ export function createAppShellChatActions(deps: { skillInvocationDisplayText(text, sendResult.skillInvocation), sendResult.attachments, { + turnId: sendResult.turnId ?? messageId, + updateOnly: true, ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: sendResult.inlineReferences ?? [], }, @@ -558,6 +573,8 @@ export function createAppShellChatActions(deps: { skillInvocationDisplayText(text, sendResult.skillInvocation), sendResult.attachments, { + turnId: sendResult.turnId ?? messageId, + updateOnly: true, ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: sendResult.inlineReferences ?? [], }, @@ -653,6 +670,7 @@ export function createAppShellChatActions(deps: { }); if (result.kind === 'outcome_unknown') return; showOptimisticUserMessage(sessionId, messageId, text, result.attachments, { + updateOnly: true, ...(quotes.length > 0 ? { quotes } : {}), inlineReferences: result.inlineReferences, }); diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 20d4021b3b..56c405148e 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -291,6 +291,7 @@ export function createAppShellSessionEventHandlers(options: { switch (event.type) { case 'queue_update': for (const entry of [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])]) { + if (entry.state !== 'queued') continue; projectTransientMessage?.(sessionId, { type: 'user', id: entry.messageId, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 58a3a56693..ee8b9d58f6 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -350,6 +350,7 @@ function AppShellContent({ transientMessages, setMessages, addTransientMessage, + updateTransientMessage, removeTransientMessage, transcriptRangeRef, messageLoadPending, @@ -1776,6 +1777,7 @@ function AppShellContent({ setMessageRetryPendingBySession, setMessages, addTransientMessage, + updateTransientMessage, removeTransientMessage, transcriptRangeRef, setNavSelection, diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index 6fc56b695e..21188afc26 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -102,6 +102,15 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { } } + function updateTransientMessage(sessionId: string, message: TransientUserMessage): void { + const pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending?.has(message.id)) return; + pending.set(message.id, message); + if (activeIdRef.current === sessionId) { + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); + } + } + function removeTransientMessage(sessionId: string, messageId: string): void { const pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending?.delete(messageId)) return; @@ -165,6 +174,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { transientMessages, setMessages: setMessagesForActiveSession, addTransientMessage, + updateTransientMessage, removeTransientMessage, transcriptRangeRef, messageLoadPending, diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 128259a0d9..43a3d0d9e1 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -457,6 +457,17 @@ export function ChatView(props: { } }, [revealTurn]); const mountedTurns = turns.slice(mountStart, mountEnd); + const inlineTransientMessage = tailTurnId + ? transientMessages.find((message) => { + if (message.turnId !== tailTurnId) return false; + const turn = mountedTurns.find((candidate) => candidate.turnId === tailTurnId); + return turn !== undefined + && turn.user === undefined + && !turn.timeline.some( + (item) => item.kind === 'user' && item.messageId === message.id, + ); + }) + : undefined; const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, @@ -532,8 +543,10 @@ export function ChatView(props: { const hasVisibleConversationItem = conversationItemPlacement.byTurn.size > 0 || conversationItemPlacement.orphan !== undefined; const showEmptyState = - (chat.length === 0 && transientMessages.length === 0 && !streamingActive && !hasVisibleConversationItem) - || Boolean(props.messageLoading && chat.length === 0 && !hasVisibleConversationItem); + chat.length === 0 + && transientMessages.length === 0 + && !streamingActive + && !hasVisibleConversationItem; const emptyContent = props.messageLoading ? (
@@ -639,6 +652,12 @@ export function ChatView(props: { className="maka-turn-virtual-item" data-virtual-turn-id={turn.turnId} > + {inlineTransientMessage?.turnId === turn.turnId ? ( + + ) : null} )} - {transientMessages.map((message) => ( + {transientMessages.filter( + (message) => message.id !== inlineTransientMessage?.id, + ).map((message) => ( Date: Wed, 26 Aug 2026 06:32:43 +0800 Subject: [PATCH 13/26] fix(desktop): preserve host transient ordering Generated-by: Codex --- .../__tests__/message-queue-ui-state.test.ts | 6 ++- .../main/__tests__/streaming-handoff.test.ts | 36 ++++++++++++++ .../transient-message-projection.test.ts | 48 ++++++++++++++++++- .../src/renderer/app-shell-chat-actions.ts | 17 +++++-- .../src/renderer/app-shell-session-events.ts | 40 +++++++++------- apps/desktop/src/renderer/app-shell.tsx | 3 +- .../renderer/transient-message-projection.ts | 34 ++++++++++++- .../use-app-shell-session-workspace.ts | 30 ++++++++++-- packages/ui/src/chat-view.tsx | 47 +++++++++++------- packages/ui/src/components.tsx | 6 ++- 10 files changed, 218 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 9806a97fd2..4ed78e51ca 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -35,7 +35,7 @@ test('queue_update events drive the independent desktop queue projection', () => setLiveTurnBySession: controller.setLiveTurnBySession, setInteractionBySession: controller.setInteractionBySession, setMessageQueueBySession: controller.setMessageQueueBySession, - projectTransientMessage: (_sessionId, message) => transientMessages.push(message), + projectQueuedTransientMessages: (_sessionId, messages) => transientMessages.push(...messages), removeTransientMessage: (_sessionId, messageId) => removedTransientMessageIds.push(messageId), showModelSetupToast() {}, @@ -88,7 +88,8 @@ test('queue_update events drive the independent desktop queue projection', () => { type: 'user', id: 'message-steer', - turnId: 'message-steer', + turnId: 'turn-1', + transientPlacement: 'current_turn', ts: 1, text: 'adjust this run', }, @@ -96,6 +97,7 @@ test('queue_update events drive the independent desktop queue projection', () => type: 'user', id: 'message-next', turnId: 'message-next', + transientPlacement: 'next_turn', ts: 1, text: 'do this next', }, diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index ea0e6a0dde..3cd5846844 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -143,6 +143,42 @@ describe('single live-turn handoff', () => { assert.equal((markup.match(/data-virtual-turn-id="turn-1"/g) ?? []).length, 1); }); + it('keeps an unresolved root transient before a live Turn that arrived before IPC settled', () => { + const markup = renderWithLocale(createElement(ChatView, { + activeSession: { + id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask', + }, + messages: [], + transientMessages: [ + { + type: 'user', id: 'message-1', turnId: 'message-1', ts: 1, text: 'send now', + transientPlacement: 'turn_source', + }, + { + type: 'user', id: 'message-next', turnId: 'message-next', ts: 2, text: 'do this next', + transientPlacement: 'next_turn', + }, + ], + scrollBehavior: 'smooth', + liveTurn: { + turnId: 'host-turn', + phase: 'streamed', + steps: [{ + stepId: 'assistant-1', + text: { text: 'live answer', truncated: false, complete: false }, + tools: [], + }], + }, + onNew() {}, + } satisfies Parameters[0])); + + assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="host-turn"')); + assert.ok(markup.indexOf('do this next') > markup.indexOf('data-turn-id="host-turn"')); + assert.equal((markup.match(/data-transient-message-id=/g) ?? []).length, 2); + }); + it('renders one ordered timeline: thinking before its tool and answer', () => { const markup = renderLiveTurn({ turnId: 'turn-1', diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index 3d5c8c1ab0..ee8447b221 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -20,7 +20,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; -import { reconcileTransientMessages } from '../../renderer/transient-message-projection.js'; +import { + mergeTransientMessageProjection, + projectQueuedTransientMessages, + reconcileTransientMessages, +} from '../../renderer/transient-message-projection.js'; const transient: Extract = { type: 'user', @@ -115,3 +119,45 @@ test('keeps a transient message out of a sparse historical range', () => { assert.deepEqual(projected, []); assert.equal(pending.has('message-live'), true); }); + +test('uses the Host queue snapshot order for already-present transient messages', () => { + const localSecond = { + ...transient, + id: 'message-2', + turnId: 'message-2', + text: 'second', + }; + const remoteFirst = { + ...transient, + id: 'message-1', + turnId: 'message-1', + text: 'first', + }; + const pending = new Map([[localSecond.id, localSecond]]); + + projectQueuedTransientMessages(pending, [remoteFirst, localSecond]); + + assert.deepEqual( + reconcileTransientMessages(pending, []).map((message) => message.id), + ['message-1', 'message-2'], + ); +}); + +test('keeps a Host-bound current Turn when a later IPC result has no Turn identity', () => { + const hostBound = { + ...transient, + id: 'message-current', + turnId: 'host-turn', + transientPlacement: 'current_turn' as const, + }; + const lateIpcUpdate = { + ...hostBound, + turnId: hostBound.id, + text: 'uploaded content', + }; + + assert.deepEqual(mergeTransientMessageProjection(hostBound, lateIpcUpdate), { + ...lateIpcUpdate, + turnId: 'host-turn', + }); +}); diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 9c8b4d6a80..a691a80c95 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -36,6 +36,7 @@ import { type InteractionQueues, type LiveTurnProjection, type NavSelection, + type TransientUserMessageProjection, } from '@maka/ui'; import { messageRefreshErrorMessage } from './app-shell-copy.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -156,11 +157,11 @@ export function createAppShellChatActions(deps: { setMessages: MessageListUpdater; addTransientMessage: ( sessionId: string, - message: Extract, + message: TransientUserMessageProjection, ) => void; updateTransientMessage?: ( sessionId: string, - message: Extract, + message: TransientUserMessageProjection, ) => void; removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; @@ -239,7 +240,8 @@ export function createAppShellChatActions(deps: { attachments: readonly import('@maka/core/events').AttachmentRef[] = [], quotes: readonly QuoteRef[] = [], inlineReferences: readonly InlineReference[] = [], - ): Extract { + transientPlacement?: TransientUserMessageProjection['transientPlacement'], + ): TransientUserMessageProjection { return { type: 'user', id: messageId, @@ -252,6 +254,7 @@ export function createAppShellChatActions(deps: { ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), inlineReferences: [...inlineReferences], + ...(transientPlacement ? { transientPlacement } : {}), }; } @@ -263,6 +266,7 @@ export function createAppShellChatActions(deps: { options: { turnId?: string; updateOnly?: boolean; + transientPlacement?: TransientUserMessageProjection['transientPlacement']; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; } = {}, @@ -274,6 +278,7 @@ export function createAppShellChatActions(deps: { attachments, options.quotes, options.inlineReferences, + options.transientPlacement, ); if (options.updateOnly) { (updateTransientMessage ?? addTransientMessage)(sessionId, next); @@ -391,6 +396,7 @@ export function createAppShellChatActions(deps: { options.displayText ?? text, [], { + transientPlacement: 'turn_source', ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }, @@ -473,6 +479,7 @@ export function createAppShellChatActions(deps: { { turnId: sendResult.turnId ?? messageId, updateOnly: true, + transientPlacement: 'turn_source', ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: sendResult.inlineReferences ?? [], }, @@ -507,6 +514,7 @@ export function createAppShellChatActions(deps: { options.displayText ?? text, [], { + transientPlacement: 'turn_source', ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }, @@ -575,6 +583,7 @@ export function createAppShellChatActions(deps: { { turnId: sendResult.turnId ?? messageId, updateOnly: true, + transientPlacement: 'turn_source', ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: sendResult.inlineReferences ?? [], }, @@ -652,6 +661,7 @@ export function createAppShellChatActions(deps: { const messageId = crypto.randomUUID(); const quotes = options.quotes ?? []; showOptimisticUserMessage(sessionId, messageId, text, retainedAttachmentRefs(pending ?? []), { + transientPlacement: placement, ...(quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }); @@ -671,6 +681,7 @@ export function createAppShellChatActions(deps: { if (result.kind === 'outcome_unknown') return; showOptimisticUserMessage(sessionId, messageId, text, result.attachments, { updateOnly: true, + transientPlacement: placement, ...(quotes.length > 0 ? { quotes } : {}), inlineReferences: result.inlineReferences, }); diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 56c405148e..1b09961a02 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -30,6 +30,7 @@ import { settleLiveTurnStep, type LiveTurnProjection, type InteractionQueues, + type TransientUserMessageProjection, } from '@maka/ui'; import type { RefreshMessagesOptions } from './app-shell-chat-actions.js'; import type { MessageQueueUiState } from './app-shell-session-ui-state.js'; @@ -84,9 +85,9 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession: StateUpdater>; setInteractionBySession: StateUpdater; setMessageQueueBySession?: StateUpdater>; - projectTransientMessage?: ( + projectQueuedTransientMessages?: ( sessionId: string, - message: Extract, + messages: readonly TransientUserMessageProjection[], ) => void; removeTransientMessage?: (sessionId: string, messageId: string) => void; onInteractionChanged?: (sessionId: string) => void; @@ -116,7 +117,7 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession, setInteractionBySession, setMessageQueueBySession, - projectTransientMessage, + projectQueuedTransientMessages, removeTransientMessage, onInteractionChanged, onExecutionBoundaryChanged, @@ -290,21 +291,24 @@ export function createAppShellSessionEventHandlers(options: { switch (event.type) { case 'queue_update': - for (const entry of [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])]) { - if (entry.state !== 'queued') continue; - projectTransientMessage?.(sessionId, { - type: 'user', - id: entry.messageId, - turnId: entry.messageId, - ts: event.ts, - text: entry.content.displayText ?? entry.content.text, - ...(entry.content.attachments ? { attachments: [...entry.content.attachments] } : {}), - ...(entry.content.quotes ? { quotes: [...entry.content.quotes] } : {}), - ...(entry.content.inlineReferences - ? { inlineReferences: [...entry.content.inlineReferences] } - : {}), - }); - } + projectQueuedTransientMessages?.( + sessionId, + [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])] + .filter((entry) => entry.state === 'queued') + .map((entry) => ({ + type: 'user', + id: entry.messageId, + turnId: entry.placement === 'current_turn' ? event.turnId : entry.messageId, + transientPlacement: entry.placement, + ts: event.ts, + text: entry.content.displayText ?? entry.content.text, + ...(entry.content.attachments ? { attachments: [...entry.content.attachments] } : {}), + ...(entry.content.quotes ? { quotes: [...entry.content.quotes] } : {}), + ...(entry.content.inlineReferences + ? { inlineReferences: [...entry.content.inlineReferences] } + : {}), + })), + ); setMessageQueueBySession?.((current) => { if (event.steering.length === 0 && event.followup.length === 0) { if (!(sessionId in current)) return current; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ee8b9d58f6..6eac6f152f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -351,6 +351,7 @@ function AppShellContent({ setMessages, addTransientMessage, updateTransientMessage, + projectQueuedTransientMessages, removeTransientMessage, transcriptRangeRef, messageLoadPending, @@ -2208,7 +2209,7 @@ function AppShellContent({ setLiveTurnBySession, setInteractionBySession, setMessageQueueBySession, - projectTransientMessage: addTransientMessage, + projectQueuedTransientMessages, removeTransientMessage, displayBatch: sessionDisplayBatch, onInteractionChanged: markInteractionChanged, diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/transient-message-projection.ts index a2501f8b40..cc4d935940 100644 --- a/apps/desktop/src/renderer/transient-message-projection.ts +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -18,8 +18,38 @@ */ import type { StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from '@maka/ui'; -type TransientUserMessage = Extract; +type TransientUserMessage = TransientUserMessageProjection; + +/** + * Replace the queue-backed subset in the exact order supplied by the Host. + * Other local intents keep their relative position because queue absence is + * not cancellation or delivery proof. + */ +export function projectQueuedTransientMessages( + transient: Map, + queued: readonly TransientUserMessage[], +): void { + if (queued.length === 0) return; + const queuedIds = new Set(queued.map((message) => message.id)); + const retained = [...transient.entries()].filter(([id]) => !queuedIds.has(id)); + transient.clear(); + for (const [id, message] of retained) transient.set(id, message); + for (const message of queued) transient.set(message.id, message); +} + +export function mergeTransientMessageProjection( + current: TransientUserMessage, + update: TransientUserMessage, +): TransientUserMessage { + const hostBoundCurrentTurn = + current.transientPlacement === 'current_turn' + && update.transientPlacement === 'current_turn' + && current.turnId !== current.id + && update.turnId === update.id; + return hostBoundCurrentTurn ? { ...update, turnId: current.turnId } : update; +} /** * Project renderer-only messages beside the canonical transcript until the @@ -34,5 +64,5 @@ export function reconcileTransientMessages( ): TransientUserMessage[] { for (const message of durable) transient.delete(message.id); if (transient.size === 0 || options.includeTransient === false) return []; - return [...transient.values()].sort((left, right) => left.ts - right.ts); + return [...transient.values()]; } diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index 21188afc26..0c10cb1f15 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -19,6 +19,7 @@ import { useRef, useState } from 'react'; import type { StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from '@maka/ui'; import { useAppShellSessionUiState } from './app-shell-session-ui-state'; import { useAppShellSessionList } from './use-app-shell-session-list'; import { createBootstrapSelectionLease } from './bootstrap-selection-lease'; @@ -28,7 +29,11 @@ import { markNewTaskReloadIntent, } from './new-task-reload-intent'; import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; -import { reconcileTransientMessages } from './transient-message-projection.js'; +import { + mergeTransientMessageProjection, + projectQueuedTransientMessages as applyQueuedTransientProjection, + reconcileTransientMessages, +} from './transient-message-projection.js'; type ToastApi = { error(title: string, description?: string): void; @@ -38,7 +43,7 @@ type MessageListUpdater = ( next: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[]), ) => void; -type TransientUserMessage = Extract; +type TransientUserMessage = TransientUserMessageProjection; export function useAppShellSessionWorkspace(toastApi: ToastApi) { const [activeId, setActiveIdState] = useState(); @@ -104,8 +109,24 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { function updateTransientMessage(sessionId: string, message: TransientUserMessage): void { const pending = transientMessagesBySessionRef.current.get(sessionId); - if (!pending?.has(message.id)) return; - pending.set(message.id, message); + const current = pending?.get(message.id); + if (!pending || !current) return; + pending.set(message.id, mergeTransientMessageProjection(current, message)); + if (activeIdRef.current === sessionId) { + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); + } + } + + function projectQueuedTransientMessages( + sessionId: string, + messages: readonly TransientUserMessage[], + ): void { + let pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending) { + pending = new Map(); + transientMessagesBySessionRef.current.set(sessionId, pending); + } + applyQueuedTransientProjection(pending, messages); if (activeIdRef.current === sessionId) { setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); } @@ -175,6 +196,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { setMessages: setMessagesForActiveSession, addTransientMessage, updateTransientMessage, + projectQueuedTransientMessages, removeTransientMessage, transcriptRangeRef, messageLoadPending, diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 43a3d0d9e1..9943f1befd 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -60,9 +60,14 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } +export type TransientUserMessageProjection = Extract & { + /** Presentation-only placement until canonical transcript grouping arrives. */ + transientPlacement?: 'turn_source' | 'current_turn' | 'next_turn'; +}; + export function ChatView(props: { messages: StoredMessage[]; - transientMessages?: readonly Extract[]; + transientMessages?: readonly TransientUserMessageProjection[]; messageLoading?: boolean; liveTurn?: LiveTurnProjection; /** Live display content already present when the host activated this conversation surface. */ @@ -457,17 +462,22 @@ export function ChatView(props: { } }, [revealTurn]); const mountedTurns = turns.slice(mountStart, mountEnd); - const inlineTransientMessage = tailTurnId - ? transientMessages.find((message) => { - if (message.turnId !== tailTurnId) return false; + const inlineTransientMessages = tailTurnId + ? transientMessages.filter((message) => { const turn = mountedTurns.find((candidate) => candidate.turnId === tailTurnId); - return turn !== undefined - && turn.user === undefined - && !turn.timeline.some( - (item) => item.kind === 'user' && item.messageId === message.id, - ); + if ( + turn === undefined + || turn.user !== undefined + || turn.timeline.some((item) => item.kind === 'user' && item.messageId === message.id) + ) { + return false; + } + return message.turnId === tailTurnId || message.transientPlacement === 'turn_source'; }) - : undefined; + : []; + const inlineTransientMessageIds = new Set( + inlineTransientMessages.map((message) => message.id), + ); const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, @@ -652,12 +662,15 @@ export function ChatView(props: { className="maka-turn-virtual-item" data-virtual-turn-id={turn.turnId} > - {inlineTransientMessage?.turnId === turn.turnId ? ( - - ) : null} + {turn.turnId === tailTurnId + ? inlineTransientMessages.map((message) => ( + + )) + : null} )} {transientMessages.filter( - (message) => message.id !== inlineTransientMessage?.id, + (message) => !inlineTransientMessageIds.has(message.id), ).map((message) => ( Date: Wed, 26 Aug 2026 10:10:05 +0800 Subject: [PATCH 14/26] fix: reconcile transient messages from durable proof Generated-by: Codex --- ...me-host-session-execution-ipc-main.test.ts | 36 ++++++++-- .../transient-message-projection.test.ts | 20 ++++++ apps/desktop/src/main/runtime-host-client.ts | 6 ++ ...runtime-host-session-execution-ipc-main.ts | 16 +++-- apps/desktop/src/preload/bridge-contract.d.ts | 9 +-- apps/desktop/src/preload/preload.ts | 16 +++-- apps/desktop/src/renderer/app-shell.tsx | 2 + .../renderer/transient-message-projection.ts | 10 +++ .../use-app-shell-session-workspace.ts | 23 +++++++ .../cli/src/__tests__/pi-transcript.test.ts | 19 ++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 7 +- .../cli/src/__tests__/pi-tui-turn.test.ts | 7 +- .../runtime-host-session-driver.test.ts | 28 +++----- packages/cli/src/pi-transcript.ts | 15 ++++- packages/cli/src/pi-tui-runner.ts | 63 ++++++++---------- packages/cli/src/pi-tui-turn.ts | 7 +- .../cli/src/runtime-host-session-driver.ts | 19 +++--- packages/cli/src/session-driver.ts | 14 ++-- .../src/__tests__/message-coordinator.test.ts | 63 ++++++++++++++++++ .../src/__tests__/protocol.test.ts | 13 ++++ packages/runtime-host/src/protocol/index.ts | 2 +- packages/runtime-host/src/protocol/message.ts | 66 +++++++++++++++++++ .../runtime-host/src/protocol/operations.ts | 1 + .../src/server/message-coordinator.ts | 44 +++++++++++++ .../src/server/operation-dispatcher.ts | 1 + .../sqlite-session-metadata-store.test.ts | 5 ++ packages/storage/src/execution-stores.ts | 2 + .../storage/src/message-admission-store.ts | 10 +++ packages/storage/src/session-store.ts | 5 ++ .../src/sqlite-session-metadata-store.ts | 42 ++++++++++++ 30 files changed, 466 insertions(+), 105 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 4ae67b1223..165367d3ef 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 @@ -624,7 +624,6 @@ test("submits an ordinary composer message once under its stable message identit assert.deepEqual(result, { ok: true, disposition: "turn_started", - messageId: "message-1", turnId: "host-turn", attachments: [], inlineReferences: [], @@ -632,6 +631,36 @@ test("submits an ordinary composer message once under its stable message identit }); }); +test('returns Host-owned Message lifecycle proof to the renderer', async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + queryMessages: async (input) => ({ + messages: input.messageIds.map((messageId) => ({ + messageId, + status: messageId === 'message-cancelled' ? 'cancelled' : 'accepted', + })), + }), + }), + }, + ipc, + ); + + assert.deepEqual( + await ipc.invoke('sessions:queryMessageStatuses', 'session-1', [ + 'message-accepted', + 'message-cancelled', + ]), + { + messages: [ + { messageId: 'message-accepted', status: 'accepted' }, + { messageId: 'message-cancelled', status: 'cancelled' }, + ], + }, + ); +}); + test('keeps slash Skill sends on the exact-Turn path with their stable message identity', async () => { const starts: unknown[] = []; const ipc = ipcHarness(); @@ -938,7 +967,6 @@ test("retries a dispatched busy fallback with its original message identity", as { ok: false, reason: "outcome_unknown", - messageId: "turn-unknown", skillInvocation: { loaded: [], failed: [], receipts: [] }, }, ); @@ -1138,7 +1166,6 @@ test("queues explicit Desktop follow-ups", async () => { }), { kind: "queued", - messageId: "followup-message", attachments: [ { kind: "other", @@ -1211,7 +1238,7 @@ test('keeps an unknown Desktop follow-up admission available for reconciliation' messageId: 'followup-unknown', text: 'keep this visible', }), - { kind: 'outcome_unknown', messageId: 'followup-unknown' }, + { kind: 'outcome_unknown' }, ); }); @@ -1536,6 +1563,7 @@ function executionClient(overrides: Partial): ExecutionClient { interruptTurn: unavailable, listSessionTurnLandmarks: unavailable, listSessionTurns: unavailable, + queryMessages: unavailable, queryTurnResume: unavailable, readExecutionBoundary: unavailable, regenerateTurn: unavailable, diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index ee8447b221..120bbd6a2b 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -23,6 +23,7 @@ import type { StoredMessage } from '@maka/core/session'; import { mergeTransientMessageProjection, projectQueuedTransientMessages, + reconcileTransientMessageLifecycle, reconcileTransientMessages, } from '../../renderer/transient-message-projection.js'; @@ -67,6 +68,25 @@ test('replaces a transient message by canonical message id exactly once', () => assert.equal(pending.size, 0); }); +test('removes only messages with durable cancellation proof after reconnect', () => { + const accepted = { ...transient, id: 'message-accepted' }; + const handedOff = { ...transient, id: 'message-handed-off' }; + const cancelled = { ...transient, id: 'message-cancelled' }; + const unknown = { ...transient, id: 'message-unknown' }; + const pending = new Map( + [accepted, handedOff, cancelled, unknown].map((message) => [message.id, message]), + ); + + reconcileTransientMessageLifecycle(pending, [ + { messageId: accepted.id, status: 'accepted' }, + { messageId: handedOff.id, status: 'handed_off' }, + { messageId: cancelled.id, status: 'cancelled' }, + { messageId: unknown.id, status: 'unknown' }, + ]); + + assert.deepEqual([...pending.keys()], [accepted.id, handedOff.id, unknown.id]); +}); + test('canonicalizing one send does not hide a later transient send', () => { const second = { ...transient, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ff9e34e11b..9aa793878b 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1084,6 +1084,12 @@ export class DesktopRuntimeHostClient { }); } + queryMessages( + input: OperationInput<'turn.message.query'>, + ): Promise> { + return this.request('turn.message.query', input); + } + retractQueueEntry( input: Omit, ): Promise { 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 dd68f84201..6ffd2e96fe 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 @@ -97,6 +97,7 @@ type RuntimeHostSessionExecutionClient = Pick< | "interruptTurn" | 'listSessionTurns' | 'listSessionTurnLandmarks' + | 'queryMessages' | "queryTurnResume" | "readExecutionBoundary" | "regenerateTurn" @@ -191,6 +192,14 @@ export function registerRuntimeHostSessionExecutionIpc( const newId = deps.newId ?? randomUUID; const stopSession = createRuntimeHostSessionStop(deps, newId); + ipcMain.handle( + 'sessions:queryMessageStatuses', + async (_event, sessionId: string, messageIds: unknown) => { + if (!Array.isArray(messageIds)) throw new Error('Invalid Message identities'); + return deps.client.queryMessages({ sessionId, messageIds }); + }, + ); + handleReconnectableRead( ipcMain, "sessions:observe", @@ -340,7 +349,6 @@ export function registerRuntimeHostSessionExecutionIpc( return { ok: false as const, reason: 'outcome_unknown' as const, - messageId: command.messageId, skillInvocation, }; } @@ -351,7 +359,6 @@ export function registerRuntimeHostSessionExecutionIpc( return { ok: true as const, disposition: submitted.disposition, - messageId: command.messageId, turnId: submitted.turnId, attachments, inlineReferences, @@ -362,7 +369,6 @@ export function registerRuntimeHostSessionExecutionIpc( return { ok: true as const, disposition: submitted.disposition, - messageId: command.messageId, attachments, inlineReferences, skillInvocation, @@ -548,7 +554,7 @@ export function registerRuntimeHostSessionExecutionIpc( inlineReferences, }, }); - if (!result) return { kind: 'outcome_unknown' as const, messageId }; + if (!result) return { kind: 'outcome_unknown' as const }; if (result.disposition === "turn_started") { deps.emitSessionsChanged("status-change", sessionId, { turnId: result.turnId, @@ -556,14 +562,12 @@ export function registerRuntimeHostSessionExecutionIpc( return { kind: "started" as const, turnId: result.turnId, - messageId, attachments, inlineReferences, }; } return { kind: "queued" as const, - messageId, attachments, inlineReferences, }; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 7855ac4d4c..24e779bd77 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -804,7 +804,6 @@ export interface MakaBridge { | { ok: true; disposition: 'turn_started' | 'steering' | 'followup'; - messageId: string; turnId?: string; attachments: import('@maka/core/events').AttachmentRef[]; inlineReferences: import('@maka/core/events').InlineReference[]; @@ -818,7 +817,6 @@ export interface MakaBridge { | { ok: false; reason: 'outcome_unknown'; - messageId: string; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } >; @@ -909,12 +907,15 @@ export interface MakaBridge { | { kind: 'queued' | 'started'; turnId?: string; - messageId: string; attachments: import('@maka/core/events').AttachmentRef[]; inlineReferences: import('@maka/core/events').InlineReference[]; } - | { kind: 'outcome_unknown'; messageId: string } + | { kind: 'outcome_unknown' } >; + queryMessageStatuses( + sessionId: string, + messageIds: readonly string[], + ): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; updateQueueEntry( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 63bd52f887..0b06ac32a7 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1682,11 +1682,10 @@ const makaBridge = { | { kind: 'queued' | 'started'; turnId?: string; - messageId: string; attachments: AttachmentRef[]; inlineReferences: InlineReference[]; } - | { kind: 'outcome_unknown'; messageId: string } + | { kind: 'outcome_unknown' } > { const session = await runtimeHostSessionRef(sessionId); const attachmentItems = command.attachmentItems @@ -1705,17 +1704,26 @@ const makaBridge = { | { kind: 'queued' | 'started'; turnId?: string; - messageId: string; attachments: AttachmentRef[]; inlineReferences: InlineReference[]; } - | { kind: 'outcome_unknown'; messageId: string }; + | { kind: 'outcome_unknown' }; if (result.kind === 'outcome_unknown') return result; return { ...result, attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), }; }, + queryMessageStatuses( + sessionId: string, + messageIds: readonly string[], + ): Promise { + return invokeSessionRuntimeHost( + 'sessions:queryMessageStatuses', + sessionId, + messageIds, + ); + }, retractQueueEntry(sessionId: string, entryId: string): Promise { return invokeSessionRuntimeHost('sessions:retractQueueEntry', sessionId, entryId); }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 6eac6f152f..8ee255d3bc 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -352,6 +352,7 @@ function AppShellContent({ addTransientMessage, updateTransientMessage, projectQueuedTransientMessages, + reconcileTransientMessageStatuses, removeTransientMessage, transcriptRangeRef, messageLoadPending, @@ -2304,6 +2305,7 @@ function AppShellContent({ const next = completeLiveContentSeed(current, sessionId, expected); activeEventSeedRef.current = next; setActiveEventSeed(next); + void reconcileTransientMessageStatuses(sessionId); }; useActiveSessionEvents({ uiLocale, diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/transient-message-projection.ts index cc4d935940..7c89f01367 100644 --- a/apps/desktop/src/renderer/transient-message-projection.ts +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -18,6 +18,7 @@ */ import type { StoredMessage } from '@maka/core/session'; +import type { MessageLifecycleStatus } from '@maka/runtime-host/protocol'; import type { TransientUserMessageProjection } from '@maka/ui'; type TransientUserMessage = TransientUserMessageProjection; @@ -51,6 +52,15 @@ export function mergeTransientMessageProjection( return hostBoundCurrentTurn ? { ...update, turnId: current.turnId } : update; } +export function reconcileTransientMessageLifecycle( + transient: Map, + messages: readonly { messageId: string; status: MessageLifecycleStatus }[], +): void { + for (const message of messages) { + if (message.status === 'cancelled') transient.delete(message.messageId); + } +} + /** * Project renderer-only messages beside the canonical transcript until the * canonical transcript carries the same message id. Keeping the two arrays diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index 0c10cb1f15..06223d38f0 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -32,6 +32,7 @@ import type { DesktopTranscriptRangeController } from './desktop-transcript-rang import { mergeTransientMessageProjection, projectQueuedTransientMessages as applyQueuedTransientProjection, + reconcileTransientMessageLifecycle, reconcileTransientMessages, } from './transient-message-projection.js'; @@ -122,6 +123,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { messages: readonly TransientUserMessage[], ): void { let pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending && messages.length === 0) return; if (!pending) { pending = new Map(); transientMessagesBySessionRef.current.set(sessionId, pending); @@ -132,6 +134,26 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { } } + async function reconcileTransientMessageStatuses(sessionId: string): Promise { + const pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending || pending.size === 0) return; + try { + const result = await window.maka.sessions.queryMessageStatuses( + sessionId, + [...pending.keys()], + ); + const current = transientMessagesBySessionRef.current.get(sessionId); + if (!current) return; + reconcileTransientMessageLifecycle(current, result.messages); + if (current.size === 0) transientMessagesBySessionRef.current.delete(sessionId); + if (activeIdRef.current === sessionId) { + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); + } + } catch { + // A failed proof query leaves presentation intact until canonical proof arrives. + } + } + function removeTransientMessage(sessionId: string, messageId: string): void { const pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending?.delete(messageId)) return; @@ -197,6 +219,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { addTransientMessage, updateTransientMessage, projectQueuedTransientMessages, + reconcileTransientMessageStatuses, removeTransientMessage, transcriptRangeRef, messageLoadPending, diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 427f8be2a0..4874d76546 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -37,6 +37,7 @@ import { refreshRunningShellRunElapsed, hydrateToolsWithStoredMessages, makaPiToolPresentationStatus, + reconcileTransientMessageLifecycle, replaceTranscriptWithStoredMessages, submitCompactToTranscript, toggleAllThinkingExpansion, @@ -315,6 +316,24 @@ describe('Maka Pi TUI transcript', () => { ]); }); + test('removes only transient rows with durable cancellation proof', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'accepted', 'message-accepted', true); + appendUserPrompt(state, 'handed off', 'message-handed-off', true); + appendUserPrompt(state, 'cancelled', 'message-cancelled', true); + + reconcileTransientMessageLifecycle(state, [ + { messageId: 'message-accepted', status: 'accepted' }, + { messageId: 'message-handed-off', status: 'handed_off' }, + { messageId: 'message-cancelled', status: 'cancelled' }, + ]); + + assert.deepEqual( + state.entries.map((entry) => ('messageId' in entry ? entry.messageId : undefined)), + ['message-accepted', 'message-handed-off'], + ); + }); + test('keeps a transient user row before later durable output in a sparse replacement', () => { const state = createMakaPiTranscriptState(); replaceTranscriptWithStoredMessages(state, [ diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index d5d4c1a2b6..f447892794 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -6905,7 +6905,7 @@ class SteeringTurnDriver implements MakaSessionDriver { summary: fakeSessionSummary(turn.sessionId), }), ); - return { messageId: options.messageId, disposition: 'turn_started' as const }; + return; } if (options.placement === 'current_turn') { this.steered.push(text); @@ -6915,11 +6915,6 @@ class SteeringTurnDriver implements MakaSessionDriver { this.followup.push({ messageId: options.messageId, text }); } this.emitQueueUpdate(); - return { - messageId: options.messageId, - disposition: - options.placement === 'current_turn' ? ('steering' as const) : ('followup' as const), - }; } subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { diff --git a/packages/cli/src/__tests__/pi-tui-turn.test.ts b/packages/cli/src/__tests__/pi-tui-turn.test.ts index 8606611c1c..f3a61af77c 100644 --- a/packages/cli/src/__tests__/pi-tui-turn.test.ts +++ b/packages/cli/src/__tests__/pi-tui-turn.test.ts @@ -39,7 +39,6 @@ describe('Maka Pi TUI turn', () => { placement: 'current_turn', modelText: 'expanded prompt', }); - return { messageId: options.messageId, disposition: 'turn_started' }; }, }, turnActivity: { activities: new SessionActivityRegistry() }, @@ -57,11 +56,7 @@ describe('Maka Pi TUI turn', () => { }, }); - assert.deepEqual(outcome, { - kind: 'admitted', - messageId: 'message-1', - disposition: 'turn_started', - }); + assert.deepEqual(outcome, { kind: 'admitted' }); assert.deepEqual(sequence, ['start', 'submit']); }); 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 5b9badf431..9f8d85b44f 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1104,13 +1104,10 @@ describe('Runtime Host Maka Session driver', () => { }); await driver.switchSession('session-1'); - assert.deepEqual( - await driver.submitMessage!('Later', { - messageId: 'message-1', - placement: 'next_turn', - }), - { messageId: 'message-1', disposition: 'followup' }, - ); + await driver.submitMessage!('Later', { + messageId: 'message-1', + placement: 'next_turn', + }); assert.deepEqual(await driver.retractQueued!(), { text: 'Later', messageIds: ['message-1'], @@ -1155,16 +1152,11 @@ describe('Runtime Host Maka Session driver', () => { }); await driver.switchSession('session-1'); - const admission = await driver.submitMessage!('Visible prompt', { + await driver.submitMessage!('Visible prompt', { messageId: 'message-1', placement: 'current_turn', modelText: 'Expanded prompt', }); - - assert.deepEqual(admission, { - messageId: 'message-1', - disposition: 'steering', - }); assert.deepEqual(connection.requests.at(-1), { operation: 'turn.message.submit', input: { @@ -1195,12 +1187,11 @@ describe('Runtime Host Maka Session driver', () => { }); await driver.switchSession('session-1'); - assert.deepEqual( - await driver.submitMessage!('Keep this visible', { + await assert.doesNotReject(() => + driver.submitMessage!('Keep this visible', { messageId: 'message-unknown', placement: 'current_turn', }), - { messageId: 'message-unknown', disposition: 'outcome_unknown' }, ); }); @@ -1223,12 +1214,11 @@ describe('Runtime Host Maka Session driver', () => { }); await driver.switchSession('session-1'); - assert.deepEqual( - await driver.submitMessage!('Keep this visible', { + await assert.doesNotReject(() => + driver.submitMessage!('Keep this visible', { messageId: 'message-interrupted', placement: 'current_turn', }), - { messageId: 'message-interrupted', disposition: 'outcome_unknown' }, ); }); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 34658442a5..4cd99926a2 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -60,7 +60,7 @@ import { goalStatusLineText, isLiveGoalStatus } from './pi-goal.js'; import { renderToolBlock } from './pi-transcript-tools.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; import { renderTuiShortcutCopy } from './tui-shortcut-copy.js'; -import type { GoalProjection } from '@maka/runtime-host/protocol'; +import type { GoalProjection, MessageLifecycleStatus } from '@maka/runtime-host/protocol'; export interface MakaPiUsageSummary { /** Cumulative cost in USD across the session. */ @@ -255,6 +255,19 @@ export function appendUserPrompt( state.entries.push(entry); } +export function reconcileTransientMessageLifecycle( + state: MakaPiTranscriptState, + messages: readonly { messageId: string; status: MessageLifecycleStatus }[], +): void { + const cancelled = new Set( + messages.filter(({ status }) => status === 'cancelled').map(({ messageId }) => messageId), + ); + if (cancelled.size === 0) return; + state.entries = state.entries.filter( + (entry) => entry.kind !== 'user' || entry.transient !== true || !cancelled.has(entry.messageId), + ); +} + export function appendTurnFailureToTranscript(state: MakaPiTranscriptState, error: unknown): void { clearPendingInteractions(state); state.entries.push({ diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 8a019c660d..f383917a69 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -94,6 +94,7 @@ import { completePendingInteraction, applyShellRunViewUpdateToTranscript, permissionModeLabel, + reconcileTransientMessageLifecycle, replaceTranscriptWithStoredMessages, hydrateToolsWithStoredMessages, submitCompactToTranscript, @@ -551,6 +552,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { replaceTranscript(messages, { preserveTransientMessages: true }); shellRunElapsedTicker.sync(); requestRender(); + const messageIds = state.entries.flatMap((entry) => + entry.kind === 'user' && entry.transient === true ? [entry.messageId] : [], + ); + if (messageIds.length > 0) { + void input.driver + .queryMessageStatuses?.(messageIds) + .then((result) => { + if (closed || input.driver.getSessionId() !== sessionId) return; + reconcileTransientMessageLifecycle(state, result.messages); + requestRender(); + }) + .catch(() => undefined); + } return; } rememberTranscriptModel(messages); @@ -914,14 +928,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { refillEditorFromQueues(retracted.text); }; - // Enter during a turn asks the Host to place the message at the current - // step boundary. The Host alone decides whether it steers or starts a - // successor Turn if the previous Turn settled during admission. - const steerRunningTurn = (text: string) => { - if (!text.trim()) { - requestRender(); - return; - } + const submitRunningMessage = (text: string, placement: 'current_turn' | 'next_turn') => { editor.addToHistory(text); const submitMessage = input.driver.submitMessage; if (!submitMessage) { @@ -932,12 +939,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { appendUserPrompt(state, text, messageId, true); requestRender(); const task = submitMessage - .call(input.driver, text, { messageId, placement: 'current_turn' }) - .then(() => { - // The subscription projects queue and Turn state; admission only - // confirms that this stable message identity belongs to the Host. - requestRender(); - }) + .call(input.driver, text, { messageId, placement }) + .then(requestRender) .catch((error) => { removeTransientUserMessage(messageId); refillEditorFromQueues(text); @@ -946,6 +949,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { trackEnqueue(task); }; + // Enter during a turn asks the Host to place the message at the current + // step boundary. The Host alone decides whether it steers or starts a + // successor Turn if the previous Turn settled during admission. + const steerRunningTurn = (text: string) => { + if (!text.trim()) { + requestRender(); + return; + } + submitRunningMessage(text, 'current_turn'); + }; + // Alt+Enter: during a turn, queue the text to open the next turn; when idle, // it submits like Enter. const handleAltEnter = () => { @@ -963,26 +977,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { submitPrompt(text); return; } - editor.addToHistory(text); - const submitMessage = input.driver.submitMessage; - if (!submitMessage) { - refillEditorFromQueues(text); - return; - } - const messageId = randomUUID(); - appendUserPrompt(state, text, messageId, true); - requestRender(); - const task = submitMessage - .call(input.driver, text, { messageId, placement: 'next_turn' }) - .then(() => { - requestRender(); - }) - .catch((error) => { - removeTransientUserMessage(messageId); - refillEditorFromQueues(text); - reportError(error); - }); - trackEnqueue(task); + submitRunningMessage(text, 'next_turn'); }; // Alt+↑: take back every queued message from the Runtime Host, joined and diff --git a/packages/cli/src/pi-tui-turn.ts b/packages/cli/src/pi-tui-turn.ts index 9b1553bc6a..957dc8cc6e 100644 --- a/packages/cli/src/pi-tui-turn.ts +++ b/packages/cli/src/pi-tui-turn.ts @@ -30,7 +30,6 @@ import { type GoalTurnOutcome } from '@maka/runtime/goal-continuation'; import { SkillInvocationBlockedError, type MakaPreparedSessionTurn, - type MakaMessageAdmission, type MakaSessionDriver, } from './session-driver.js'; @@ -69,7 +68,7 @@ export interface RunMakaPiTuiTurnInput { onFailure?: (error: unknown) => void | Promise; } -export type MakaPiTuiTurnOutcome = GoalTurnOutcome | ({ kind: 'admitted' } & MakaMessageAdmission); +export type MakaPiTuiTurnOutcome = GoalTurnOutcome | { kind: 'admitted' }; /** * Owns one visible TUI turn from activity reservation through full stream drain. @@ -109,12 +108,12 @@ export async function runMakaPiTuiTurn( request.turnOrchestration === undefined && input.driver.submitMessage ) { - const admission = await input.driver.submitMessage(request.prompt, { + await input.driver.submitMessage(request.prompt, { messageId: externalTurnId!, placement: 'current_turn', ...(request.sendText !== undefined ? { modelText: request.sendText } : {}), }); - return finishBeforeDrain({ kind: 'admitted', ...admission }); + return finishBeforeDrain({ kind: 'admitted' }); } const turn = diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 2a67acc2c4..eb37138dd4 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -79,7 +79,6 @@ import type { MakaAttachedSessionTurn, MakaSideConversationCloseResult, MakaSideConversationOpenResult, - MakaMessageAdmission, MakaRetractedMessages, MakaPreparePromptOptions, MakaPreparedSessionTurn, @@ -385,10 +384,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { yield* events; } - async submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { + async submitMessage(text: string, options: MakaSubmitMessageOptions): Promise { const sessionId = await this.#ensureSession(); const sessionGeneration = this.#sessionGeneration; const configuration = await this.#loadConfiguration(sessionId); @@ -397,9 +393,8 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#assertCurrentSession(sessionId, sessionGeneration); this.#adoptLoadedConfiguration(configuration); const modelText = options.modelText ?? text; - let result; try { - result = await this.#request('turn.message.submit', { + await this.#request('turn.message.submit', { originHostEpoch: this.#connection.hostEpoch, sessionId, messageId: options.messageId, @@ -414,11 +409,17 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') || (error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched') ) { - return { messageId: options.messageId, disposition: 'outcome_unknown' }; + return; } throw error; } - return { messageId: options.messageId, disposition: result.disposition }; + } + + async queryMessageStatuses( + messageIds: readonly string[], + ): Promise> { + const sessionId = await this.#ensureSession(); + return this.#request('turn.message.query', { sessionId, messageIds }); } async retractQueued(): Promise { diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index aa541ee466..351b5b568f 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -28,7 +28,11 @@ import type { CreateSessionInput, TurnOrchestration } from '@maka/core/runtime-i import type { UserQuestionResponse } from '@maka/core/user-question'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; -import type { GoalControlAction, GoalProjection } from '@maka/runtime-host/protocol'; +import type { + GoalControlAction, + GoalProjection, + TurnMessageQueryResult, +} from '@maka/runtime-host/protocol'; export interface MakaSessionMoveResult { previousCwd: string; @@ -96,11 +100,6 @@ export interface MakaSubmitMessageOptions { modelText?: string; } -export interface MakaMessageAdmission { - messageId: string; - disposition: 'steering' | 'followup' | 'turn_started' | 'outcome_unknown'; -} - export interface MakaRetractedMessages { text: string; messageIds: readonly string[]; @@ -120,7 +119,8 @@ export interface MakaSessionDriver { prompt: string, options?: MakaPreparePromptOptions, ): Promise; - submitMessage?(text: string, options: MakaSubmitMessageOptions): Promise; + submitMessage?(text: string, options: MakaSubmitMessageOptions): Promise; + queryMessageStatuses?(messageIds: readonly string[]): Promise; compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; retractQueued?(): Promise; diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 4763c12670..df3c9ed762 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -72,6 +72,59 @@ test('idle submit starts exactly one root Turn and retry identity is connection- assert.equal(fixture.liveResidencies(), 0); }); +test('message query returns durable cancellation proof after the live queue disappears', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'cancelled-message', 'discard me', 'next_turn'); + await fixture.coordinator.cancelMessages(ROOT.sessionId, ['cancelled-message']); + + const result = await fixture.coordinator.handlers['turn.message.query']( + { + sessionId: ROOT.sessionId, + messageIds: ['cancelled-message', 'unknown-message'], + }, + operationContext(), + ); + + assert.deepEqual(result, { + ok: true, + result: { + messages: [ + { messageId: 'cancelled-message', status: 'cancelled' }, + { messageId: 'unknown-message', status: 'unknown' }, + ], + }, + }); +}); + +test('message query distinguishes a live admission from durable handoff proof', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'accepted-message', 'waiting', 'next_turn'); + fixture.receipts.set( + 'handed-off-message', + sourceReceipt('handed-off-message', 'delivered', 'current_turn', 'steering'), + ); + + const result = await fixture.coordinator.handlers['turn.message.query']( + { + sessionId: ROOT.sessionId, + messageIds: ['accepted-message', 'handed-off-message'], + }, + operationContext(), + ); + + assert.deepEqual(result, { + ok: true, + result: { + messages: [ + { messageId: 'accepted-message', status: 'accepted' }, + { messageId: 'handed-off-message', status: 'handed_off' }, + ], + }, + }); +}); + test('submit re-runs admission when the queue revision moves during preflight', async () => { let preflightCalls = 0; const fixture = createFixture(undefined, async () => { @@ -2267,6 +2320,16 @@ function memoryMessageAdmissionStore( return admission; }, readMessageAdmission: async (_sessionId, messageId) => admissions.get(messageId)?.admission, + readCancelledMessageAdmission: async (_sessionId, messageId) => { + const entry = admissions.get(messageId); + return entry?.state === 'cancelled' + ? { + messageId, + submittedContentDigest: entry.admission.submittedContentDigest, + submittedPlacement: entry.admission.submittedPlacement, + } + : undefined; + }, listMessageAdmissions: async (sessionId) => [...admissions.values()] .filter(({ admission, state }) => admission.sessionId === sessionId && state === 'accepted') diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c4f2fad83b..ad488a531f 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -242,6 +242,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 43); }); + test('publishes a new compatibility epoch for durable Message lifecycle queries', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); + }); + test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); @@ -969,6 +973,14 @@ describe('Runtime Host bootstrap protocol', () => { }); test('requires stable Message command identities, origin Host Epoch, and exact inputs', () => { + const query = { + requestId: 'query-request-1', + operation: 'turn.message.query' as const, + input: { + sessionId: 'session-1', + messageIds: ['message-1', 'message-2'], + }, + }; const submit = { requestId: 'submit-request-1', operation: 'turn.message.submit' as const, @@ -996,6 +1008,7 @@ describe('Runtime Host bootstrap protocol', () => { runId: 'run-1', }, }; + assert.deepEqual(decodeClientFrame(query), query); assert.deepEqual(decodeClientFrame(submit), submit); assert.deepEqual(decodeClientFrame(retract), retract); assert.deepEqual(decodeClientFrame(interrupt), interrupt); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 30f01b2cb7..73ae39f084 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,7 @@ 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 = 50 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; // 50: WorkHub can append durable coordination summaries and admit tool-free // answers through its reserved Coordination Session authority. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. diff --git a/packages/runtime-host/src/protocol/message.ts b/packages/runtime-host/src/protocol/message.ts index e72c171b02..b8c48908d8 100644 --- a/packages/runtime-host/src/protocol/message.ts +++ b/packages/runtime-host/src/protocol/message.ts @@ -91,6 +91,20 @@ export type TurnMessageSubmitResult = | { readonly disposition: 'followup'; readonly queueRevision: number } | { readonly disposition: 'turn_started'; readonly turnId: string }; +export type MessageLifecycleStatus = 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; + +export interface TurnMessageQueryInput { + readonly sessionId: string; + readonly messageIds: readonly string[]; +} + +export interface TurnMessageQueryResult { + readonly messages: readonly { + readonly messageId: string; + readonly status: MessageLifecycleStatus; + }[]; +} + export interface QueueRetractInput { readonly originHostEpoch: string; readonly sessionId: string; @@ -163,6 +177,13 @@ const MESSAGE_OPERATION_ERRORS = [ ] as const; export const MESSAGE_OPERATION_SPECS = { + 'turn.message.query': defineOperation({ + mode: 'query', + availability: 'ready', + errors: MESSAGE_OPERATION_ERRORS, + decodeInput: decodeTurnMessageQueryInput, + decodeOutput: decodeTurnMessageQueryResult, + }), 'turn.message.submit': defineOperation({ mode: 'command', availability: 'ready', @@ -258,6 +279,51 @@ function decodeTurnMessageSubmitInput(value: unknown): TurnMessageSubmitInput { }; } +function decodeTurnMessageQueryInput(value: unknown): TurnMessageQueryInput { + const record = requireExactRecord(value, 'turn.message.query input', ['sessionId', 'messageIds']); + if (!Array.isArray(record.messageIds) || record.messageIds.length > MESSAGE_QUEUE_MAX_ENTRIES) { + throw invalidProtocolFrame('Invalid turn.message.query messageIds'); + } + const messageIds = record.messageIds.map((messageId) => requireEntityId(messageId, 'messageId')); + if (new Set(messageIds).size !== messageIds.length) { + throw invalidProtocolFrame('Duplicate turn.message.query messageId'); + } + return { + sessionId: requireEntityId(record.sessionId, 'sessionId'), + messageIds, + }; +} + +function decodeTurnMessageQueryResult(value: unknown): TurnMessageQueryResult { + const record = requireExactRecord(value, 'turn.message.query result', ['messages']); + if (!Array.isArray(record.messages) || record.messages.length > MESSAGE_QUEUE_MAX_ENTRIES) { + throw invalidProtocolFrame('Invalid turn.message.query messages'); + } + const messages = record.messages.map((candidate) => { + const message = requireExactRecord(candidate, 'turn.message.query message', [ + 'messageId', + 'status', + ]); + if ( + message.status !== 'accepted' && + message.status !== 'handed_off' && + message.status !== 'cancelled' && + message.status !== 'unknown' + ) { + throw invalidProtocolFrame('Invalid turn.message.query status'); + } + const status = message.status as MessageLifecycleStatus; + return { + messageId: requireEntityId(message.messageId, 'messageId'), + status, + }; + }); + if (new Set(messages.map(({ messageId }) => messageId)).size !== messages.length) { + throw invalidProtocolFrame('Duplicate turn.message.query result messageId'); + } + return { messages }; +} + function decodeTurnMessageSubmitResult(value: unknown): TurnMessageSubmitResult { const record = requireRecord(value, 'turn.message.submit result'); if (record.disposition === 'turn_started') { diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 9d95dec0bf..00bcf5257e 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -316,6 +316,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'subscription.open', 'task.ledger.query', 'turn.interrupt', + 'turn.message.query', 'turn.message.submit', 'turn.query', 'turn.regenerate', diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 066d3d5e0c..acd667d753 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -315,6 +315,7 @@ const HOST_EPOCH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; /** The sole in-memory message authority for one Runtime Host Epoch. */ export class HostMessageCoordinator implements RuntimeMessageAuthority { readonly handlers: MessageOperationHandlerMap = { + 'turn.message.query': (input) => this.queryMessages(input), 'turn.message.submit': (input, context) => this.submit(input, context), 'queue.retract': (input) => this.retract(input), 'queue.entry.retract': (input) => this.retractQueuedEntry(input), @@ -370,6 +371,49 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return state ? hasLiveMessageState(state) : false; } + async queryMessages(input: { sessionId: string; messageIds: readonly string[] }): Promise< + MessageOutcome<{ + messages: Array<{ + messageId: string; + status: 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; + }>; + }> + > { + const messages = [] as Array<{ + messageId: string; + status: 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; + }>; + for (const messageId of input.messageIds) { + const cancelled = await this.#admissions.readCancelledMessageAdmission( + input.sessionId, + messageId, + ); + if (cancelled) { + messages.push({ messageId, status: 'cancelled' }); + continue; + } + const accepted = await this.#admissions.readMessageAdmission(input.sessionId, messageId); + if (accepted) { + messages.push({ messageId, status: 'accepted' }); + continue; + } + const root = await this.#durableProof.readRootTurnSourceMessageReceipt( + input.sessionId, + messageId, + ); + if (root) { + messages.push({ messageId, status: 'handed_off' }); + continue; + } + const steering = await this.#durableProof.readImmutableSteeringMessageProof( + input.sessionId, + messageId, + ); + messages.push({ messageId, status: steering ? 'handed_off' : 'unknown' }); + } + return success({ messages }); + } + retireSessions(sessionIds: readonly string[]): void { for (const sessionId of new Set(sessionIds)) { const state = this.#sessions.get(sessionId); diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 208befbb28..49876f7991 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -85,6 +85,7 @@ export type ConnectionEffectOperationKey = Extract< >; export type MessageOperationKey = Extract< OperationKey, + | 'turn.message.query' | 'turn.message.submit' | 'queue.retract' | 'queue.entry.retract' diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index f939ab6099..93027587f8 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -445,6 +445,11 @@ describe('SqliteSessionMetadataStore', () => { await store.commitMessageAdmission(admission); await store.cancelMessageAdmissions('session-1', ['message-1']); assert.deepEqual(await store.listMessageAdmissions('session-1'), []); + assert.deepEqual(await store.readCancelledMessageAdmission('session-1', 'message-1'), { + messageId: 'message-1', + submittedContentDigest: messageContentDigest({ text: 'discard this draft' }), + submittedPlacement: 'next_turn', + }); await assert.rejects( store.commitMessageAdmission(admission), /identity is already cancelled/, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 32996da174..88201cf48a 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -415,6 +415,8 @@ async function createExecutionStoresForWrite sessionStore.commitMessageAdmission(admission)), readMessageAdmission: (sessionId, messageId) => run(() => sessionStore.readMessageAdmission(sessionId, messageId)), + readCancelledMessageAdmission: (sessionId, messageId) => + run(() => sessionStore.readCancelledMessageAdmission(sessionId, messageId)), listMessageAdmissions: (sessionId) => run(() => sessionStore.listMessageAdmissions(sessionId)), markMessagesHandedOff: (input) => run(() => sessionStore.markMessagesHandedOff(input)), diff --git a/packages/storage/src/message-admission-store.ts b/packages/storage/src/message-admission-store.ts index a73a3f43e2..e0b728e109 100644 --- a/packages/storage/src/message-admission-store.ts +++ b/packages/storage/src/message-admission-store.ts @@ -35,12 +35,22 @@ export interface PendingMessageAdmission { readonly admittedAt: number; } +export interface CancelledMessageAdmission { + readonly messageId: string; + readonly submittedContentDigest: `sha256:${string}`; + readonly submittedPlacement: 'current_turn' | 'next_turn'; +} + export interface MessageAdmissionStore { commitMessageAdmission(admission: PendingMessageAdmission): Promise; readMessageAdmission( sessionId: string, messageId: string, ): Promise; + readCancelledMessageAdmission( + sessionId: string, + messageId: string, + ): Promise; listMessageAdmissions(sessionId: string): Promise; markMessagesHandedOff(input: { sessionId: string; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 5361318930..3d69a52679 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -888,6 +888,11 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readMessageAdmission(sessionId, messageId); } + async readCancelledMessageAdmission(sessionId: string, messageId: string) { + await this.ensureReady(); + return this.metadata.readCancelledMessageAdmission(sessionId, messageId); + } + async listMessageAdmissions(sessionId: string): Promise { await this.ensureReady(); return this.metadata.listMessageAdmissions(sessionId); diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 8943dea71f..a2ed11dfc7 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -1656,6 +1656,48 @@ export class SqliteSessionMetadataStore { }); } + async readCancelledMessageAdmission( + sessionId: string, + messageId: string, + ): Promise< + | { + messageId: string; + submittedContentDigest: `sha256:${string}`; + submittedPlacement: 'current_turn' | 'next_turn'; + } + | undefined + > { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeSessionId(messageId); + return this.readTransaction(() => { + const row = this.db + .prepare( + ` + SELECT submitted_content_digest, submitted_placement + FROM cancelled_message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(sessionId, messageId) as + | { submitted_content_digest?: unknown; submitted_placement?: unknown } + | undefined; + if (!row) return undefined; + if ( + typeof row.submitted_content_digest !== 'string' || + !/^sha256:[a-f0-9]{64}$/u.test(row.submitted_content_digest) || + (row.submitted_placement !== 'current_turn' && row.submitted_placement !== 'next_turn') + ) { + throw new SessionMetadataConflictError('Invalid cancelled Message admission identity'); + } + return { + messageId, + submittedContentDigest: row.submitted_content_digest as `sha256:${string}`, + submittedPlacement: row.submitted_placement, + }; + }); + } + async listMessageAdmissions(sessionId: string): Promise { this.assertOpen(); assertSafeSessionId(sessionId); From cc370ba7e5ee6080d3853d0dc062bda99aea0838 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 12:22:04 +0800 Subject: [PATCH 15/26] refactor: make Runtime Host the sole Message admission authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message admission was split across the clients: Desktop and the CLI each decided whether typed text needed an exact reserved Turn or could be queued, and each carried its own submit path for the two answers. That duplicated an authority the Runtime Host already holds, and it meant the same decision could drift between surfaces. The Host now owns it. `turn.message.submit` carries the exact-Turn intent (explicit Skill ids, an orchestration override) and answers with one disposition — steering, followup, turn_started or blocked. Only a Message that describes how one Turn runs needs an idle Session; a `/skill:` token in the text does not, because message preparation expands it on the queued path too. `turn.start` stays for Clients that genuinely reserve a Turn (Workbar, WorkHub, Side Conversation) and for headless `maka run`. With one answer to render, each client keeps one submit path. Desktop's `sessions:enqueue` becomes `sessions:submitMessage` and the ordinary-send branch inside `sessions:send` is gone, taking ~90 lines of duplicated bridge types with it. The CLI TUI submits every Message the same way and attaches to whatever Turn the Host starts, so `MakaPiTuiTurnOutcome` and the `ownsTurnUi` fork disappear. `MessageLifecycleStatus` had no behavioural consumer, so the query result collapses to the cancelled ids that actually retire a transient row. Two projection fixes fall out of the single path. A transcript replacement now preserves every client-local entry — notices as well as transient user rows — so adopting an attached Turn no longer wipes the recap or Skill card the client just wrote. A started-Turn announcement for a Session the client no longer displays is ignored, so a mid-turn `/session` detach cannot hand the adopted view the abandoned Session's metadata. User-visible: the transcript is the single place a queued message is shown in full; the composer queue plate keeps its edit, promote, reorder and delete controls on a one-line preview. A `/skill:` message sent during a running Turn now queues instead of failing. A second Enter typed while the Host is still admitting the first Message is submitted rather than dropped. Generated-by: Claude Code --- .../app-shell-busy-race-settlement.test.ts | 37 +- .../app-shell-first-send-cleanup.test.ts | 1 + .../__tests__/message-queue-ui-state.test.ts | 3 +- ...me-host-session-execution-ipc-main.test.ts | 70 ++-- .../main/__tests__/streaming-handoff.test.ts | 12 +- .../transient-message-projection.test.ts | 41 +- ...runtime-host-session-execution-ipc-main.ts | 102 ++--- apps/desktop/src/preload/bridge-contract.d.ts | 58 +-- apps/desktop/src/preload/preload.ts | 115 ++---- .../src/renderer/app-shell-chat-actions.ts | 131 +++--- .../src/renderer/app-shell-session-events.ts | 3 +- apps/desktop/src/renderer/app-shell.tsx | 4 +- apps/desktop/src/renderer/styles/composer.css | 8 + .../renderer/transient-message-projection.ts | 23 +- .../use-app-shell-session-workspace.ts | 14 +- .../cli/src/__tests__/pi-transcript.test.ts | 18 +- .../cli/src/__tests__/pi-tui-runner.test.ts | 391 +++++++++++++++--- .../cli/src/__tests__/pi-tui-turn.test.ts | 106 +---- packages/cli/src/pi-transcript.ts | 88 ++-- packages/cli/src/pi-tui-runner.ts | 164 ++++---- packages/cli/src/pi-tui-turn.ts | 90 +--- .../cli/src/runtime-host-session-driver.ts | 12 +- packages/cli/src/runtime-host-tui-command.ts | 2 + packages/cli/src/session-driver.ts | 14 +- .../__tests__/execution-host-recovery.test.ts | 25 +- .../src/__tests__/message-coordinator.test.ts | 41 +- packages/runtime-host/src/protocol/message.ts | 133 ++++-- packages/runtime-host/src/protocol/turn.ts | 4 +- .../src/server/message-coordinator.ts | 101 +++-- .../src/server/root-turn-coordinator.ts | 22 +- packages/ui/src/chat-view.tsx | 16 +- packages/ui/src/composer-message-queue.tsx | 15 +- 32 files changed, 963 insertions(+), 901 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 433237be7f..a901144f0b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -101,6 +101,7 @@ function createActionsDeps() { setMessageRetryPendingBySession: () => undefined, setMessages: () => undefined, addTransientMessage: () => undefined, + updateTransientMessage: () => undefined, removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, @@ -135,15 +136,20 @@ describe('busy-raced send settlement', () => { }); const restoreWindow = installWindow({ sessions: { - enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => { + submitMessage: async ( + _sessionId: string, + _placement: string, + command: { messageId: string }, + ) => { submittedMessageId = command.messageId; observeSubmit(); await admission; return { - kind: 'queued', - messageId: command.messageId, + ok: true, + disposition: 'followup', attachments: [], inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, }; }, }, @@ -153,6 +159,7 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), }); const sending = actions.enqueueMessage( @@ -176,10 +183,7 @@ describe('busy-raced send settlement', () => { const transient = new Map(); const restoreWindow = installWindow({ sessions: { - enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => ({ - kind: 'outcome_unknown' as const, - messageId: command.messageId, - }), + submitMessage: async () => ({ ok: false, reason: 'outcome_unknown' as const }), }, }); try { @@ -187,6 +191,7 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef: { current: 'session-a' }, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), }); @@ -212,15 +217,20 @@ describe('busy-raced send settlement', () => { }); const restoreWindow = installWindow({ sessions: { - enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => { + submitMessage: async ( + _sessionId: string, + _placement: string, + command: { messageId: string }, + ) => { submittedMessageId = command.messageId; observeSubmit(); await admission; return { - kind: 'queued' as const, - messageId: command.messageId, + ok: true as const, + disposition: 'followup' as const, attachments: [], inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, }; }, }, @@ -263,7 +273,11 @@ describe('busy-raced send settlement', () => { }); const restoreWindow = installWindow({ sessions: { - submitMessage: async (_sessionId: string, command: { messageId: string }) => { + submitMessage: async ( + _sessionId: string, + _placement: string, + command: { messageId: string }, + ) => { submittedMessageId = command.messageId; observeSubmit(); await admission; @@ -284,6 +298,7 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), }); const sending = actions.send('also check the tests'); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 40b6cceb94..729865797c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -99,6 +99,7 @@ function createActionsDeps() { setMessageRetryPendingBySession: () => undefined, setMessages: () => undefined, addTransientMessage: () => undefined, + updateTransientMessage: () => undefined, removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 4ed78e51ca..cc5de97b64 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -88,8 +88,9 @@ test('queue_update events drive the independent desktop queue projection', () => { type: 'user', id: 'message-steer', - turnId: 'turn-1', + turnId: 'message-steer', transientPlacement: 'current_turn', + hostTurnId: 'turn-1', ts: 1, text: 'adjust this run', }, 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 165367d3ef..7a982498a7 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 @@ -604,8 +604,7 @@ test("submits an ordinary composer message once under its stable message identit ipc, ); - const result = await ipc.invoke("sessions:send", "session-1", { - type: "send", + const result = await ipc.invoke("sessions:submitMessage", "session-1", "current_turn", { messageId: "message-1", text: "check the projection", }); @@ -631,16 +630,15 @@ test("submits an ordinary composer message once under its stable message identit }); }); -test('returns Host-owned Message lifecycle proof to the renderer', async () => { +test('returns Host-owned cancellation proof to the renderer', async () => { const ipc = ipcHarness(); registerExecutionIpc( { client: executionClient({ queryMessages: async (input) => ({ - messages: input.messageIds.map((messageId) => ({ - messageId, - status: messageId === 'message-cancelled' ? 'cancelled' : 'accepted', - })), + cancelledMessageIds: input.messageIds.filter( + (messageId) => messageId === 'message-cancelled', + ), }), }), }, @@ -648,39 +646,28 @@ test('returns Host-owned Message lifecycle proof to the renderer', async () => { ); assert.deepEqual( - await ipc.invoke('sessions:queryMessageStatuses', 'session-1', [ + await ipc.invoke('sessions:queryCancelledMessages', 'session-1', [ 'message-accepted', 'message-cancelled', ]), - { - messages: [ - { messageId: 'message-accepted', status: 'accepted' }, - { messageId: 'message-cancelled', status: 'cancelled' }, - ], - }, + { cancelledMessageIds: ['message-cancelled'] }, ); }); -test('keeps slash Skill sends on the exact-Turn path with their stable message identity', async () => { - const starts: unknown[] = []; +test('submits a slash Skill message and reports the Host Skill outcome', async () => { + const submits: unknown[] = []; const ipc = ipcHarness(); registerExecutionIpc( { client: executionClient({ getSession: async () => session(), - submitMessage: async () => { - throw new Error('slash Skill send must preserve Skill invocation feedback'); + startTurn: async () => { + throw new Error('a Skill Message must not route around Host admission'); }, - startTurn: async (input) => { - starts.push(input); + submitMessage: async (input) => { + submits.push(input); return { - kind: 'started', - turn: { - sessionId: input.sessionId, - turnId: input.turnId, - runId: 'run-skill', - status: 'running', - }, + disposition: 'blocked', skillInvocation: { loaded: [], failed: [{ request: 'missing', reason: 'not_found' }], @@ -694,22 +681,20 @@ test('keeps slash Skill sends on the exact-Turn path with their stable message i ipc, ); - const result = await ipc.invoke('sessions:send', 'session-1', { - type: 'send', + const result = await ipc.invoke('sessions:submitMessage', 'session-1', 'current_turn', { messageId: 'message-skill', text: '/skill:missing inspect this', }); - assert.deepEqual(starts, [{ + assert.deepEqual(submits, [{ sessionId: 'session-1', - turnId: 'message-skill', + messageId: 'message-skill', + placement: 'current_turn', content: { text: '/skill:missing inspect this', inlineReferences: [] }, }]); assert.deepEqual(result, { - ok: true, - turnId: 'message-skill', - attachments: [], - inlineReferences: [], + ok: false, + reason: 'skill_invocation_failed', skillInvocation: { loaded: [], failed: [{ request: 'missing', reason: 'not_found' }], @@ -817,7 +802,7 @@ test("retries a dispatched normal send with its original Turn identity", async ( const result = await ipc.invoke("sessions:send", "session-1", { type: "send", - messageId: 'message-1', + turnId: 'message-1', text: "keep this Turn identity", }); @@ -961,12 +946,13 @@ test("retries a dispatched busy fallback with its original message identity", as assert.deepEqual( await ipc.invoke("sessions:send", "session-1", { type: "send", - messageId: "turn-unknown", + turnId: "turn-unknown", text: "ordinary chat keeps the existing failure contract", }), { ok: false, reason: "outcome_unknown", + messageId: "turn-unknown", skillInvocation: { loaded: [], failed: [], receipts: [] }, }, ); @@ -1146,7 +1132,7 @@ test("queues explicit Desktop follow-ups", async () => { ); assert.deepEqual( - await ipc.invoke("sessions:enqueue", "session-1", "next_turn", { + await ipc.invoke("sessions:submitMessage", "session-1", "next_turn", { messageId: "followup-message", text: "do this next", quotes: [{ text: "quoted context" }], @@ -1165,7 +1151,8 @@ test("queues explicit Desktop follow-ups", async () => { ], }), { - kind: "queued", + ok: true, + disposition: "followup", attachments: [ { kind: "other", @@ -1180,6 +1167,7 @@ test("queues explicit Desktop follow-ups", async () => { }, ], inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, }, ); assert.deepEqual(submits, [ @@ -1234,11 +1222,11 @@ test('keeps an unknown Desktop follow-up admission available for reconciliation' ); assert.deepEqual( - await ipc.invoke('sessions:enqueue', 'session-1', 'next_turn', { + await ipc.invoke('sessions:submitMessage', 'session-1', 'next_turn', { messageId: 'followup-unknown', text: 'keep this visible', }), - { kind: 'outcome_unknown' }, + { ok: false, reason: 'outcome_unknown' }, ); }); diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 3cd5846844..fcc91c8e5a 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -101,7 +101,10 @@ describe('single live-turn handoff', () => { { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, ], transientMessages: [ - { type: 'user', id: 'message-pending', turnId: 'message-pending', ts: 2, text: 'send now' }, + { + type: 'user', id: 'message-pending', turnId: 'message-pending', ts: 2, + text: 'send now', transientPlacement: 'current_turn', + }, ], scrollBehavior: 'smooth', onNew() {}, @@ -121,7 +124,10 @@ describe('single live-turn handoff', () => { }, messages: [], transientMessages: [ - { type: 'user', id: 'turn-1', turnId: 'turn-1', ts: 1, text: 'send now' }, + { + type: 'user', id: 'turn-1', turnId: 'turn-1', ts: 1, text: 'send now', + transientPlacement: 'current_turn', + }, ], messageLoading: true, scrollBehavior: 'smooth', @@ -154,7 +160,7 @@ describe('single live-turn handoff', () => { transientMessages: [ { type: 'user', id: 'message-1', turnId: 'message-1', ts: 1, text: 'send now', - transientPlacement: 'turn_source', + transientPlacement: 'current_turn', }, { type: 'user', id: 'message-next', turnId: 'message-next', ts: 2, text: 'do this next', diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index 120bbd6a2b..bfa0358b0f 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -20,19 +20,20 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from '@maka/ui'; import { mergeTransientMessageProjection, projectQueuedTransientMessages, - reconcileTransientMessageLifecycle, reconcileTransientMessages, } from '../../renderer/transient-message-projection.js'; -const transient: Extract = { +const transient: TransientUserMessageProjection = { type: 'user', id: 'message-1', - turnId: 'turn-1', + turnId: 'message-1', ts: 2, text: 'send now', + transientPlacement: 'current_turn', }; test('keeps a transient message through sparse transcript replacement', () => { @@ -68,25 +69,6 @@ test('replaces a transient message by canonical message id exactly once', () => assert.equal(pending.size, 0); }); -test('removes only messages with durable cancellation proof after reconnect', () => { - const accepted = { ...transient, id: 'message-accepted' }; - const handedOff = { ...transient, id: 'message-handed-off' }; - const cancelled = { ...transient, id: 'message-cancelled' }; - const unknown = { ...transient, id: 'message-unknown' }; - const pending = new Map( - [accepted, handedOff, cancelled, unknown].map((message) => [message.id, message]), - ); - - reconcileTransientMessageLifecycle(pending, [ - { messageId: accepted.id, status: 'accepted' }, - { messageId: handedOff.id, status: 'handed_off' }, - { messageId: cancelled.id, status: 'cancelled' }, - { messageId: unknown.id, status: 'unknown' }, - ]); - - assert.deepEqual([...pending.keys()], [accepted.id, handedOff.id, unknown.id]); -}); - test('canonicalizing one send does not hide a later transient send', () => { const second = { ...transient, @@ -164,20 +146,11 @@ test('uses the Host queue snapshot order for already-present transient messages' }); test('keeps a Host-bound current Turn when a later IPC result has no Turn identity', () => { - const hostBound = { - ...transient, - id: 'message-current', - turnId: 'host-turn', - transientPlacement: 'current_turn' as const, - }; - const lateIpcUpdate = { - ...hostBound, - turnId: hostBound.id, - text: 'uploaded content', - }; + const hostBound = { ...transient, id: 'message-current', hostTurnId: 'host-turn' }; + const lateIpcUpdate = { ...transient, id: 'message-current', text: 'uploaded content' }; assert.deepEqual(mergeTransientMessageProjection(hostBound, lateIpcUpdate), { ...lateIpcUpdate, - turnId: 'host-turn', + hostTurnId: 'host-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 6ffd2e96fe..709c655816 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 @@ -26,7 +26,6 @@ import { } from '@maka/runtime-host/client'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { isSideConversationSession } from '@maka/core/side-conversation'; -import { parseSkillInvocationTokens } from '@maka/runtime/skill-invocation'; import { type SessionChangedEvent, type SessionChangedReason, @@ -113,6 +112,9 @@ type RuntimeHostSessionExecutionClient = Pick< | "updateSessionConfiguration" >; +/** No Skill was named, so the Host resolved none. */ +const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] } as const; + async function submitMessageWithReconnect( client: Pick, input: Parameters[0], @@ -193,7 +195,7 @@ export function registerRuntimeHostSessionExecutionIpc( const stopSession = createRuntimeHostSessionStop(deps, newId); ipcMain.handle( - 'sessions:queryMessageStatuses', + 'sessions:queryCancelledMessages', async (_event, sessionId: string, messageIds: unknown) => { if (!Array.isArray(messageIds)) throw new Error('Invalid Message identities'); return deps.client.queryMessages({ sessionId, messageIds }); @@ -273,7 +275,7 @@ export function registerRuntimeHostSessionExecutionIpc( if (!session) throw new Error(`Runtime Host Session not found: ${sessionId}`); const sideConversation = isSideConversationSession(session.labels); - const turnId = command.turnId ?? command.messageId ?? newId(); + const turnId = command.turnId ?? newId(); let attachments = retainedAttachmentsForSession( sessionId, command.retainedAttachments ?? [], @@ -331,49 +333,6 @@ export function registerRuntimeHostSessionExecutionIpc( ? { turnOrchestration: command.turnOrchestration } : {}), }; - if ( - command.messageId !== undefined && - !sideConversation && - (command.skillIds?.length ?? 0) === 0 && - parseSkillInvocationTokens(command.text).length === 0 && - command.turnOrchestration === undefined - ) { - const submitted = await submitMessageWithReconnect(deps.client, { - sessionId, - messageId: command.messageId, - content: startInput.content, - placement: 'current_turn', - }); - const skillInvocation = { loaded: [], failed: [], receipts: [] }; - if (!submitted) { - return { - ok: false as const, - reason: 'outcome_unknown' as const, - skillInvocation, - }; - } - if (submitted.disposition === 'turn_started') { - deps.emitSessionsChanged('status-change', sessionId, { - turnId: submitted.turnId, - }); - return { - ok: true as const, - disposition: submitted.disposition, - turnId: submitted.turnId, - attachments, - inlineReferences, - skillInvocation, - }; - } - deps.emitSessionsChanged('status-change', sessionId); - return { - ok: true as const, - disposition: submitted.disposition, - attachments, - inlineReferences, - skillInvocation, - }; - } let startResult; try { startResult = sideConversation @@ -405,22 +364,19 @@ export function registerRuntimeHostSessionExecutionIpc( // 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, messageId, content: startInput.content, placement: 'current_turn' as const, }; - const submitted = sideConversation - ? await submitMessageWithReconnect(deps.client, submitInput) - : await deps.client.submitMessage(submitInput); + const submitted = await submitMessageWithReconnect(deps.client, submitInput); if (!submitted) { return { ok: false as const, reason: 'outcome_unknown' as const, messageId, - skillInvocation: emptySkillInvocation, + skillInvocation: EMPTY_SKILL_INVOCATION, }; } if (submitted.disposition === "turn_started") { @@ -432,7 +388,7 @@ export function registerRuntimeHostSessionExecutionIpc( turnId: submitted.turnId, attachments, inlineReferences, - skillInvocation: emptySkillInvocation, + skillInvocation: EMPTY_SKILL_INVOCATION, }; } // The steering renderer believed this session idle; nudge it to @@ -445,7 +401,7 @@ export function registerRuntimeHostSessionExecutionIpc( ...(sideConversation ? { messageId } : {}), attachments, inlineReferences, - skillInvocation: emptySkillInvocation, + skillInvocation: EMPTY_SKILL_INVOCATION, }; } if (startResult.kind === "blocked") { @@ -488,7 +444,7 @@ export function registerRuntimeHostSessionExecutionIpc( }, ); ipcMain.handle( - "sessions:enqueue", + "sessions:submitMessage", async (event, sessionId: string, placement: unknown, value: unknown) => { if (placement !== "current_turn" && placement !== "next_turn") { throw new Error("Invalid message placement"); @@ -497,10 +453,7 @@ export function registerRuntimeHostSessionExecutionIpc( ...(value && typeof value === "object" ? value : {}), type: "send", }); - if (!command) throw new Error("Invalid queued message"); - if ((command.skillIds?.length ?? 0) > 0 || command.turnOrchestration) { - throw new Error("Queued control input is not available"); - } + if (!command) throw new Error("Invalid submitted message"); const session = await deps.client.getSession(sessionId); if (!session) { throw new Error(`Runtime Host Session not found: ${sessionId}`); @@ -534,12 +487,19 @@ export function registerRuntimeHostSessionExecutionIpc( if (attachments.length > MAX_ATTACHMENT_COUNT) { throw new Error("Too many attachments"); } - const displayText = command.displayText ?? command.text; + const displayText = + command.displayText ?? + (command.text.trim().length > 0 + ? command.text + : (command.skillIds ?? []).map((id) => `/skill:${id}`).join(" ")); const inlineReferences = mergeWorkspaceFileInlineReferences({ displayText, workspaceFileReferences: command.workspaceFileReferences, }); const messageId = command.messageId ?? newId(); + // Skill and orchestration intent travels with the Message. Runtime Host + // decides whether it opens its own Turn, steers the running one, or + // fails closed; the Desktop never routes on message content. const result = await submitMessageWithReconnect(deps.client, { sessionId, messageId, @@ -553,23 +513,41 @@ export function registerRuntimeHostSessionExecutionIpc( ...(command.quotes ? { quotes: command.quotes } : {}), inlineReferences, }, + ...((command.skillIds?.length ?? 0) > 0 ? { skillIds: command.skillIds } : {}), + ...(command.turnOrchestration + ? { turnOrchestration: command.turnOrchestration } + : {}), }); - if (!result) return { kind: 'outcome_unknown' as const }; + if (!result) return { ok: false as const, reason: 'outcome_unknown' as const }; + if (result.disposition === 'blocked') { + return { + ok: false as const, + reason: 'skill_invocation_failed' as const, + skillInvocation: result.skillInvocation, + }; + } if (result.disposition === "turn_started") { deps.emitSessionsChanged("status-change", sessionId, { turnId: result.turnId, }); return { - kind: "started" as const, + ok: true as const, + disposition: result.disposition, turnId: result.turnId, attachments, inlineReferences, + skillInvocation: result.skillInvocation ?? EMPTY_SKILL_INVOCATION, }; } + // The submitting surface believed this Session idle when it steered; + // nudge it to refresh so its composer converges on the running Turn. + deps.emitSessionsChanged("status-change", sessionId); return { - kind: "queued" as const, + ok: true as const, + disposition: result.disposition, attachments, inlineReferences, + skillInvocation: EMPTY_SKILL_INVOCATION, }; }, ); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 24e779bd77..4b32ace702 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -784,42 +784,6 @@ export interface MakaBridge { completeHostIds: string[]; }>; create(input?: CreateSessionRequestInput): Promise; - submitMessage( - sessionId: string, - command: { - type: 'send'; - messageId: string; - text: string; - displayText?: string; - skillIds?: string[]; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: import('@maka/core/events').AttachmentRef[]; - turnOrchestration?: never; - quotes?: import('@maka/core/events').QuoteRef[]; - workspaceFileReferences?: Array< - Pick - >; - }, - ): Promise< - | { - ok: true; - disposition: 'turn_started' | 'steering' | 'followup'; - turnId?: string; - attachments: import('@maka/core/events').AttachmentRef[]; - inlineReferences: import('@maka/core/events').InlineReference[]; - skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; - } - | { - ok: false; - reason: 'skill_invocation_failed'; - skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; - } - | { - ok: false; - reason: 'outcome_unknown'; - skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; - } - >; send( sessionId: string, command: { @@ -889,13 +853,20 @@ export interface MakaBridge { | { kind: 'outcome_unknown'; messageId: string } | { kind: 'started'; turnId: string } >; - enqueue( + /** + * The single Message admission path. Skill and orchestration intent travel + * with the Message; Runtime Host decides whether it opens its own Turn, + * steers the running one, or fails closed. + */ + submitMessage( sessionId: string, placement: 'current_turn' | 'next_turn', command: { messageId: string; text: string; displayText?: string; + skillIds?: string[]; + turnOrchestration?: TurnOrchestration; attachmentItems?: RendererIngestInput[]; retainedAttachments?: import('@maka/core/events').AttachmentRef[]; quotes?: import('@maka/core/events').QuoteRef[]; @@ -905,14 +876,21 @@ export interface MakaBridge { }, ): Promise< | { - kind: 'queued' | 'started'; + ok: true; + disposition: 'turn_started' | 'steering' | 'followup'; turnId?: string; attachments: import('@maka/core/events').AttachmentRef[]; inlineReferences: import('@maka/core/events').InlineReference[]; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } + | { + ok: false; + reason: 'skill_invocation_failed'; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } - | { kind: 'outcome_unknown' } + | { ok: false; reason: 'outcome_unknown' } >; - queryMessageStatuses( + queryCancelledMessages( sessionId: string, messageIds: readonly string[], ): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0b06ac32a7..1f90ee20fd 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -854,47 +854,6 @@ async function createDesktopSessionOnScope( return projectSessionSummary(scope, session); } -function sendDesktopSessionCommand( - sessionId: string, - command: Parameters[1], -): ReturnType; -function sendDesktopSessionCommand( - sessionId: string, - command: Parameters[1], -): ReturnType; -async function sendDesktopSessionCommand( - sessionId: string, - command: - | Parameters[1] - | Parameters[1], -): Promise< - | Awaited> - | Awaited> -> { - const session = await runtimeHostSessionRef(sessionId); - const send = async (input: SessionCommand | Record) => { - const result = (await ipcRenderer.invoke( - 'sessions:send', - session.scope, - session.sessionId, - input, - )) as - | Awaited> - | Awaited>; - return result.ok - ? { - ...result, - attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), - } - : result; - }; - if (command.type === 'send' && 'attachmentItems' in command && command.attachmentItems) { - const encoded = await encodeIngestItems(command.attachmentItems as RendererIngestInput[]); - return send({ ...command, attachmentItems: encoded }); - } - return send(command); -} - function sendActiveRuntimeHost(channel: string, ...args: unknown[]): void { void activeRuntimeHostRef() .then((scope) => ipcRenderer.send(channel, scope, ...args)) @@ -1630,11 +1589,21 @@ const makaBridge = { const scope = await activeRuntimeHostRef(); return createDesktopSessionOnScope(scope, input); }, - submitMessage(sessionId, command) { - return sendDesktopSessionCommand(sessionId, command); - }, - send(sessionId, command) { - return sendDesktopSessionCommand(sessionId, command); + async send(sessionId, command) { + const session = await runtimeHostSessionRef(sessionId); + const encoded = + 'attachmentItems' in command && command.attachmentItems + ? { ...command, attachmentItems: await encodeIngestItems(command.attachmentItems) } + : command; + const result = (await ipcRenderer.invoke( + 'sessions:send', + session.scope, + session.sessionId, + encoded, + )) as Awaited>; + return result.ok + ? { ...result, attachments: projectDesktopAttachmentRefs(session.scope, result.attachments) } + : result; }, compact(sessionId: string): Promise> { return invokeSessionRuntimeHost('sessions:compact', sessionId); @@ -1666,33 +1635,13 @@ const makaBridge = { > { return invokeSessionRuntimeHost('sessions:steer', sessionId, text, admissionId); }, - async enqueue( - sessionId: string, - placement: 'current_turn' | 'next_turn', - command: { - messageId: string; - text: string; - displayText?: string; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: AttachmentRef[]; - quotes?: QuoteRef[]; - workspaceFileReferences?: Array>; - }, - ): Promise< - | { - kind: 'queued' | 'started'; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - } - | { kind: 'outcome_unknown' } - > { + async submitMessage(sessionId, placement, command) { const session = await runtimeHostSessionRef(sessionId); const attachmentItems = command.attachmentItems ? await encodeIngestItems(command.attachmentItems) : undefined; - const result = await ipcRenderer.invoke( - 'sessions:enqueue', + const result = (await ipcRenderer.invoke( + 'sessions:submitMessage', session.scope, session.sessionId, placement, @@ -1700,29 +1649,13 @@ const makaBridge = { ...command, ...(attachmentItems ? { attachmentItems } : {}), }, - ) as - | { - kind: 'queued' | 'started'; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - } - | { kind: 'outcome_unknown' }; - if (result.kind === 'outcome_unknown') return result; - return { - ...result, - attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), - }; + )) as Awaited>; + return result.ok + ? { ...result, attachments: projectDesktopAttachmentRefs(session.scope, result.attachments) } + : result; }, - queryMessageStatuses( - sessionId: string, - messageIds: readonly string[], - ): Promise { - return invokeSessionRuntimeHost( - 'sessions:queryMessageStatuses', - sessionId, - messageIds, - ); + queryCancelledMessages(sessionId, messageIds) { + return invokeSessionRuntimeHost('sessions:queryCancelledMessages', sessionId, messageIds); }, retractQueueEntry(sessionId: string, entryId: string): Promise { return invokeSessionRuntimeHost('sessions:retractQueueEntry', sessionId, entryId); diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index a691a80c95..86c7233d76 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -159,7 +159,7 @@ export function createAppShellChatActions(deps: { sessionId: string, message: TransientUserMessageProjection, ) => void; - updateTransientMessage?: ( + updateTransientMessage: ( sessionId: string, message: TransientUserMessageProjection, ) => void; @@ -233,58 +233,37 @@ export function createAppShellChatActions(deps: { } = deps; const copy = getShellCopy(uiLocale).chatActions; - function optimisticUserMessage( - messageId: string, - turnId: string, - text: string, - attachments: readonly import('@maka/core/events').AttachmentRef[] = [], - quotes: readonly QuoteRef[] = [], - inlineReferences: readonly InlineReference[] = [], - transientPlacement?: TransientUserMessageProjection['transientPlacement'], - ): TransientUserMessageProjection { - return { - type: 'user', - id: messageId, - // StoredMessage requires a grouping key, but transient messages are - // rendered beside the Turn projection. Canonical transcript data later - // supplies the Host-owned grouping for this same message id. - turnId, - ts: Date.now(), - text, - ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), - ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), - inlineReferences: [...inlineReferences], - ...(transientPlacement ? { transientPlacement } : {}), - }; - } - - function showOptimisticUserMessage( + function showTransientUserMessage( sessionId: string, messageId: string, text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], options: { - turnId?: string; + placement?: TransientUserMessageProjection['transientPlacement']; + hostTurnId?: string; updateOnly?: boolean; - transientPlacement?: TransientUserMessageProjection['transientPlacement']; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; } = {}, ): void { - const next = optimisticUserMessage( - messageId, - options.turnId ?? messageId, + const quotes = options.quotes ?? []; + const next: TransientUserMessageProjection = { + type: 'user', + id: messageId, + // StoredMessage requires a grouping key, but a transient row is not a + // Turn member yet: `hostTurnId` carries the Host grouping once it exists, + // and canonical transcript replaces the row by this same message id. + turnId: messageId, + ts: Date.now(), text, - attachments, - options.quotes, - options.inlineReferences, - options.transientPlacement, - ); - if (options.updateOnly) { - (updateTransientMessage ?? addTransientMessage)(sessionId, next); - } else { - addTransientMessage(sessionId, next); - } + ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), + ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), + inlineReferences: [...(options.inlineReferences ?? [])], + transientPlacement: options.placement ?? 'current_turn', + ...(options.hostTurnId ? { hostTurnId: options.hostTurnId } : {}), + }; + if (options.updateOnly) updateTransientMessage(sessionId, next); + else addTransientMessage(sessionId, next); if (activeIdRef.current !== sessionId) return; setMessageLoadErrorBySession((current) => { if (!current[sessionId]) return current; @@ -390,13 +369,12 @@ export function createAppShellChatActions(deps: { if (newChatPermissionChoice) clearNewChatPermissionChoice(); optimisticSessionId = session.id; optimisticMessageId = messageId; - showOptimisticUserMessage( + showTransientUserMessage( session.id, messageId, options.displayText ?? text, [], { - transientPlacement: 'turn_source', ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }, @@ -411,7 +389,6 @@ export function createAppShellChatActions(deps: { ? retainedAttachmentRefs(pending) : undefined; const sendCommand = { - type: 'send' as const, text, ...(options.displayText ? { displayText: options.displayText } : {}), ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), @@ -423,16 +400,17 @@ export function createAppShellChatActions(deps: { ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), }; - const sendResult = options.turnOrchestration - ? await window.maka.sessions.send(session.id, { - ...sendCommand, - turnId: messageId, - turnOrchestration: options.turnOrchestration, - }) - : await window.maka.sessions.submitMessage(session.id, { - ...sendCommand, - messageId, - }); + const sendResult = await window.maka.sessions.submitMessage( + session.id, + 'current_turn', + { + ...sendCommand, + messageId, + ...(options.turnOrchestration + ? { turnOrchestration: options.turnOrchestration } + : {}), + }, + ); if (!sendResult.ok) { if (sendResult.reason === 'outcome_unknown') { unsentSessionId = undefined; @@ -470,17 +448,16 @@ export function createAppShellChatActions(deps: { if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { setNavSelection({ section: 'sessions' }); setActiveId(session.id); - showOptimisticUserMessage( + showTransientUserMessage( session.id, messageId, options.displayText ?? skillInvocationDisplayText(text, sendResult.skillInvocation), sendResult.attachments, { - turnId: sendResult.turnId ?? messageId, + ...(sendResult.turnId ? { hostTurnId: sendResult.turnId } : {}), updateOnly: true, - transientPlacement: 'turn_source', - ...(quotes && quotes.length > 0 ? { quotes } : {}), + ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: sendResult.inlineReferences ?? [], }, ); @@ -508,13 +485,12 @@ export function createAppShellChatActions(deps: { } optimisticSessionId = sessionId; optimisticMessageId = messageId; - showOptimisticUserMessage( + showTransientUserMessage( sessionId, messageId, options.displayText ?? text, [], { - transientPlacement: 'turn_source', ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }, @@ -529,7 +505,6 @@ export function createAppShellChatActions(deps: { ? retainedAttachmentRefs(pending) : undefined; const sendCommand = { - type: 'send' as const, text, ...(options.displayText ? { displayText: options.displayText } : {}), ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), @@ -541,16 +516,11 @@ export function createAppShellChatActions(deps: { ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), }; - const sendResult = options.turnOrchestration - ? await window.maka.sessions.send(sessionId, { - ...sendCommand, - turnId: messageId, - turnOrchestration: options.turnOrchestration, - }) - : await window.maka.sessions.submitMessage(sessionId, { - ...sendCommand, - messageId, - }); + const sendResult = await window.maka.sessions.submitMessage(sessionId, 'current_turn', { + ...sendCommand, + messageId, + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), + }); if (!sendResult.ok) { if (sendResult.reason === 'outcome_unknown') return true; removeOptimisticUserMessage(sessionId, messageId); @@ -574,16 +544,15 @@ export function createAppShellChatActions(deps: { sessionId, ); } - showOptimisticUserMessage( + showTransientUserMessage( sessionId, messageId, options.displayText ?? skillInvocationDisplayText(text, sendResult.skillInvocation), sendResult.attachments, { - turnId: sendResult.turnId ?? messageId, + ...(sendResult.turnId ? { hostTurnId: sendResult.turnId } : {}), updateOnly: true, - transientPlacement: 'turn_source', ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: sendResult.inlineReferences ?? [], }, @@ -660,15 +629,15 @@ export function createAppShellChatActions(deps: { ): Promise { const messageId = crypto.randomUUID(); const quotes = options.quotes ?? []; - showOptimisticUserMessage(sessionId, messageId, text, retainedAttachmentRefs(pending ?? []), { - transientPlacement: placement, + showTransientUserMessage(sessionId, messageId, text, retainedAttachmentRefs(pending ?? []), { + placement, ...(quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }); try { const attachmentItems = pending?.length ? toComposerIngestItems(pending) : []; const retainedAttachments = pending?.length ? retainedAttachmentRefs(pending) : []; - const result = await window.maka.sessions.enqueue(sessionId, placement, { + const result = await window.maka.sessions.submitMessage(sessionId, placement, { messageId, text, ...(attachmentItems.length > 0 ? { attachmentItems } : {}), @@ -678,10 +647,10 @@ export function createAppShellChatActions(deps: { ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), }); - if (result.kind === 'outcome_unknown') return; - showOptimisticUserMessage(sessionId, messageId, text, result.attachments, { + if (!result.ok) return; + showTransientUserMessage(sessionId, messageId, text, result.attachments, { updateOnly: true, - transientPlacement: placement, + placement, ...(quotes.length > 0 ? { quotes } : {}), inlineReferences: result.inlineReferences, }); diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 1b09961a02..22d56bd87b 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -298,8 +298,9 @@ export function createAppShellSessionEventHandlers(options: { .map((entry) => ({ type: 'user', id: entry.messageId, - turnId: entry.placement === 'current_turn' ? event.turnId : entry.messageId, + turnId: entry.messageId, transientPlacement: entry.placement, + ...(entry.placement === 'current_turn' ? { hostTurnId: event.turnId } : {}), ts: event.ts, text: entry.content.displayText ?? entry.content.text, ...(entry.content.attachments ? { attachments: [...entry.content.attachments] } : {}), diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 8ee255d3bc..96c114476e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -352,7 +352,7 @@ function AppShellContent({ addTransientMessage, updateTransientMessage, projectQueuedTransientMessages, - reconcileTransientMessageStatuses, + retireCancelledTransientMessages, removeTransientMessage, transcriptRangeRef, messageLoadPending, @@ -2305,7 +2305,7 @@ function AppShellContent({ const next = completeLiveContentSeed(current, sessionId, expected); activeEventSeedRef.current = next; setActiveEventSeed(next); - void reconcileTransientMessageStatuses(sessionId); + void retireCancelledTransientMessages(sessionId); }; useActiveSessionEvents({ uiLocale, diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 43c4f76250..c42d3cd34b 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -61,6 +61,14 @@ overflow-y: auto; } +.maka-composer-queue-text { + display: block; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + .maka-composer-queue-actions { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/transient-message-projection.ts index 7c89f01367..49eb1505d5 100644 --- a/apps/desktop/src/renderer/transient-message-projection.ts +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -18,7 +18,6 @@ */ import type { StoredMessage } from '@maka/core/session'; -import type { MessageLifecycleStatus } from '@maka/runtime-host/protocol'; import type { TransientUserMessageProjection } from '@maka/ui'; type TransientUserMessage = TransientUserMessageProjection; @@ -40,25 +39,17 @@ export function projectQueuedTransientMessages( for (const message of queued) transient.set(message.id, message); } +/** + * A Host-named Turn outranks a later local update that still has none: the + * IPC reply can land after the Host event that already bound this Message. + */ export function mergeTransientMessageProjection( current: TransientUserMessage, update: TransientUserMessage, ): TransientUserMessage { - const hostBoundCurrentTurn = - current.transientPlacement === 'current_turn' - && update.transientPlacement === 'current_turn' - && current.turnId !== current.id - && update.turnId === update.id; - return hostBoundCurrentTurn ? { ...update, turnId: current.turnId } : update; -} - -export function reconcileTransientMessageLifecycle( - transient: Map, - messages: readonly { messageId: string; status: MessageLifecycleStatus }[], -): void { - for (const message of messages) { - if (message.status === 'cancelled') transient.delete(message.messageId); - } + return current.hostTurnId !== undefined && update.hostTurnId === undefined + ? { ...update, hostTurnId: current.hostTurnId } + : update; } /** diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index 06223d38f0..dc51c891bf 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -32,7 +32,6 @@ import type { DesktopTranscriptRangeController } from './desktop-transcript-rang import { mergeTransientMessageProjection, projectQueuedTransientMessages as applyQueuedTransientProjection, - reconcileTransientMessageLifecycle, reconcileTransientMessages, } from './transient-message-projection.js'; @@ -134,17 +133,16 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { } } - async function reconcileTransientMessageStatuses(sessionId: string): Promise { + async function retireCancelledTransientMessages(sessionId: string): Promise { const pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending || pending.size === 0) return; try { - const result = await window.maka.sessions.queryMessageStatuses( - sessionId, - [...pending.keys()], - ); + const result = await window.maka.sessions.queryCancelledMessages(sessionId, [ + ...pending.keys(), + ]); const current = transientMessagesBySessionRef.current.get(sessionId); if (!current) return; - reconcileTransientMessageLifecycle(current, result.messages); + for (const messageId of result.cancelledMessageIds) current.delete(messageId); if (current.size === 0) transientMessagesBySessionRef.current.delete(sessionId); if (activeIdRef.current === sessionId) { setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); @@ -219,7 +217,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { addTransientMessage, updateTransientMessage, projectQueuedTransientMessages, - reconcileTransientMessageStatuses, + retireCancelledTransientMessages, removeTransientMessage, transcriptRangeRef, messageLoadPending, diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 4874d76546..3331b16877 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -37,7 +37,7 @@ import { refreshRunningShellRunElapsed, hydrateToolsWithStoredMessages, makaPiToolPresentationStatus, - reconcileTransientMessageLifecycle, + retireCancelledTransientMessages, replaceTranscriptWithStoredMessages, submitCompactToTranscript, toggleAllThinkingExpansion, @@ -309,7 +309,7 @@ describe('Maka Pi TUI transcript', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'send now', 'message-1', true); - replaceTranscriptWithStoredMessages(state, [], { preserveTransientMessages: true }); + replaceTranscriptWithStoredMessages(state, [], { preserveClientLocalEntries: true }); assert.deepEqual(state.entries, [ { kind: 'user', messageId: 'message-1', text: 'send now', transient: true }, @@ -322,11 +322,7 @@ describe('Maka Pi TUI transcript', () => { appendUserPrompt(state, 'handed off', 'message-handed-off', true); appendUserPrompt(state, 'cancelled', 'message-cancelled', true); - reconcileTransientMessageLifecycle(state, [ - { messageId: 'message-accepted', status: 'accepted' }, - { messageId: 'message-handed-off', status: 'handed_off' }, - { messageId: 'message-cancelled', status: 'cancelled' }, - ]); + retireCancelledTransientMessages(state, ['message-cancelled']); assert.deepEqual( state.entries.map((entry) => ('messageId' in entry ? entry.messageId : undefined)), @@ -355,7 +351,7 @@ describe('Maka Pi TUI transcript', () => { modelId: 'model-1', }, ], - { preserveTransientMessages: true }, + { preserveClientLocalEntries: true }, ); assert.deepEqual( @@ -394,7 +390,7 @@ describe('Maka Pi TUI transcript', () => { modelId: 'model-1', }, ], - { preserveTransientMessages: true }, + { preserveClientLocalEntries: true }, ); assert.deepEqual( @@ -423,7 +419,7 @@ describe('Maka Pi TUI transcript', () => { modelId: 'model-1', }, ], - { preserveTransientMessages: true }, + { preserveClientLocalEntries: true }, ); assert.deepEqual( @@ -441,7 +437,7 @@ describe('Maka Pi TUI transcript', () => { replaceTranscriptWithStoredMessages( state, [{ type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'send now' }], - { preserveTransientMessages: true }, + { preserveClientLocalEntries: true }, ); assert.deepEqual(state.entries, [{ kind: 'user', messageId: 'message-1', text: 'send now' }]); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index f447892794..d124b246c7 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -35,7 +35,10 @@ import { type SessionSummary, type StoredMessage } from '@maka/core/session'; import { type ThinkingLevel } from '@maka/core/model-thinking'; import { type UserQuestionResponse } from '@maka/core/user-question'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; -import type { AgentGraphClientSnapshot } from '@maka/runtime-host/protocol'; +import type { + AgentGraphClientSnapshot, + TurnMessageSubmitResult, +} from '@maka/runtime-host/protocol'; import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { type ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { GoalProjection } from '@maka/runtime-host/protocol'; @@ -295,7 +298,9 @@ describe('Maka Pi TUI runner', () => { terminal.input('run'); terminal.input('\r'); - await waitFor(() => driver.prompts.length === 1); + // The double Escape interrupts a *running* Turn, so wait for the Host- + // admitted Turn to reach the drain rather than for the Message to be sent. + await waitFor(() => driver.streamPulls === 1); terminal.input('\x1b'); terminal.input('\x1b'); await waitFor(() => driver.stopCalls === 1); @@ -1609,7 +1614,7 @@ describe('Maka Pi TUI runner', () => { ]); }); - test('waits to start a visible turn until shared session activity releases', async () => { + test('waits to drain a visible turn until shared session activity releases', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); const activities = new SessionActivityRegistry(); @@ -1627,11 +1632,14 @@ describe('Maka Pi TUI runner', () => { terminal.input('run'); terminal.input('\r'); + // Runtime Host owns admission, so the Message goes out at once; it is the + // visible Turn that waits for the Session another surface is holding. + await waitFor(() => driver.prompts.length === 1); await delay(0); - assert.deepEqual(driver.prompts, []); + assert.equal(driver.streamPulls, 0); heartbeat.release(); - await waitFor(() => driver.prompts.length === 1); + await waitFor(() => driver.streamPulls === 1); assert.deepEqual(driver.prompts, ['run']); assert.equal(activities.whenIdle('session-1'), undefined); @@ -1695,21 +1703,23 @@ describe('Maka Pi TUI runner', () => { terminal.input('run'); terminal.input('\r'); + await waitFor(() => driver.prompts.length === 1); await delay(0); - assert.deepEqual(driver.prompts, []); + assert.equal(driver.streamPulls, 0); exitMaka(terminal); await run; heartbeat.release(); await delay(0); - assert.deepEqual(driver.prompts, []); + assert.equal(driver.streamPulls, 0); assert.equal(activities.whenIdle('session-1'), undefined); }); test('flows a transcript taller than the viewport into scrollback, untruncated and un-paged', async () => { const terminal = new FakeTerminal(); const driver = new LongTranscriptDriver(); + driver.hostSummary = { model: 'deepseek-v4-flash', llmConnectionSlug: 'deepseek' }; const run = runMakaPiTui({ title: 'Maka', driver, @@ -1765,6 +1775,7 @@ describe('Maka Pi TUI runner', () => { test('browses a long transcript without depending on terminal scrollback', async () => { const terminal = new FakeTerminal(); const driver = new LongTranscriptDriver(); + driver.hostSummary = { model: 'deepseek-v4-flash', llmConnectionSlug: 'deepseek' }; const run = runMakaPiTui({ title: 'Maka', driver, @@ -2454,7 +2465,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('Enter during Host admission keeps the second prompt in the editor', async () => { + test('a second Enter during Host admission submits another Message', async () => { const terminal = new FakeTerminal(); const driver = new SteeringTurnDriver(); const admission = deferred(); @@ -2473,19 +2484,18 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('first prompt')); + // Runtime Host decides what a Message becomes, so the client neither holds + // the editor nor drops the text: the second Enter submits its own Message + // and only the keystroke typed after it stays in the draft. terminal.input('second prompt'); terminal.input('\r'); terminal.input('z'); - await waitFor(() => editorInputText(terminal) === 'second promptz'); + await waitFor(() => editorInputText(terminal) === 'z'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('second prompt')); admission.resolve(); await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('\x1b'); - terminal.input('\x1b'); - await waitFor(() => terminal.progressStates.at(-1) === false); - terminal.input('\x03'); - terminal.input('/exit'); - terminal.input('\r'); + exitMaka(terminal); await run; }); @@ -2870,6 +2880,7 @@ describe('Maka Pi TUI runner', () => { test('switches connection and model together from a cross-connection /model', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); + driver.hostSummary = { model: 'gpt-5.5', llmConnectionSlug: 'openai' }; const run = runMakaPiTui({ title: 'Maka', driver, @@ -5693,7 +5704,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => { + test('a Turn that attaches after a mid-turn detach does not adopt abandoned metadata', async () => { const terminal = new FakeTerminal(); const driver = new DetachingSwitchDriver([ storedUserMessage('user-s2', 'turn-old-2', 'history from session two'), @@ -5709,47 +5720,37 @@ describe('Maka Pi TUI runner', () => { terminal, }); - // Park preparePrompt itself: while it is unresolved, /session can - // already detach — onPrepared/onSkillInvocation then fire for the - // abandoned Turn after the epoch fence moved. - let releasePrepare!: () => void; - const parkedPrepare = new Promise((resolve) => { - releasePrepare = resolve; - }); - const basePrepare = driver.preparePrompt.bind(driver); - driver.preparePrompt = async (prompt, options) => { - const turn = await basePrepare(prompt, options); - await parkedPrepare; - return { - ...turn, - summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'), - }; - }; - terminal.input('start the long task'); terminal.input('\r'); await waitFor(() => terminal.progressStates.at(-1) === true); terminal.input('/session session-2'); terminal.input('\r'); - // Nothing else drives the frame loop while preparePrompt stays parked, - // so force a repaint for the detach notices. - terminal.resize(80, 24); await waitFor(() => plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'), ); assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/); - // The abandoned Turn's prepare resolves only now — its summary must - // not steal the adopted Session's metadata. - releasePrepare(); driver.releaseOldTurn(); await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done')); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The Host keeps running the abandoned Session's Turn and announces a + // successor on it. That Turn belongs to a Session this client left, so + // neither its transcript nor its metadata may reach the adopted view. + driver.announceStartedTurn({ + sessionId: 'session-1', + turnId: 'turn-abandoned', + events: (async function* () {})(), + messages: [storedUserMessage('user-abandoned', 'turn-abandoned', 'ABANDONED MESSAGE')], + summary: fakeSessionSummary('session-1', '/abandoned-cwd', 'ABANDONED TITLE'), + }); + await delay(0); assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false); assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)'); assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /ABANDONED MESSAGE/); - await waitFor(() => terminal.progressStates.at(-1) === false); terminal.input('/exit'); terminal.input('\r'); await run; @@ -6452,7 +6453,9 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('(4s · 2 lines)')); await waitFor(() => terminal.progressStates.at(-1) === false); assert.deepEqual(driver.prompts, ['first']); - assert.deepEqual(driver.shellRunReads, ['session-1']); + // Every attached Turn hydrates background ShellRun state, the visible one + // this client submitted included. + assert.deepEqual(driver.shellRunReads, ['session-1', 'session-1']); terminal.input('/exit'); terminal.input('\r'); @@ -6552,6 +6555,27 @@ class ThrowingFocusReportTerminal extends FakeTerminal { } class RejectingStopDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + stopCalls = 0; async listSessions(): Promise { @@ -6592,6 +6616,27 @@ class RejectingStopDriver implements MakaSessionDriver { } class SandboxBoundaryPromptDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + readonly boundaryResponses: SandboxBoundaryResponse[] = []; boundaryRequests = 0; stopCalls = 0; @@ -6692,6 +6737,27 @@ class SandboxBoundaryPromptDriver implements MakaSessionDriver { } class UserQuestionPromptDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + readonly responses: UserQuestionResponse[] = []; stopCalls = 0; private release: (() => void) | undefined; @@ -6754,6 +6820,27 @@ class UserQuestionPromptDriver implements MakaSessionDriver { } class InterruptibleTurnDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + stopCalls = 0; readonly prompts: string[] = []; private releaseTurn: (() => void) | null = null; @@ -6769,7 +6856,11 @@ class InterruptibleTurnDriver implements MakaSessionDriver { async *compactSession(): AsyncIterable {} + /** Bumped when the drain first pulls the Turn's stream. */ + streamPulls = 0; + async *promptEvents(_prompt: string): AsyncIterable { + this.streamPulls += 1; // The turn parks like a real long-running provider call until stop() aborts it. await new Promise((resolve) => { this.releaseTurn = resolve; @@ -6814,6 +6905,10 @@ class InterruptibleTurnDriver implements MakaSessionDriver { // keybindings (Enter steer, Alt+Enter queue, Alt+↑ retract, Esc Esc refill) can // be exercised end-to-end without a real runtime. class SteeringTurnDriver implements MakaSessionDriver { + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + stopCalls = 0; goal: GoalProjection | null = null; readonly steered: string[] = []; @@ -6822,6 +6917,7 @@ class SteeringTurnDriver implements MakaSessionDriver { nextSubmitError: Error | undefined; submitGate: Promise | undefined; startedTurnMessages: StoredMessage[] = []; + hostSummary: Partial = {}; retractCalls = 0; rewindTargets: RewindTarget[] = []; private steering: Array<{ messageId: string; text: string }> = []; @@ -6888,24 +6984,29 @@ class SteeringTurnDriver implements MakaSessionDriver { yield { type: 'complete', id: 'event-complete', turnId, ts: 2, stopReason: 'user_stop' }; } - async submitMessage(text: string, options: MakaSubmitMessageOptions) { + async submitMessage(text: string, options: MakaSubmitMessageOptions): Promise { + // Stays in place: a gate set by a test holds every Message that arrives + // while the Host has not answered the first one. await this.submitGate; - this.submitGate = undefined; if (this.nextSubmitError) { const error = this.nextSubmitError; this.nextSubmitError = undefined; throw error; } if (!this.turnOpen) { - const turn = await this.preparePrompt(text); + const turn = await this.preparePrompt(text, { + turnId: options.messageId, + ...(options.modelText !== undefined ? { modelText: options.modelText } : {}), + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), + }); queueMicrotask(() => this.startedTurnListener?.({ ...turn, messages: this.startedTurnMessages, - summary: fakeSessionSummary(turn.sessionId), + summary: { ...fakeSessionSummary(turn.sessionId), ...this.hostSummary }, }), ); - return; + return undefined; } if (options.placement === 'current_turn') { this.steered.push(text); @@ -6915,6 +7016,7 @@ class SteeringTurnDriver implements MakaSessionDriver { this.followup.push({ messageId: options.messageId, text }); } this.emitQueueUpdate(); + return undefined; } subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { @@ -6979,6 +7081,27 @@ class FailingOrchestrationDriver extends SteeringTurnDriver { } class SlowStopDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + stopCalls = 0; readonly prompts: string[] = []; private releaseTurn: (() => void) | null = null; @@ -7040,6 +7163,27 @@ class SlowStopDriver implements MakaSessionDriver { } class ToolOutputDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + async listSessions(): Promise { return []; } @@ -7273,6 +7417,27 @@ function pipeOutput(stdout = '', stderr = '') { } class SlashCommandDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + /** Model-facing text (options.modelText when set, else the typed prompt). */ readonly prompts: string[] = []; /** Human-facing typed prompt for every prepared turn. */ @@ -7385,7 +7550,11 @@ class SlashCommandDriver implements MakaSessionDriver { : { available: false, reason: 'Missing working directory' }; } + /** Bumped when the drain first pulls the Turn's stream. */ + streamPulls = 0; + async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable { + this.streamPulls += 1; yield { type: 'complete', id: 'event-complete', @@ -7497,6 +7666,22 @@ class HostSkillDriver extends SlashCommandDriver { super(); } + // The Host answers a refused invocation with a `blocked` disposition rather + // than a Turn; the driver surfaces that as the submit result. + override async submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + try { + return await super.submitMessage(text, options); + } catch (error) { + if (error instanceof SkillInvocationBlockedError) { + return { disposition: 'blocked', skillInvocation: error.skillInvocation }; + } + throw error; + } + } + override async preparePrompt( prompt: string, options: MakaPreparePromptOptions = {}, @@ -7656,6 +7841,12 @@ class ActiveResumeDriver extends SlashCommandDriver { // (submitted after switching) complete immediately. class DetachingSwitchDriver extends SlashCommandDriver { stopCalls = 0; + + /** Pushes a Host-started Turn the way the real started-turn stream would. */ + announceStartedTurn(turn: MakaAttachedSessionTurn): void { + this.startedTurnListener?.(turn); + } + /** When set, the next switchSession rejects — a failed detach must leave * the running drain fully live. */ failNextSwitch = false; @@ -7754,7 +7945,6 @@ class DetachingSwitchDriver extends SlashCommandDriver { } class HostSuccessorDriver extends SlashCommandDriver { - #startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; readonly #probeFirst = deferred(); #finishFirst: (() => void) | undefined; successorPulls = 0; @@ -7784,17 +7974,10 @@ class HostSuccessorDriver extends SlashCommandDriver { yield { type: 'complete', id: 'complete-first', turnId, ts: 3, stopReason: 'end_turn' }; } - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.#startedTurnListener = listener; - return () => { - if (this.#startedTurnListener === listener) this.#startedTurnListener = undefined; - }; - } - publishSuccessor(): void { const turnId = 'turn-second'; const driver = this; - this.#startedTurnListener?.({ + this.startedTurnListener?.({ sessionId: this.getSessionId()!, turnId, messages: [ @@ -7958,6 +8141,27 @@ class LongTranscriptDriver extends SlashCommandDriver { } class DeferredControlDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + readonly prompts: string[] = []; readonly models: string[] = []; private resolveSetModel: (() => void) | null = null; @@ -8018,6 +8222,27 @@ class DeferredControlDriver implements MakaSessionDriver { } class RejectingSandboxBoundaryDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + readonly responses: SandboxBoundaryResponse[] = []; async listSessions(): Promise { @@ -8095,6 +8320,27 @@ class DeferredListSessionsDriver extends SlashCommandDriver { } class SandboxBoundaryThenErrorDriver implements MakaSessionDriver { + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial = {}; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + respondCalls = 0; private resolveContinue: (() => void) | null = null; @@ -8241,6 +8487,41 @@ function switchResult( return { summary, messages }; } +interface HostAdmittingDriver { + preparePrompt( + prompt: string, + options?: MakaPreparePromptOptions, + ): Promise; + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + hostSummary: Partial; +} + +/** + * Emulates Runtime Host admission: an idle Session turns the submitted Message + * into a Turn the TUI then attaches to. `hostSummary` is what the Host reports + * for that Session, so a test whose TUI runs on a non-default model points it + * there instead of letting the default summary rewrite the status line. + */ +async function admitMessageAsTurn( + driver: HostAdmittingDriver, + text: string, + options: MakaSubmitMessageOptions, +): Promise { + const turn = await driver.preparePrompt(text, { + turnId: options.messageId, + ...(options.modelText !== undefined ? { modelText: options.modelText } : {}), + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), + }); + queueMicrotask(() => + driver.startedTurnListener?.({ + ...turn, + messages: [], + summary: { ...fakeSessionSummary(turn.sessionId), ...driver.hostSummary }, + }), + ); + return undefined; +} + function fakeSessionSummary( sessionId: string, cwd = '/repo', diff --git a/packages/cli/src/__tests__/pi-tui-turn.test.ts b/packages/cli/src/__tests__/pi-tui-turn.test.ts index f3a61af77c..a692536194 100644 --- a/packages/cli/src/__tests__/pi-tui-turn.test.ts +++ b/packages/cli/src/__tests__/pi-tui-turn.test.ts @@ -24,89 +24,27 @@ import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { runMakaPiTuiTurn } from '../pi-tui-turn.js'; describe('Maka Pi TUI turn', () => { - test('submits an ordinary message once and leaves Turn projection to the Host subscription', async () => { - const sequence: string[] = []; - const outcome = await runMakaPiTuiTurn({ - driver: { - async preparePrompt() { - throw new Error('ordinary admission must not start a renderer-owned Turn'); - }, - async submitMessage(prompt, options) { - sequence.push('submit'); - assert.equal(prompt, 'visible prompt'); - assert.deepEqual(options, { - messageId: 'message-1', - placement: 'current_turn', - modelText: 'expanded prompt', - }); - }, - }, - turnActivity: { activities: new SessionActivityRegistry() }, - request: { - kind: 'external', - prompt: 'visible prompt', - turnId: 'message-1', - sendText: 'expanded prompt', - sessionId: null, - }, - shouldAbort: () => false, - onStart: () => sequence.push('start'), - onPrepared: () => { - sequence.push('prepared'); - }, - }); - - assert.deepEqual(outcome, { kind: 'admitted' }); - assert.deepEqual(sequence, ['start', 'submit']); - }); - - test('prepares and drains an external turn under one Session activity lease', async () => { + test('drains an attached turn under one Session activity lease', async () => { const activities = new SessionActivityRegistry(); const sequence: string[] = []; - let startedTurnId: string | undefined; const outcome = await runMakaPiTuiTurn({ - driver: { - async preparePrompt(prompt, options) { - sequence.push('prepare'); - assert.equal(prompt, 'visible prompt'); - assert.deepEqual(options, { - turnId: 'turn-1', - modelText: 'expanded prompt', - turnOrchestration: { mode: 'swarm', source: 'slash_command' }, - }); - return preparedTurn([ - event({ - type: 'text_delta', - messageId: 'message-1', - text: 'working', - }), - event({ type: 'complete', stopReason: 'end_turn' }), - ]); - }, - }, turnActivity: { activities }, request: { - kind: 'external', - prompt: 'visible prompt', - turnId: 'turn-1', - sendText: 'expanded prompt', - sessionId: null, - turnOrchestration: { mode: 'swarm', source: 'slash_command' }, + turn: preparedTurn([ + event({ type: 'text_delta', messageId: 'message-1', text: 'working' }), + event({ type: 'complete', stopReason: 'end_turn' }), + ]), }, shouldAbort: () => false, - onStart: (turnId) => { - startedTurnId = turnId; - sequence.push('start'); - }, + onStart: () => sequence.push('start'), onEvent: (sessionEvent) => { sequence.push(`event:${sessionEvent.type}`); }, }); assert.deepEqual(outcome, { kind: 'completed', turnId: 'turn-1' }); - assert.equal(startedTurnId, 'turn-1'); - assert.deepEqual(sequence, ['start', 'prepare', 'event:text_delta', 'event:complete']); + assert.deepEqual(sequence, ['start', 'event:text_delta', 'event:complete']); assert.equal(activities.whenIdle('session-1'), undefined); }); @@ -115,13 +53,8 @@ describe('Maka Pi TUI turn', () => { const failures: string[] = []; const outcome = await runMakaPiTuiTurn({ - driver: { - async preparePrompt() { - return preparedTurn([]); - }, - }, turnActivity: { activities }, - request: { kind: 'external', prompt: 'hello', turnId: 'turn-1', sessionId: null }, + request: { turn: preparedTurn([]) }, shouldAbort: () => false, onFailure: (error) => { failures.push(errorMessage(error)); @@ -137,31 +70,36 @@ describe('Maka Pi TUI turn', () => { assert.equal(activities.whenIdle('session-1'), undefined); }); - test('releases existing-session activity when preparation fails', async () => { + test('releases the Session activity when the attached stream fails', async () => { const activities = new SessionActivityRegistry(); const failures: string[] = []; const outcome = await runMakaPiTuiTurn({ - driver: { - async preparePrompt() { - assert.ok(activities.whenIdle('session-1')); - throw new Error('prepare failed'); + turnActivity: { activities }, + request: { + turn: { + sessionId: 'session-1', + turnId: 'turn-1', + events: failingEvents('stream failed'), }, }, - turnActivity: { activities }, - request: { kind: 'external', prompt: 'hello', turnId: 'turn-1', sessionId: 'session-1' }, shouldAbort: () => false, onFailure: (error) => { failures.push(errorMessage(error)); }, }); - assert.deepEqual(outcome, { kind: 'errored', turnId: 'turn-1', reason: 'prepare failed' }); - assert.deepEqual(failures, ['prepare failed']); + assert.deepEqual(outcome, { kind: 'errored', turnId: 'turn-1', reason: 'stream failed' }); + assert.deepEqual(failures, ['stream failed']); assert.equal(activities.whenIdle('session-1'), undefined); }); }); +async function* failingEvents(reason: string): AsyncIterable { + await Promise.resolve(); + throw new Error(reason); +} + function preparedTurn(events: readonly SessionEvent[]) { return { sessionId: 'session-1', diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 4cd99926a2..0c737c9963 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -60,7 +60,7 @@ import { goalStatusLineText, isLiveGoalStatus } from './pi-goal.js'; import { renderToolBlock } from './pi-transcript-tools.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; import { renderTuiShortcutCopy } from './tui-shortcut-copy.js'; -import type { GoalProjection, MessageLifecycleStatus } from '@maka/runtime-host/protocol'; +import type { GoalProjection } from '@maka/runtime-host/protocol'; export interface MakaPiUsageSummary { /** Cumulative cost in USD across the session. */ @@ -255,13 +255,11 @@ export function appendUserPrompt( state.entries.push(entry); } -export function reconcileTransientMessageLifecycle( +export function retireCancelledTransientMessages( state: MakaPiTranscriptState, - messages: readonly { messageId: string; status: MessageLifecycleStatus }[], + cancelledMessageIds: readonly string[], ): void { - const cancelled = new Set( - messages.filter(({ status }) => status === 'cancelled').map(({ messageId }) => messageId), - ); + const cancelled = new Set(cancelledMessageIds); if (cancelled.size === 0) return; state.entries = state.entries.filter( (entry) => entry.kind !== 'user' || entry.transient !== true || !cancelled.has(entry.messageId), @@ -349,58 +347,42 @@ export function applyShellRunUpdateToTranscript( export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], - options: { preserveTransientMessages?: boolean } = {}, + options: { preserveClientLocalEntries?: boolean } = {}, ): void { const durableMessageIds = new Set(messages.map((message) => message.id)); const durableEntries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); const durableEntryIds = new Set(durableEntries.map(transcriptEntryId).filter(Boolean)); - const transientEntries = options.preserveTransientMessages - ? state.entries.flatMap((entry, index) => { - if ( - entry.kind !== 'user' || - entry.transient !== true || - durableMessageIds.has(entry.messageId) - ) { - return []; - } - const priorEntries = state.entries.slice(0, index); - const nextDurableId = state.entries - .slice(index + 1) - .map(transcriptEntryId) - .find((messageId) => messageId !== undefined && durableEntryIds.has(messageId)); - const previousDurableId = priorEntries - .map(transcriptEntryId) - .reverse() - .find((messageId) => messageId !== undefined && durableEntryIds.has(messageId)); - const hadPrecedingDurable = priorEntries.some( - (candidate) => - !(candidate.kind === 'user' && candidate.transient === true) && - transcriptEntryId(candidate) !== undefined, - ); - return [{ entry, nextDurableId, previousDurableId, hadPrecedingDurable }]; - }) - : []; + // Client-local entries have no durable counterpart to arrive in `messages`: + // a transient user row still waiting for its canonical message, and every + // notice the client itself wrote (recap, skill card, error). A replacement + // that dropped them would erase what the client just told the user. + const isClientLocal = (entry: MakaPiTranscriptEntry): boolean => + entry.kind === 'notice' || + (entry.kind === 'user' && entry.transient === true && !durableMessageIds.has(entry.messageId)); + // A preserved entry keeps its place relative to the durable entry it + // followed. With no durable entry ahead of it it stays at the head, unless + // something durable preceded it — then the tail is where it belongs. const transientEntriesByBoundary = new Map(); - for (const transient of transientEntries) { - const nextIndex = transient.nextDurableId - ? durableEntries.findIndex((entry) => transcriptEntryId(entry) === transient.nextDurableId) - : -1; - const previousIndex = transient.previousDurableId - ? durableEntries.findIndex( - (entry) => transcriptEntryId(entry) === transient.previousDurableId, - ) - : -1; - const boundary = - nextIndex >= 0 - ? nextIndex - : previousIndex >= 0 - ? previousIndex + 1 - : transient.hadPrecedingDurable - ? durableEntries.length - : 0; - const grouped = transientEntriesByBoundary.get(boundary); - if (grouped) grouped.push(transient.entry); - else transientEntriesByBoundary.set(boundary, [transient.entry]); + if (options.preserveClientLocalEntries) { + state.entries.forEach((entry, index) => { + if (!isClientLocal(entry)) return; + const priorEntries = state.entries.slice(0, index); + const previousDurableId = priorEntries + .map(transcriptEntryId) + .reverse() + .find((messageId) => messageId !== undefined && durableEntryIds.has(messageId)); + const previousIndex = previousDurableId + ? durableEntries.findIndex( + (candidate) => transcriptEntryId(candidate) === previousDurableId, + ) + : -1; + const hadPrecedingEntry = priorEntries.some((candidate) => !isClientLocal(candidate)); + const boundary = + previousIndex >= 0 ? previousIndex + 1 : hadPrecedingEntry ? durableEntries.length : 0; + const grouped = transientEntriesByBoundary.get(boundary); + if (grouped) grouped.push(entry); + else transientEntriesByBoundary.set(boundary, [entry]); + }); } state.entries = []; for (let boundary = 0; boundary <= durableEntries.length; boundary += 1) { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index f383917a69..09c0778b02 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -60,6 +60,8 @@ import { type ForeignSessionSummary, } from '@maka/core/foreign-session'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; +import type { GoalTurnOutcome } from '@maka/runtime/goal-continuation'; +import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import type { SessionActivityLease } from '@maka/runtime/goal-turn-lifecycle'; import { listApiKeyOnboardableProviders } from './onboarding-catalog.js'; import type { @@ -94,7 +96,7 @@ import { completePendingInteraction, applyShellRunViewUpdateToTranscript, permissionModeLabel, - reconcileTransientMessageLifecycle, + retireCancelledTransientMessages, replaceTranscriptWithStoredMessages, hydrateToolsWithStoredMessages, submitCompactToTranscript, @@ -102,11 +104,7 @@ import { toggleAllToolExpansion, type MakaPiTranscriptMetadata, } from './pi-transcript.js'; -import { - runMakaPiTuiTurn, - type MakaPiTuiTurnOutcome, - type MakaPiTuiTurnRequest, -} from './pi-tui-turn.js'; +import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; import { editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; import { TranscriptViewerOverlay } from './pi-tui-transcript-viewer.js'; @@ -294,7 +292,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const replaceTranscript = ( messages: readonly StoredMessage[], - options: { preserveTransientMessages?: boolean } = {}, + options: { preserveClientLocalEntries?: boolean } = {}, ): void => { rememberTranscriptModel(messages); replaceTranscriptWithStoredMessages(state, messages, options); @@ -530,6 +528,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const unsubscribeStartedTurns = input.driver.subscribeStartedTurns?.((turn) => { if (closed) return; + // A Turn on a Session this client no longer displays — the one left + // running by a mid-turn `/session` detach — must not reach the adopted + // transcript, nor hand it the abandoned Session's metadata. + if (turn.sessionId !== input.driver.getSessionId()) return; const attached = { kind: 'external', turn } as const; if (busy || turnRunning || !startAttachedTurn) pendingAttachedTurn = attached; else startAttachedTurn(attached); @@ -549,7 +551,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { input.driver.subscribeTranscriptReplacements?.((sessionId, turnId, messages, reason) => { if (closed || input.driver.getSessionId() !== sessionId) return; if (reason === 'reconnect') { - replaceTranscript(messages, { preserveTransientMessages: true }); + replaceTranscript(messages, { preserveClientLocalEntries: true }); shellRunElapsedTicker.sync(); requestRender(); const messageIds = state.entries.flatMap((entry) => @@ -557,10 +559,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ); if (messageIds.length > 0) { void input.driver - .queryMessageStatuses?.(messageIds) + .queryCancelledMessages(messageIds) .then((result) => { if (closed || input.driver.getSessionId() !== sessionId) return; - reconcileTransientMessageLifecycle(state, result.messages); + retireCancelledTransientMessages(state, result.cancelledMessageIds); requestRender(); }) .catch(() => undefined); @@ -827,12 +829,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // from the render mirror, which can // lag a step-boundary consumption and would resurrect an already-consumed // steering message for a double execution. Clears the local mirror. + const restoreDraft = (text: string) => { + if (!text) return; + const draft = editor.getText(); + editor.setText(draft ? `${text}\n\n${draft}` : text); + }; const refillEditorFromQueues = (joined: string) => { state.steering = []; state.followup = []; - if (!joined) return; - const draft = editor.getText(); - editor.setText(draft ? `${joined}\n\n${draft}` : joined); + restoreDraft(joined); }; const pendingEnqueueTasks = new Set>(); @@ -876,9 +881,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; // Open a fresh turn from a submitted prompt (idle path). Control actions hold - // `busy`, so a prompt typed mid-switch is ignored rather than racing it. + // `busy`, so a prompt typed mid-switch goes back to the editor rather than + // racing it. Exiting is never held back. const submitPrompt = (prompt: string) => { - if (busy || !prompt.trim()) { + if (!prompt.trim()) { requestRender(); return; } @@ -886,6 +892,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { beginGracefulClose(); return; } + if (busy) { + restoreDraft(prompt); + requestRender(); + return; + } // Captured BEFORE lastActivityAt is refreshed, so the idle gap measures up // to (not including) this very submission. const idleMs = Date.now() - lastActivityAt; @@ -909,11 +920,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // is the idle-return submission that triggers the recap below. promptSeq += 1; maybeTriggerAutoRecap(idleMs); - void runAgentTurn({ - kind: 'external', - prompt, - sessionId: input.driver.getSessionId(), - }); + submitMessage(prompt, 'current_turn'); }; const removeTransientUserMessage = (messageId: string) => { @@ -928,23 +935,38 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { refillEditorFromQueues(retracted.text); }; - const submitRunningMessage = (text: string, placement: 'current_turn' | 'next_turn') => { + /** + * The single TUI submission path. Runtime Host owns what the Message becomes + * — a new Turn, steering for the running one, or a queued follow-up — so the + * TUI only renders the transient row until canonical transcript replaces it. + */ + const submitMessage = ( + text: string, + placement: 'current_turn' | 'next_turn', + options: { modelText?: string; turnOrchestration?: TurnOrchestration } = {}, + ) => { editor.addToHistory(text); - const submitMessage = input.driver.submitMessage; - if (!submitMessage) { - refillEditorFromQueues(text); - return; - } const messageId = randomUUID(); appendUserPrompt(state, text, messageId, true); requestRender(); - const task = submitMessage - .call(input.driver, text, { messageId, placement }) - .then(requestRender) + const task = input.driver + .submitMessage(text, { messageId, placement, ...options }) + .then((result) => { + // Runtime Host resolved the Skills this Message named and refused it. + // Retire the row it belongs to and report the failure in its place. + if (result?.disposition === 'blocked') { + removeTransientUserMessage(messageId); + showSkillInvocation(result.skillInvocation); + } + }) .catch((error) => { + // The Message never became anything, so its row goes with the failure + // notice that replaces it. The text stays in editor history for a retry. removeTransientUserMessage(messageId); - refillEditorFromQueues(text); reportError(error); + }) + .finally(() => { + requestRender(); }); trackEnqueue(task); }; @@ -957,7 +979,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); return; } - submitRunningMessage(text, 'current_turn'); + submitMessage(text, 'current_turn'); }; // Alt+Enter: during a turn, queue the text to open the next turn; when idle, @@ -977,7 +999,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { submitPrompt(text); return; } - submitRunningMessage(text, 'next_turn'); + submitMessage(text, 'next_turn'); }; // Alt+↑: take back every queued message from the Runtime Host, joined and @@ -1107,7 +1129,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { function runAgentTurn( request: MakaPiTuiTurnRequest, authoritativeAttachedTurn?: MakaAttachedSessionTurn, - ): Promise { + ): Promise { busy = true; const epoch = ++turnEpoch; // A mid-turn /session switch-away (#3380) bumps turnEpoch and orphans this @@ -1115,35 +1137,18 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // runner state — the adopted Session owns it now. const superseded = () => epoch !== turnEpoch; const activity = beginActivity(); - const ownsTurnUi = - request.kind === 'attached' || - request.turnOrchestration !== undefined || - input.driver.submitMessage === undefined; - if (ownsTurnUi) { - turnRunning = true; - turnStartedAt = Date.now(); - startTurnElapsedTicker(); - interruptRequested = false; - lastTurnEscapeAt = 0; - editor.disableSubmit = false; - setTaskbarProgress(true); - attention.promptTurnStarted(); - } else { - // The editor clears before invoking onSubmit. While Host admission is - // unresolved, disable submission so a second Enter cannot erase a draft - // that the busy gate would then refuse. - editor.disableSubmit = true; - } + turnRunning = true; + turnStartedAt = Date.now(); + startTurnElapsedTicker(); + interruptRequested = false; + lastTurnEscapeAt = 0; + editor.disableSubmit = false; + setTaskbarProgress(true); + attention.promptTurnStarted(); requestRender(); let permissionAlerted = false; - let optimisticUserEntry: (typeof state.entries)[number] | undefined; - let turnPrepared = false; const finishTurnUi = () => { - if (!ownsTurnUi) { - editor.disableSubmit = false; - return; - } turnRunning = false; turnStartedAt = undefined; stopTurnElapsedTicker(); @@ -1157,19 +1162,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; return runMakaPiTuiTurn({ - driver: input.driver, turnActivity: input.turnActivity, request, // A requested stop converges through the authoritative event stream. // Cutting the iterator short here would make the UI appear idle before // the runtime has emitted its terminal event and accepted the stop. shouldAbort: () => closed, - onStart: (turnId) => { - if (request.kind !== 'attached') { - if (!turnId) throw new Error('External TUI turn did not receive a stable identity'); - appendUserPrompt(state, request.prompt, turnId, true); - optimisticUserEntry = state.entries.at(-1); - } + onStart: () => { requestRender(); }, onPrepared: async (turn) => { @@ -1177,11 +1176,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // switch resolved (preparePrompt was in flight), and the abandoned // Turn's metadata must not overwrite the adopted Session's view. if (superseded()) return; - turnPrepared = true; if (authoritativeAttachedTurn) { adoptSessionMetadata(authoritativeAttachedTurn.summary); replaceTranscript(authoritativeAttachedTurn.messages, { - preserveTransientMessages: true, + preserveClientLocalEntries: true, }); shellRunHydration.reset(); if (input.listShellRunUpdates) { @@ -1198,15 +1196,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // belonging to the abandoned Session must not land on the adopted // viewport (covers the blocked-invocation path too). if (superseded()) return; - if ( - skillInvocation.loaded.length === 0 && - skillInvocation.failed.length > 0 && - optimisticUserEntry - ) { - const index = state.entries.indexOf(optimisticUserEntry); - if (index >= 0) state.entries.splice(index, 1); - optimisticUserEntry = undefined; - } showSkillInvocation(skillInvocation); }, onEvent: (event) => { @@ -1250,10 +1239,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // here as "ended without completion" — never report that against the // adopted Session. if (superseded()) return; - if (request.kind === 'external' && !turnPrepared && optimisticUserEntry?.kind === 'user') { - removeTransientUserMessage(optimisticUserEntry.messageId); - optimisticUserEntry = undefined; - } appendTurnFailureToTranscript(state, error); attention.attentionNeeded(); shellRunElapsedTicker.sync(); @@ -1352,7 +1337,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { startAttachedTurn = (attached) => { if (closed || turnRunning) return; void runAgentTurn( - { kind: 'attached', turn: attached.turn }, + { turn: attached.turn }, attached.kind === 'external' ? attached.turn : undefined, ); }; @@ -2370,11 +2355,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const digest = await input.foreignSessions.readDigest(summary); if (closed) return; newSession(); - void runAgentTurn({ - kind: 'external', - prompt: foreignSessionHandoffDisplayText(digest), - sessionId: input.driver.getSessionId(), - sendText: buildForeignSessionHandoffMessage(digest), + submitMessage(foreignSessionHandoffDisplayText(digest), 'current_turn', { + modelText: buildForeignSessionHandoffMessage(digest), }); handedOff = true; } catch (error) { @@ -2620,10 +2602,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { level: 'info', text: 'Using Swarm Mode for this turn only.', }); - void runAgentTurn({ - kind: 'external', - prompt: command.task, - sessionId: input.driver.getSessionId(), + submitMessage(command.task, 'current_turn', { turnOrchestration: { mode: 'swarm', source: 'slash_command' }, }); }; @@ -2733,10 +2712,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { level: 'info', text: 'Using Graph Mode for this turn only.', }); - void runAgentTurn({ - kind: 'external', - prompt: command.task, - sessionId: input.driver.getSessionId(), + submitMessage(command.task, 'current_turn', { turnOrchestration: { mode: 'graph', source: 'slash_command' }, }); }; diff --git a/packages/cli/src/pi-tui-turn.ts b/packages/cli/src/pi-tui-turn.ts index 957dc8cc6e..88da27c27e 100644 --- a/packages/cli/src/pi-tui-turn.ts +++ b/packages/cli/src/pi-tui-turn.ts @@ -18,115 +18,69 @@ */ import type { SessionEvent } from '@maka/core/events'; -import { randomUUID } from 'node:crypto'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; -import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import { drainGoalTurn, type SessionActivityLease, type SessionActivityRegistry, } from '@maka/runtime/goal-turn-lifecycle'; import { type GoalTurnOutcome } from '@maka/runtime/goal-continuation'; -import { - SkillInvocationBlockedError, - type MakaPreparedSessionTurn, - type MakaSessionDriver, -} from './session-driver.js'; +import type { MakaPreparedSessionTurn } from './session-driver.js'; export interface MakaPiTuiTurnActivity { activities: SessionActivityRegistry; } -export type MakaPiTuiTurnRequest = - | { - kind: 'external'; - prompt: string; - /** Stable operation/message identity shared by the transient row and Host admission. */ - turnId?: string; - /** Model-facing text after explicit skill expansion, when different. */ - sendText?: string; - /** Session observed before preparation; null is valid for the first turn. */ - sessionId: string | null; - /** Trusted one-turn orchestration override supplied by a host command. */ - turnOrchestration?: TurnOrchestration; - } - | { - /** A Turn that another Client or the Runtime Host already started. */ - kind: 'attached'; - turn: MakaPreparedSessionTurn; - }; +/** A Turn that another Client or the Runtime Host already started. */ +export interface MakaPiTuiTurnRequest { + turn: MakaPreparedSessionTurn; +} export interface RunMakaPiTuiTurnInput { - driver: Pick; turnActivity: MakaPiTuiTurnActivity; request: MakaPiTuiTurnRequest; shouldAbort: () => boolean; - onStart?: (turnId: string | undefined) => void; + onStart?: () => void; onPrepared?: (turn: MakaPreparedSessionTurn) => void | Promise; onSkillInvocation?: (result: SkillInvocationResult) => void | Promise; onEvent?: (event: SessionEvent) => void | Promise; onFailure?: (error: unknown) => void | Promise; } -export type MakaPiTuiTurnOutcome = GoalTurnOutcome | { kind: 'admitted' }; - /** * Owns one visible TUI turn from activity reservation through full stream drain. - * Goal continuation and ScheduledTask admission remain Runtime Host responsibilities. + * Every Turn reaches the TUI the same way: Runtime Host admits a submitted + * Message and this runner attaches to the Turn it started. */ -export async function runMakaPiTuiTurn( - input: RunMakaPiTuiTurnInput, -): Promise { +export async function runMakaPiTuiTurn(input: RunMakaPiTuiTurnInput): Promise { const { request } = input; let activity: SessionActivityLease | undefined; - const externalTurnId = request.kind === 'external' ? (request.turnId ?? randomUUID()) : undefined; - let preparedTurnId = request.kind === 'attached' ? request.turn.turnId : externalTurnId; + let preparedTurnId = request.turn.turnId; - const finishBeforeDrain = (outcome: T): T => { + const finishBeforeDrain = (outcome: GoalTurnOutcome): GoalTurnOutcome => { activity?.release(); activity = undefined; return outcome; }; try { - input.onStart?.(preparedTurnId); + input.onStart?.(); if (input.shouldAbort()) { return finishBeforeDrain(abortedOutcome(preparedTurnId)); } - const observedSessionId = - request.kind === 'external' ? request.sessionId : request.turn.sessionId; - if (observedSessionId) { - activity = await input.turnActivity.activities.acquire(observedSessionId); - if (input.shouldAbort()) { - return finishBeforeDrain(abortedOutcome(preparedTurnId)); - } - } - - if ( - request.kind === 'external' && - request.turnOrchestration === undefined && - input.driver.submitMessage - ) { - await input.driver.submitMessage(request.prompt, { - messageId: externalTurnId!, - placement: 'current_turn', - ...(request.sendText !== undefined ? { modelText: request.sendText } : {}), - }); - return finishBeforeDrain({ kind: 'admitted' }); + activity = await input.turnActivity.activities.acquire(request.turn.sessionId); + if (input.shouldAbort()) { + return finishBeforeDrain(abortedOutcome(preparedTurnId)); } - const turn = - request.kind === 'attached' - ? request.turn - : await input.driver.preparePrompt(request.prompt, { - ...(externalTurnId ? { turnId: externalTurnId } : {}), - ...(request.sendText !== undefined ? { modelText: request.sendText } : {}), - ...(request.turnOrchestration ? { turnOrchestration: request.turnOrchestration } : {}), - }); + const turn = request.turn; preparedTurnId = turn.turnId; - if (turn.skillInvocation) await input.onSkillInvocation?.(turn.skillInvocation); + // Adoption first: onPrepared replaces the transcript with the attached + // Turn's canonical messages, so a Skill card projected before it would be + // wiped by the very adoption that follows. await input.onPrepared?.(turn); + if (turn.skillInvocation) await input.onSkillInvocation?.(turn.skillInvocation); if (!activity) activity = await input.turnActivity.activities.acquire(turn.sessionId); if (input.shouldAbort()) { @@ -161,10 +115,6 @@ export async function runMakaPiTuiTurn( if (input.shouldAbort()) { return finishBeforeDrain(abortedOutcome(preparedTurnId)); } - if (error instanceof SkillInvocationBlockedError) { - await input.onSkillInvocation?.(error.skillInvocation); - return finishBeforeDrain(abortedOutcome(preparedTurnId)); - } let reportedError = error; try { await input.onFailure?.(error); diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index eb37138dd4..dfb19820c3 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -384,7 +384,10 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { yield* events; } - async submitMessage(text: string, options: MakaSubmitMessageOptions): Promise { + async submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise | undefined> { const sessionId = await this.#ensureSession(); const sessionGeneration = this.#sessionGeneration; const configuration = await this.#loadConfiguration(sessionId); @@ -394,7 +397,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#adoptLoadedConfiguration(configuration); const modelText = options.modelText ?? text; try { - await this.#request('turn.message.submit', { + return await this.#request('turn.message.submit', { originHostEpoch: this.#connection.hostEpoch, sessionId, messageId: options.messageId, @@ -403,19 +406,20 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { ...(modelText === text ? {} : { displayText: text }), }, placement: options.placement, + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), }); } catch (error) { if ( (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') || (error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched') ) { - return; + return undefined; } throw error; } } - async queryMessageStatuses( + async queryCancelledMessages( messageIds: readonly string[], ): Promise> { const sessionId = await this.#ensureSession(); diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index edf426b526..2d1b648ee5 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -192,6 +192,8 @@ function createFirstRunSessionDriver(): MakaSessionDriver { getSessionId: () => null, listSessions: async () => [], preparePrompt: unavailable, + submitMessage: unavailable, + queryCancelledMessages: async () => ({ cancelledMessageIds: [] }), compactSession: async function* () {}, respondToSandboxBoundary: async () => {}, setModel: async () => {}, diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 351b5b568f..29f6e56335 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -32,6 +32,7 @@ import type { GoalControlAction, GoalProjection, TurnMessageQueryResult, + TurnMessageSubmitResult, } from '@maka/runtime-host/protocol'; export interface MakaSessionMoveResult { @@ -98,6 +99,8 @@ export interface MakaSubmitMessageOptions { messageId: string; placement: 'current_turn' | 'next_turn'; modelText?: string; + /** Exact-Turn intent carried to Runtime Host, which decides how to admit it. */ + turnOrchestration?: TurnOrchestration; } export interface MakaRetractedMessages { @@ -119,8 +122,15 @@ export interface MakaSessionDriver { prompt: string, options?: MakaPreparePromptOptions, ): Promise; - submitMessage?(text: string, options: MakaSubmitMessageOptions): Promise; - queryMessageStatuses?(messageIds: readonly string[]): Promise; + /** + * Submits one Message and reports how Runtime Host admitted it. `undefined` + * means the outcome could not be proven, so the caller keeps its row. + */ + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise; + queryCancelledMessages(messageIds: readonly string[]): Promise; compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; retractQueued?(): Promise; diff --git a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts index a96fef991c..a00b1367ac 100644 --- a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts @@ -299,22 +299,25 @@ test('same idle Message submit is connection-independent and starts one canonica }); }); -test('a rejected idle Message submit leaves no durable transcript entry', async () => { +test('a blocked idle Message submit leaves no durable transcript entry', async () => { await withExecutionRoot(async (fixture) => { const host = await fixture.startHost(); const client = await connectClient(fixture.root); const messageId = randomUUID(); try { - await assert.rejects( - () => - client.request('turn.message.submit', { - originHostEpoch: host.hostEpoch, - sessionId: fixture.sessionId, - messageId, - content: { text: '/skill:missing reject this submit' }, - placement: 'current_turn', - }), - operationError('operation_conflict'), + // A Skill the Host cannot resolve is an outcome of admission, not a + // protocol failure: the submit answers `blocked` and no Turn is opened. + const result = await client.request('turn.message.submit', { + originHostEpoch: host.hostEpoch, + sessionId: fixture.sessionId, + messageId, + content: { text: '/skill:missing reject this submit' }, + placement: 'current_turn', + }); + assert.equal(result.disposition, 'blocked'); + assert.ok( + result.disposition === 'blocked' && result.skillInvocation.failed.length > 0, + 'the blocked outcome carries why the Skill could not be resolved', ); } finally { await client.close(); diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index df3c9ed762..f255b59ca1 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -72,35 +72,12 @@ test('idle submit starts exactly one root Turn and retry identity is connection- assert.equal(fixture.liveResidencies(), 0); }); -test('message query returns durable cancellation proof after the live queue disappears', async () => { +test('message query reports only durable cancellation proof', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); await submit(fixture, 'cancelled-message', 'discard me', 'next_turn'); - await fixture.coordinator.cancelMessages(ROOT.sessionId, ['cancelled-message']); - - const result = await fixture.coordinator.handlers['turn.message.query']( - { - sessionId: ROOT.sessionId, - messageIds: ['cancelled-message', 'unknown-message'], - }, - operationContext(), - ); - - assert.deepEqual(result, { - ok: true, - result: { - messages: [ - { messageId: 'cancelled-message', status: 'cancelled' }, - { messageId: 'unknown-message', status: 'unknown' }, - ], - }, - }); -}); - -test('message query distinguishes a live admission from durable handoff proof', async () => { - const fixture = createFixture(); - fixture.coordinator.reserveRootTurn(ROOT); await submit(fixture, 'accepted-message', 'waiting', 'next_turn'); + await fixture.coordinator.cancelMessages(ROOT.sessionId, ['cancelled-message']); fixture.receipts.set( 'handed-off-message', sourceReceipt('handed-off-message', 'delivered', 'current_turn', 'steering'), @@ -109,19 +86,19 @@ test('message query distinguishes a live admission from durable handoff proof', const result = await fixture.coordinator.handlers['turn.message.query']( { sessionId: ROOT.sessionId, - messageIds: ['accepted-message', 'handed-off-message'], + messageIds: [ + 'cancelled-message', + 'accepted-message', + 'handed-off-message', + 'unknown-message', + ], }, operationContext(), ); assert.deepEqual(result, { ok: true, - result: { - messages: [ - { messageId: 'accepted-message', status: 'accepted' }, - { messageId: 'handed-off-message', status: 'handed_off' }, - ], - }, + result: { cancelledMessageIds: ['cancelled-message'] }, }); }); diff --git a/packages/runtime-host/src/protocol/message.ts b/packages/runtime-host/src/protocol/message.ts index b8c48908d8..ea4df35d27 100644 --- a/packages/runtime-host/src/protocol/message.ts +++ b/packages/runtime-host/src/protocol/message.ts @@ -25,16 +25,22 @@ import { requireExactRecord, requireId, requireRecord, + requireShapedRecord, requireUtf8String, } from './codec.js'; import { defineOperation } from './operation-spec.js'; import { decodeMessageContent, + decodeSkillIds, + decodeTurnOrchestration, decodeTurnSnapshot, type MessageContent, TURN_MESSAGE_TEXT_MAX_BYTES, type TurnSnapshot, } from './turn.js'; +import { decodeSkillInvocationResult } from '@maka/core/skill-invocation'; +import type { SkillInvocationResult } from '@maka/core/skill-invocation'; +import type { TurnOrchestration } from '@maka/core/runtime-inputs'; export const MESSAGE_QUEUE_MAX_ENTRIES = 64; export const MESSAGE_QUEUE_PROJECTION_MAX_BYTES = 52 * 1024; @@ -78,31 +84,43 @@ export interface SessionMessageQueueProjection { readonly followup: readonly QueuedMessageSnapshot[]; } +/** + * The sole client admission input for a user Message. `skillIds` and + * `turnOrchestration` carry exact-Turn intent: the Host, not the client, + * decides that such a Message can only open its own Turn. + */ export interface TurnMessageSubmitInput { readonly originHostEpoch: string; readonly sessionId: string; readonly messageId: string; readonly content: MessageContent; readonly placement: MessagePlacement; + readonly skillIds?: readonly string[]; + readonly turnOrchestration?: TurnOrchestration; } export type TurnMessageSubmitResult = | { readonly disposition: 'steering'; readonly queueRevision: number } | { readonly disposition: 'followup'; readonly queueRevision: number } - | { readonly disposition: 'turn_started'; readonly turnId: string }; - -export type MessageLifecycleStatus = 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; + | { + readonly disposition: 'turn_started'; + readonly turnId: string; + readonly skillInvocation?: SkillInvocationResult; + } + | { readonly disposition: 'blocked'; readonly skillInvocation: SkillInvocationResult }; export interface TurnMessageQueryInput { readonly sessionId: string; readonly messageIds: readonly string[]; } +/** + * Durable cancellation proof for the queried identities. Only a cancelled + * Message retires a client's transient row; every other identity stays visible + * until canonical transcript replaces it, so absence needs no status of its own. + */ export interface TurnMessageQueryResult { - readonly messages: readonly { - readonly messageId: string; - readonly status: MessageLifecycleStatus; - }[]; + readonly cancelledMessageIds: readonly string[]; } export interface QueueRetractInput { @@ -263,19 +281,31 @@ export function decodeSessionMessageQueueProjection(value: unknown): SessionMess } function decodeTurnMessageSubmitInput(value: unknown): TurnMessageSubmitInput { - const record = requireExactRecord(value, 'turn.message.submit input', [ - 'originHostEpoch', - 'sessionId', - 'messageId', - 'content', - 'placement', - ]); + const record = requireShapedRecord( + value, + 'turn.message.submit input', + ['originHostEpoch', 'sessionId', 'messageId', 'content', 'placement'], + ['skillIds', 'turnOrchestration'], + ); + const skillIds = decodeSkillIds(record.skillIds); + const placement = requireMessagePlacement(record.placement); + const turnOrchestration = + record.turnOrchestration !== undefined + ? decodeTurnOrchestration(record.turnOrchestration) + : undefined; + // Exact-Turn intent has no queued form: a Skill or orchestration Message + // opens its own Turn or fails closed, so `next_turn` cannot describe it. + if ((skillIds.length > 0 || turnOrchestration !== undefined) && placement !== 'current_turn') { + throw invalidProtocolFrame('Invalid turn.message.submit placement for an exact Turn'); + } return { originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'), sessionId: requireEntityId(record.sessionId, 'sessionId'), messageId: requireEntityId(record.messageId, 'messageId'), - content: decodeMessageContent(record.content), - placement: requireMessagePlacement(record.placement), + content: decodeMessageContent(record.content, skillIds.length > 0), + placement, + ...(skillIds.length > 0 ? { skillIds } : {}), + ...(turnOrchestration !== undefined ? { turnOrchestration } : {}), }; } @@ -295,40 +325,49 @@ function decodeTurnMessageQueryInput(value: unknown): TurnMessageQueryInput { } function decodeTurnMessageQueryResult(value: unknown): TurnMessageQueryResult { - const record = requireExactRecord(value, 'turn.message.query result', ['messages']); - if (!Array.isArray(record.messages) || record.messages.length > MESSAGE_QUEUE_MAX_ENTRIES) { - throw invalidProtocolFrame('Invalid turn.message.query messages'); + const record = requireExactRecord(value, 'turn.message.query result', ['cancelledMessageIds']); + if ( + !Array.isArray(record.cancelledMessageIds) || + record.cancelledMessageIds.length > MESSAGE_QUEUE_MAX_ENTRIES + ) { + throw invalidProtocolFrame('Invalid turn.message.query cancelledMessageIds'); } - const messages = record.messages.map((candidate) => { - const message = requireExactRecord(candidate, 'turn.message.query message', [ - 'messageId', - 'status', - ]); - if ( - message.status !== 'accepted' && - message.status !== 'handed_off' && - message.status !== 'cancelled' && - message.status !== 'unknown' - ) { - throw invalidProtocolFrame('Invalid turn.message.query status'); - } - const status = message.status as MessageLifecycleStatus; - return { - messageId: requireEntityId(message.messageId, 'messageId'), - status, - }; - }); - if (new Set(messages.map(({ messageId }) => messageId)).size !== messages.length) { - throw invalidProtocolFrame('Duplicate turn.message.query result messageId'); + const cancelledMessageIds = record.cancelledMessageIds.map((messageId) => + requireEntityId(messageId, 'messageId'), + ); + if (new Set(cancelledMessageIds).size !== cancelledMessageIds.length) { + throw invalidProtocolFrame('Duplicate turn.message.query cancelledMessageId'); } - return { messages }; + return { cancelledMessageIds }; } function decodeTurnMessageSubmitResult(value: unknown): TurnMessageSubmitResult { const record = requireRecord(value, 'turn.message.submit result'); if (record.disposition === 'turn_started') { - assertExactKeys(record, 'turn.message.submit turn_started result', ['disposition', 'turnId']); - return { disposition: record.disposition, turnId: requireEntityId(record.turnId, 'turnId') }; + const shaped = requireShapedRecord( + record, + 'turn.message.submit turn_started result', + ['disposition', 'turnId'], + ['skillInvocation'], + ); + return { + disposition: 'turn_started', + turnId: requireEntityId(shaped.turnId, 'turnId'), + ...(shaped.skillInvocation !== undefined + ? { skillInvocation: decodeSubmitSkillInvocation(shaped.skillInvocation) } + : {}), + }; + } + if (record.disposition === 'blocked') { + assertExactKeys(record, 'turn.message.submit blocked result', [ + 'disposition', + 'skillInvocation', + ]); + const skillInvocation = decodeSubmitSkillInvocation(record.skillInvocation); + if (skillInvocation.loaded.length !== 0 || skillInvocation.failed.length === 0) { + throw invalidProtocolFrame('Invalid blocked turn.message.submit Skill invocation'); + } + return { disposition: 'blocked', skillInvocation }; } if (record.disposition === 'steering' || record.disposition === 'followup') { assertExactKeys(record, 'turn.message.submit queued result', ['disposition', 'queueRevision']); @@ -340,6 +379,14 @@ function decodeTurnMessageSubmitResult(value: unknown): TurnMessageSubmitResult throw invalidProtocolFrame('Invalid turn.message.submit disposition'); } +function decodeSubmitSkillInvocation(value: unknown): SkillInvocationResult { + try { + return decodeSkillInvocationResult(value); + } catch { + throw invalidProtocolFrame('Invalid turn.message.submit Skill invocation'); + } +} + function decodeQueueRetractInput(value: unknown): QueueRetractInput { const record = requireExactRecord(value, 'queue.retract input', [ 'originHostEpoch', diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 100cb6c230..032e713551 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -361,7 +361,7 @@ function requirePositiveSafeInteger(value: unknown, label: string): number { return decoded; } -function decodeSkillIds(value: unknown): string[] { +export function decodeSkillIds(value: unknown): string[] { if (value === undefined) return []; if ( !Array.isArray(value) || @@ -379,7 +379,7 @@ function decodeSkillIds(value: unknown): string[] { return [...value]; } -function decodeTurnOrchestration(value: unknown): TurnOrchestration { +export function decodeTurnOrchestration(value: unknown): TurnOrchestration { const record = requireExactRecord(value, 'Turn orchestration', ['mode', 'source']); if (!isOrchestrationMode(record.mode) || !isTurnOrchestrationSource(record.source)) { throw invalidProtocolFrame('Invalid Turn orchestration'); diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index acd667d753..0575d42012 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -28,6 +28,8 @@ import { type MessageContent, } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { TurnOrchestration } from '@maka/core/runtime-inputs'; +import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import { RuntimeMessageAuthorityInvariantError, type RuntimeMessageAuthority, @@ -106,8 +108,19 @@ export interface HostMessageStartInput { readonly initiatingConnectionId: string; readonly turnId?: string; readonly runId?: string; + readonly skillIds?: readonly string[]; + readonly turnOrchestration?: TurnOrchestration; } +/** + * Starting a Turn from a Message either admits it, reports Skill resolution + * the client can act on, or fails with an opaque reason. + */ +export type HostMessageStartOutcome = + | { readonly turnId: string; readonly skillInvocation?: SkillInvocationResult } + | { readonly blocked: SkillInvocationResult } + | { readonly error: string }; + export interface HostMessageRecoveryBatch { readonly sessionId: string; readonly content: MessageContent; @@ -145,7 +158,7 @@ export interface HostMessageRootPort { input: HostMessageStartInput, admission: SessionAdmissionLease, commitAdmission: (canonicalContent: MessageContent) => Promise, - ): Promise<{ readonly turnId: string } | { readonly error: string }>; + ): Promise; startRecoveredMessages?( input: HostMessageRecoveryBatch, admission: SessionAdmissionLease, @@ -371,47 +384,24 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return state ? hasLiveMessageState(state) : false; } - async queryMessages(input: { sessionId: string; messageIds: readonly string[] }): Promise< - MessageOutcome<{ - messages: Array<{ - messageId: string; - status: 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; - }>; - }> - > { - const messages = [] as Array<{ - messageId: string; - status: 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; - }>; + /** + * Durable cancellation proof for client-held transient identities. Absence of + * a tombstone is never delivery or cancellation proof, so only cancelled + * identities are reported and the client keeps every other row. + */ + async queryMessages(input: { + sessionId: string; + messageIds: readonly string[]; + }): Promise> { + const cancelledMessageIds: string[] = []; for (const messageId of input.messageIds) { const cancelled = await this.#admissions.readCancelledMessageAdmission( input.sessionId, messageId, ); - if (cancelled) { - messages.push({ messageId, status: 'cancelled' }); - continue; - } - const accepted = await this.#admissions.readMessageAdmission(input.sessionId, messageId); - if (accepted) { - messages.push({ messageId, status: 'accepted' }); - continue; - } - const root = await this.#durableProof.readRootTurnSourceMessageReceipt( - input.sessionId, - messageId, - ); - if (root) { - messages.push({ messageId, status: 'handed_off' }); - continue; - } - const steering = await this.#durableProof.readImmutableSteeringMessageProof( - input.sessionId, - messageId, - ); - messages.push({ messageId, status: steering ? 'handed_off' : 'unknown' }); + if (cancelled) cancelledMessageIds.push(messageId); } - return success({ messages }); + return success({ cancelledMessageIds }); } retireSessions(sessionIds: readonly string[]): void { @@ -886,6 +876,10 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { initiatingConnectionId, turnId, runId, + ...(payload.skillIds.length > 0 ? { skillIds: payload.skillIds } : {}), + ...(payload.turnOrchestration + ? { turnOrchestration: payload.turnOrchestration } + : {}), }, admission, async (canonicalContent) => { @@ -906,14 +900,33 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if ('error' in started) { return failure('operation_conflict', started.error); } + // A blocked Skill invocation admitted nothing: it is not remembered as + // a completed submit, so the same identity can be submitted again once + // the Skill resolves. + if ('blocked' in started) { + return success({ + disposition: 'blocked', + skillInvocation: started.blocked, + } as const); + } if (!isEntityId(started.turnId)) { throw new RuntimeMessageAuthorityInvariantError( 'Started Turn identity is not encodable', ); } - const result = { disposition: 'turn_started', turnId: started.turnId } as const; + const result = { + disposition: 'turn_started', + turnId: started.turnId, + ...(started.skillInvocation ? { skillInvocation: started.skillInvocation } : {}), + } as const; return success(result); } + if (requiresExactTurn(payload)) { + return failure( + 'session_busy', + 'An explicit Skill or orchestrated Message needs an idle Session', + ); + } if (rootState.kind === 'reserved') { return failure('session_busy', 'A Goal continuation is reserving the next root Turn'); } @@ -2266,6 +2279,8 @@ interface CanonicalSubmitPayload { readonly messageId: string; readonly content: MessageContent; readonly placement: MessagePlacement; + readonly skillIds: readonly string[]; + readonly turnOrchestration?: TurnOrchestration; } function canonicalSubmitPayload(input: TurnMessageSubmitInput): CanonicalSubmitPayload { @@ -2275,9 +2290,21 @@ function canonicalSubmitPayload(input: TurnMessageSubmitInput): CanonicalSubmitP messageId: input.messageId, content: normalizeMessageContent(input.content), placement: input.placement, + skillIds: [...(input.skillIds ?? [])], + ...(input.turnOrchestration ? { turnOrchestration: input.turnOrchestration } : {}), }; } +/** + * Exact-Turn intent. Explicit Skill ids and an orchestration override describe + * how one Turn runs, so they have no queued form and need an idle Session. + * A `/skill:` token in the text is not exact-Turn intent: message preparation + * expands it on the queued path too. + */ +function requiresExactTurn(payload: CanonicalSubmitPayload): boolean { + return payload.skillIds.length > 0 || payload.turnOrchestration !== undefined; +} + function aggregateMessageContent(contents: readonly MessageContent[]): MessageContent { return aggregateMessageContents(contents); } diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 5997c38334..b8987e2882 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -83,6 +83,7 @@ import { type HostMessageRecoveryBatch, type HostMessageSessionHeader, type HostMessageStartInput, + type HostMessageStartOutcome, type HostMessageStopClaim, type HostMessageStopFence, HostMessageCoordinator, @@ -1021,7 +1022,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { input: HostMessageStartInput, admissionLease: SessionAdmissionLease, commitAdmission: (canonicalContent: MessageContent) => Promise, - ): Promise<{ readonly turnId: string } | { readonly error: string }> { + ): Promise { if (isWorkHubCoordinationSessionId(input.sessionId)) { return Promise.resolve({ error: WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON }); } @@ -1048,17 +1049,22 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if (unavailableReason) return { error: unavailableReason }; const turnId = input.turnId ?? randomUUID(); const runId = input.runId ?? randomUUID(); - const hasSkillInvocation = parseSkillInvocationTokens(content.text).length > 0; + const skillIds = input.skillIds ?? []; + const hasSkillInvocation = + skillIds.length > 0 || parseSkillInvocationTokens(content.text).length > 0; const prepared = hasSkillInvocation ? await this.prepareHostedSkillInvocationContent( input.sessionId, turnId, content, - [], + skillIds, input.initiatingConnectionId, ) : ({ kind: 'ready', content } as const); if (prepared.kind === 'rejected') { + // Skill resolution is the only rejection a client can act on, so it + // travels back as structured feedback instead of an opaque error. + if (prepared.skillInvocation) return { blocked: prepared.skillInvocation }; return { error: prepared.outcome.ok ? 'Hosted Skill invocation was rejected' @@ -1079,7 +1085,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { return { error: 'Root Turn reservation is no longer current' }; } - await this.prepareFreshAgentGraphEpoch(header); + await this.prepareFreshAgentGraphEpoch(header, input.turnOrchestration); await commitAdmission(canonicalContent.content); const admitted = await this.rootAdmissionOwner.admitRootTurn({ @@ -1092,6 +1098,8 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { inputDigest: messageContentDigest(content), }, normalizedInput: canonicalContent.content, + ...(input.turnOrchestration ? { turnOrchestration: input.turnOrchestration } : {}), + ...(prepared.skillInvocation ? { skillInvocation: prepared.skillInvocation } : {}), sourceMessages: [ { ...input.sourceMessage, @@ -1116,6 +1124,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { sessionId: input.sessionId, turnId, content: canonicalContent.content, + ...(input.turnOrchestration ? { turnOrchestration: input.turnOrchestration } : {}), }, admitted.admission, this.acquireRecoveryResidency, @@ -1129,7 +1138,10 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { 'Fresh Message root Turn did not reserve execution', ); } - return { turnId }; + return { + turnId, + ...(prepared.skillInvocation ? { skillInvocation: prepared.skillInvocation } : {}), + }; } finally { this.releaseRootReservation(reservation); } diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 9943f1befd..fb701f18c7 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -61,8 +61,13 @@ export interface LiveContentActivationSnapshot { } export type TransientUserMessageProjection = Extract & { - /** Presentation-only placement until canonical transcript grouping arrives. */ - transientPlacement?: 'turn_source' | 'current_turn' | 'next_turn'; + /** + * Presentation-only placement until canonical transcript grouping arrives: + * `current_turn` renders beside the tail Turn, `next_turn` below it. + */ + transientPlacement: 'current_turn' | 'next_turn'; + /** The Host Turn this Message is already bound to, once the Host named one. */ + hostTurnId?: string; }; export function ChatView(props: { @@ -472,7 +477,12 @@ export function ChatView(props: { ) { return false; } - return message.turnId === tailTurnId || message.transientPlacement === 'turn_source'; + // An unbound row belongs to the Turn the user is looking at; a bound + // one only renders inline in the Turn the Host named. + return ( + message.transientPlacement === 'current_turn' + && (message.hostTurnId === undefined || message.hostTurnId === tailTurnId) + ); }) : []; const inlineTransientMessageIds = new Set( diff --git a/packages/ui/src/composer-message-queue.tsx b/packages/ui/src/composer-message-queue.tsx index c717a6685a..135b862cf4 100644 --- a/packages/ui/src/composer-message-queue.tsx +++ b/packages/ui/src/composer-message-queue.tsx @@ -26,9 +26,10 @@ import { Check, GripVertical, ICON_SIZE, Trash2, X } from './icons.js'; import { useMountedRef } from './use-mounted-ref.js'; /** - * The pending plate above the composer card. It mirrors both pending steering - * and follow-up entries so a submitted message never disappears while waiting - * for the active Turn to reach a steering boundary. + * The pending plate above the composer card. It lists both pending steering + * and follow-up entries so a submitted message stays editable, reorderable and + * deletable while it waits for the active Turn to reach a steering boundary. + * Each row is a one-line preview: the transcript owns the full message text. */ export interface ComposerMessageQueueProps { queuedMessages: readonly MessageQueueEntryProjection[]; @@ -161,7 +162,13 @@ export const ComposerMessageQueue = memo(function ComposerMessageQueue( } }} /> - ) : entry.content.displayText ?? entry.content.text} + ) : ( + // The transcript renders the queued message in full; the plate + // only needs enough of it to tell the rows apart. + + {entry.content.displayText ?? entry.content.text} + + )} style={{ minHeight: 28, paddingBlock: 0 }} startContent={entry.placement === 'next_turn' ? ( Date: Wed, 26 Aug 2026 12:49:56 +0800 Subject: [PATCH 16/26] fix(desktop): retire a Follow Up the Host refused outright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Follow Up submitted just as the running Turn settles is admitted as a fresh Turn, so an unresolvable Skill token in it comes back refused. The Follow Up path treated every non-ok answer alike and kept the row, but a refusal opens no Turn and writes no cancellation tombstone, so nothing would ever replace or retire it: the row stayed for the life of the Session. The ordinary send path already got this right, which is how the two drifted apart. The three copies of "submit, then project the answer onto the row" — first send, send into an existing Session, and Follow Up — collapse into one `submitAndProject`. It names the distinction the bug turned on: `outcome_unknown` is the only answer that leaves the row in place, because only there might the Host have acted on a Message whose reply was lost. One behaviour moves with the consolidation: a row is now updated with its attachments, inline references and Host Turn grouping whether or not the Session's surface is on screen. Only the Skill feedback toast still waits for a visible surface. Previously a first send whose surface had gone away left the row without its attachments for the user to find later. `sessions:steer` goes with it. Since Runtime Host took over admission the handler was a narrow re-wrapping of `turn.message.submit`, and its only caller — Side Conversation steering — can name the disposition itself. The IPC channel, its preload method, its bridge contract and the now-unused `steeringContent` validator are gone; `SideChatSessionPort.steer` stays, since Side Conversation's admission authority is #3716's, not this PR's. Steering now carries `inlineReferences: []` like every other submitted Message. Generated-by: Claude Code --- .../app-shell-busy-race-settlement.test.ts | 36 +++ ...me-host-session-execution-ipc-main.test.ts | 45 ++-- .../workbar-services-adapter.test.ts | 17 +- ...runtime-host-session-execution-ipc-main.ts | 30 --- apps/desktop/src/preload/bridge-contract.d.ts | 9 - apps/desktop/src/preload/preload.ts | 11 - .../src/renderer/app-shell-chat-actions.ts | 212 ++++++++++-------- .../desktop/create-workbar-services.ts | 22 +- 8 files changed, 201 insertions(+), 181 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index a901144f0b..ecddbeaec3 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -204,6 +204,42 @@ describe('busy-raced send settlement', () => { } }); + it('retires a Follow Up the Host refused outright', async () => { + const transient = new Map(); + // A Follow Up submitted just as the running Turn settles is admitted as a + // fresh Turn, so an unresolvable Skill token in it is refused outright. + // No Turn opened and no canonical message will ever replace the row, so + // leaving it visible would strand it there for the life of the Session. + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ + ok: false, + reason: 'skill_invocation_failed' as const, + skillInvocation: { + loaded: [], + failed: [{ request: 'typo', reason: 'not_found' as const }], + receipts: [], + }, + }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + + await actions.enqueueMessage('session-a', '/skill:typo do this next', 'next_turn'); + + assert.deepEqual([...transient.keys()], []); + } finally { + restoreWindow(); + } + }); + it('does not resurrect a Follow Up retracted before its IPC reply settles', async () => { const transient = new Map(); let submittedMessageId: string | 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 7a982498a7..09ac53260a 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 @@ -971,28 +971,6 @@ test("retries a dispatched busy fallback with its original message identity", as ); }); -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", - }), - }), - 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[] = []; @@ -1410,15 +1388,24 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn ); assert.deepEqual( - await ipc.invoke("sessions:steer", "session-1", " Continue ", "steer-ticket-1"), - { - kind: "queued", + await ipc.invoke("sessions:submitMessage", "session-1", "current_turn", { messageId: "steer-ticket-1", + text: "Continue", + }), + { + ok: true, + disposition: "steering", + attachments: [], + inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, }, ); assert.deepEqual( - await ipc.invoke('sessions:steer', 'session-1', 'Continue', 'unknown-ticket'), - { kind: 'outcome_unknown', messageId: 'unknown-ticket' }, + await ipc.invoke('sessions:submitMessage', 'session-1', 'current_turn', { + messageId: 'unknown-ticket', + text: 'Continue', + }), + { ok: false, reason: 'outcome_unknown' }, ); assert.deepEqual( await ipc.invoke("sessions:stop", "session-1", { @@ -1467,13 +1454,13 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn { sessionId: "session-1", messageId: "steer-ticket-1", - content: { text: "Continue" }, + content: { text: "Continue", inlineReferences: [] }, placement: "current_turn", }, { sessionId: 'session-1', messageId: 'unknown-ticket', - content: { text: 'Continue' }, + content: { text: 'Continue', inlineReferences: [] }, placement: 'current_turn', }, ]); diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 95c00f64fb..2240b5febd 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -41,13 +41,26 @@ function createBridgeRecorder(): { 'artifacts.subscribeChanges', 'inspector.subscribeUsageChanges', ]); + // Adapters that reshape a bridge answer need one to reshape. + const answers = new Map([ + [ + 'sessions.submitMessage', + { + ok: true, + disposition: 'steering', + attachments: [], + inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }, + ], + ]); const domain = (name: string) => new Proxy({}, { get: (_target, property) => (...args: unknown[]) => { const callName = `${name}.${String(property)}`; calls.push({ name: callName, args }); if (syncMethods.has(callName)) return () => undefined; - return Promise.resolve(undefined); + return Promise.resolve(answers.get(callName)); }, }); @@ -215,7 +228,7 @@ describe('createDesktopWorkbarServices', () => { 'sessions.abandonSessionCopy', 'sessions.send', 'sessions.stop', - 'sessions.steer', + 'sessions.submitMessage', 'sessions.setPermissionMode', 'sessions.regenerateTurn', 'sessions.respondToSandboxBoundary', 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 709c655816..822fa81f44 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 @@ -423,26 +423,6 @@ export function registerRuntimeHostSessionExecutionIpc( }, ); - ipcMain.handle( - "sessions:steer", - async (_event, sessionId: string, text: unknown, admissionId: unknown) => { - const content = steeringContent(text); - const messageId = admissionId === undefined ? newId() : requiredId(admissionId, "Admission"); - 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, - }; - }, - ); ipcMain.handle( "sessions:submitMessage", async (event, sessionId: string, placement: unknown, value: unknown) => { @@ -953,16 +933,6 @@ function requiredSequence(value: unknown, label: string): number { return value as number; } -function steeringContent(value: unknown): string { - if ( - typeof value !== "string" || - value.trim().length === 0 || - value.length > 128_000 - ) { - throw new Error("Invalid steering text"); - } - return value.trim(); -} function isTerminalStatus(status: string): boolean { return ( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 4b32ace702..eda0ce3ab3 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -844,15 +844,6 @@ export interface MakaBridge { expectedAdmissionId?: string; }, ): Promise; - steer( - sessionId: string, - text: string, - admissionId?: string, - ): Promise< - | { kind: 'queued'; messageId: string } - | { kind: 'outcome_unknown'; messageId: string } - | { kind: 'started'; turnId: string } - >; /** * The single Message admission path. Skill and orchestration intent travel * with the Message; Runtime Host decides whether it opens its own Turn, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1f90ee20fd..a0bfc310f3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1624,17 +1624,6 @@ const makaBridge = { ): Promise { return invokeSessionRuntimeHost('sessions:stop', sessionId, input); }, - steer( - sessionId: string, - text: string, - admissionId?: string, - ): Promise< - | { kind: 'queued'; messageId: string } - | { kind: 'outcome_unknown'; messageId: string } - | { kind: 'started'; turnId: string } - > { - return invokeSessionRuntimeHost('sessions:steer', sessionId, text, admissionId); - }, async submitMessage(sessionId, placement, command) { const session = await runtimeHostSessionRef(sessionId); const attachmentItems = command.attachmentItems diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 86c7233d76..b2664a7fe0 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -23,6 +23,7 @@ import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; import type { InlineReference, QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; import type { StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; @@ -298,6 +299,81 @@ export function createAppShellChatActions(deps: { }); } + /** + * What a submitted Message became, as far as this client can tell. + * + * `unreconciled` is the only outcome that leaves the transient row in place: + * the answer was lost, so Runtime Host may well have acted on the Message and + * canonical transcript is what settles it. A `refused` Message opened no Turn + * and will never be replaced by a canonical one, so its row is already gone. + */ + type SubmittedMessage = + | { kind: 'projected'; skillInvocation: SkillInvocationResult; turnId?: string } + | { kind: 'unreconciled' } + | { kind: 'refused'; skillInvocation: SkillInvocationResult }; + + /** + * The one place a submitted Message's outcome becomes UI. Every submission — + * first send, send into an existing Session, Follow Up — projects its row the + * same way, so the rules for retiring and updating it cannot drift apart. + */ + async function submitAndProject(input: { + sessionId: string; + messageId: string; + placement: 'current_turn' | 'next_turn'; + command: Omit< + Parameters[2], + 'messageId' + >; + displayText?: string; + quotes?: readonly QuoteRef[]; + exactTurn?: boolean; + /** Whether this Session's surface is on screen to receive Skill feedback. */ + isSurfaceVisible?: () => boolean; + }): Promise { + const { sessionId, messageId, placement } = input; + const quotes = input.quotes ?? []; + const result = await window.maka.sessions.submitMessage(sessionId, placement, { + ...input.command, + messageId, + }); + const surfaceVisible = input.isSurfaceVisible?.() ?? true; + if (!result.ok) { + if (result.reason === 'outcome_unknown') return { kind: 'unreconciled' }; + removeOptimisticUserMessage(sessionId, messageId); + if (input.exactTurn) disarmTurnActive(sessionId, messageId); + if (surfaceVisible) { + showSkillInvocationFeedback(uiLocale, toastApi, result.skillInvocation, sessionId); + } + return { kind: 'refused', skillInvocation: result.skillInvocation }; + } + if (surfaceVisible) { + showSkillInvocationFeedback(uiLocale, toastApi, result.skillInvocation, sessionId); + } + // The row is updated whether or not the surface is on screen: attachments, + // inline references and the Host Turn grouping are what the user finds when + // they come back to it. + showTransientUserMessage( + sessionId, + messageId, + input.displayText ?? + skillInvocationDisplayText(input.command.text, result.skillInvocation), + result.attachments, + { + updateOnly: true, + placement, + ...(result.turnId ? { hostTurnId: result.turnId } : {}), + ...(quotes.length > 0 ? { quotes } : {}), + inlineReferences: result.inlineReferences ?? [], + }, + ); + return { + kind: 'projected', + skillInvocation: result.skillInvocation, + ...(result.turnId ? { turnId: result.turnId } : {}), + }; + } + async function send( text: string, pending?: readonly PendingAttachment[], @@ -400,67 +476,31 @@ export function createAppShellChatActions(deps: { ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), }; - const sendResult = await window.maka.sessions.submitMessage( - session.id, - 'current_turn', - { + const submitted = await submitAndProject({ + sessionId: session.id, + messageId, + placement: 'current_turn', + command: { ...sendCommand, - messageId, ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), }, - ); - if (!sendResult.ok) { - if (sendResult.reason === 'outcome_unknown') { - unsentSessionId = undefined; - options.onSessionResolved?.(session.id); - if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - setNavSelection({ section: 'sessions' }); - setActiveId(session.id); - } - await refreshSessions(); - return true; - } - removeOptimisticUserMessage(session.id, messageId); - if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - session.id, - ); - } - if (exactTurn) disarmTurnActive(session.id, messageId); + ...(options.displayText ? { displayText: options.displayText } : {}), + ...(quotes && quotes.length > 0 ? { quotes } : {}), + exactTurn, + isSurfaceVisible: () => + Boolean(newChatOwner && isNewChatSendSurfaceActive(newChatOwner)), + }); + if (submitted.kind === 'refused') { await discardUnsentSession(); return false; } unsentSessionId = undefined; options.onSessionResolved?.(session.id); - if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - session.id, - ); - } if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { setNavSelection({ section: 'sessions' }); setActiveId(session.id); - showTransientUserMessage( - session.id, - messageId, - options.displayText ?? - skillInvocationDisplayText(text, sendResult.skillInvocation), - sendResult.attachments, - { - ...(sendResult.turnId ? { hostTurnId: sendResult.turnId } : {}), - updateOnly: true, - ...(quotes && quotes.length > 0 ? { quotes } : {}), - inlineReferences: sendResult.inlineReferences ?? [], - }, - ); } await refreshSessions(); return true; @@ -516,47 +556,22 @@ export function createAppShellChatActions(deps: { ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), }; - const sendResult = await window.maka.sessions.submitMessage(sessionId, 'current_turn', { - ...sendCommand, - messageId, - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), - }); - if (!sendResult.ok) { - if (sendResult.reason === 'outcome_unknown') return true; - removeOptimisticUserMessage(sessionId, messageId); - if (activeIdRef.current === sessionId) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - sessionId, - ); - } - if (exactTurn) disarmTurnActive(sessionId, messageId); - return false; - } - options.onSessionResolved?.(sessionId); - if (activeIdRef.current === sessionId) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - sessionId, - ); - } - showTransientUserMessage( + const submitted = await submitAndProject({ sessionId, messageId, - options.displayText ?? - skillInvocationDisplayText(text, sendResult.skillInvocation), - sendResult.attachments, - { - ...(sendResult.turnId ? { hostTurnId: sendResult.turnId } : {}), - updateOnly: true, - ...(quotes && quotes.length > 0 ? { quotes } : {}), - inlineReferences: sendResult.inlineReferences ?? [], + placement: 'current_turn', + command: { + ...sendCommand, + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), }, - ); + ...(options.displayText ? { displayText: options.displayText } : {}), + ...(quotes && quotes.length > 0 ? { quotes } : {}), + exactTurn, + isSurfaceVisible: () => activeIdRef.current === sessionId, + }); + if (submitted.kind === 'refused') return false; + if (submitted.kind === 'unreconciled') return true; + options.onSessionResolved?.(sessionId); return true; } catch (error) { await discardUnsentSession(); @@ -637,22 +652,21 @@ export function createAppShellChatActions(deps: { try { const attachmentItems = pending?.length ? toComposerIngestItems(pending) : []; const retainedAttachments = pending?.length ? retainedAttachmentRefs(pending) : []; - const result = await window.maka.sessions.submitMessage(sessionId, placement, { + await submitAndProject({ + sessionId, messageId, - text, - ...(attachmentItems.length > 0 ? { attachmentItems } : {}), - ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}), - ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), - ...(options.workspaceFileReferences?.length - ? { workspaceFileReferences: [...options.workspaceFileReferences] } - : {}), - }); - if (!result.ok) return; - showTransientUserMessage(sessionId, messageId, text, result.attachments, { - updateOnly: true, placement, + command: { + text, + ...(attachmentItems.length > 0 ? { attachmentItems } : {}), + ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}), + ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), + ...(options.workspaceFileReferences?.length + ? { workspaceFileReferences: [...options.workspaceFileReferences] } + : {}), + }, ...(quotes.length > 0 ? { quotes } : {}), - inlineReferences: result.inlineReferences, + isSurfaceVisible: () => activeIdRef.current === sessionId, }); } catch (error) { removeOptimisticUserMessage(sessionId, messageId); 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 2c3e36db07..2863994d66 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -131,7 +131,27 @@ export function createDesktopWorkbarServices( ); return result?.kind === 'retracted' ? result : undefined; }, - steer: (sessionId, text, admissionId) => bridge.sessions.steer(sessionId, text, admissionId), + // Steering is a Message placed at the current Turn's boundary, so it + // rides the one admission channel. Runtime Host names the outcome; this + // adapter only renames it for the Side Conversation port. + steer: async (sessionId, text, admissionId) => { + const messageId = admissionId ?? crypto.randomUUID(); + const result = await bridge.sessions.submitMessage(sessionId, 'current_turn', { + messageId, + text, + }); + if (!result.ok) { + if (result.reason === 'outcome_unknown') { + return { kind: 'outcome_unknown', messageId }; + } + // No Turn opened and nothing was queued; the caller surfaces it as a + // failed send rather than waiting for an admission that never lands. + throw new Error('Runtime Host refused the steering Message'); + } + return result.disposition === 'turn_started' && result.turnId + ? { kind: 'started', turnId: result.turnId } + : { kind: 'queued', messageId }; + }, setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), regenerateTurn: (sessionId, input) => From 0641b7cc054a12008f8991d425f4cdd4adae65fb Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 12:59:06 +0800 Subject: [PATCH 17/26] refactor: narrow two Message admission shapes to what reads them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two carriers outlived the readers they were shaped for. `readCancelledMessageAdmission` returned the tombstone's digest and placement, but its only production caller asks one thing: is this Message identity cancelled. The columns never left the storage layer, and the digest regex and placement validation existed to guard a payload nothing consumed. It becomes `hasCancelledMessageAdmission`, reusing the existence query the commit path already runs two functions above. `SkillInvocationBlockedError` carried a `SkillInvocationResult` for a `catch` that no longer exists — the CLI TUI now learns about a refused invocation from the `blocked` submit disposition instead. The remaining thrower is `preparePrompt`, whose only caller is headless `maka run`, and that path reports the failure as an ordinary error without reading the payload. So the reasons move into the message: `maka run` now says which Skill could not be resolved and why, where before it said only that one could not be. Generated-by: Claude Code --- .../cli/src/__tests__/pi-tui-runner.test.ts | 23 ++++++------ .../runtime-host-session-driver.test.ts | 11 +++--- .../cli/src/runtime-host-session-driver.ts | 7 ++-- packages/cli/src/session-driver.ts | 20 ++++++++--- .../src/__tests__/message-coordinator.test.ts | 12 ++----- .../src/server/message-coordinator.ts | 8 ++--- .../sqlite-session-metadata-store.test.ts | 7 ++-- packages/storage/src/execution-stores.ts | 4 +-- .../storage/src/message-admission-store.ts | 16 ++++----- packages/storage/src/session-store.ts | 4 +-- .../src/sqlite-session-metadata-store.ts | 36 +++---------------- 11 files changed, 58 insertions(+), 90 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index d124b246c7..0e6aa39b03 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -55,7 +55,7 @@ import type { RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; -import { SkillInvocationBlockedError } from '../session-driver.js'; +import { skillInvocationBlockedMessage } from '../session-driver.js'; import { listApiKeyOnboardableProviders } from '../onboarding-catalog.js'; import type { MakaOnboardingSurface, @@ -7666,29 +7666,28 @@ class HostSkillDriver extends SlashCommandDriver { super(); } + /** Nothing the Host could resolve, so it opens no Turn for this Message. */ + #refuses(): boolean { + return this.skillInvocation.loaded.length === 0 && this.skillInvocation.failed.length > 0; + } + // The Host answers a refused invocation with a `blocked` disposition rather - // than a Turn; the driver surfaces that as the submit result. + // than a Turn; the driver hands that back as the submit result. override async submitMessage( text: string, options: MakaSubmitMessageOptions, ): Promise { - try { - return await super.submitMessage(text, options); - } catch (error) { - if (error instanceof SkillInvocationBlockedError) { - return { disposition: 'blocked', skillInvocation: error.skillInvocation }; - } - throw error; + if (this.#refuses()) { + return { disposition: 'blocked', skillInvocation: this.skillInvocation }; } + return super.submitMessage(text, options); } override async preparePrompt( prompt: string, options: MakaPreparePromptOptions = {}, ): Promise { - if (this.skillInvocation.loaded.length === 0 && this.skillInvocation.failed.length > 0) { - throw new SkillInvocationBlockedError(this.skillInvocation); - } + if (this.#refuses()) throw new Error(skillInvocationBlockedMessage(this.skillInvocation)); const turn = await super.preparePrompt(prompt, options); return { ...turn, skillInvocation: this.skillInvocation }; } 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 9f8d85b44f..177b00a66e 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -48,7 +48,7 @@ import { createRuntimeHostMakaSessionDriver, type RuntimeHostMakaSessionDriverInput, } from '../runtime-host-session-driver.js'; -import { SkillInvocationBlockedError, type MakaAttachedSessionTurn } from '../session-driver.js'; +import type { MakaAttachedSessionTurn } from '../session-driver.js'; import { WAIT_BUDGET_MS } from './tui-terminal-mock.js'; describe('Runtime Host Maka Session driver', () => { @@ -1462,10 +1462,11 @@ describe('Runtime Host Maka Session driver', () => { assert.equal(connection.requests.at(-1)?.operation, 'turn.start'); connection.skillStartBlocked = true; - await assert.rejects( - driver.preparePrompt('/skill:missing', { turnId: 'turn-blocked' }), - SkillInvocationBlockedError, - ); + // The failure names what could not be resolved: headless `maka run` reports + // this message and nothing reads a structured payload off it. + await assert.rejects(driver.preparePrompt('/skill:missing', { turnId: 'turn-blocked' }), { + message: /Could not resolve the Skill this Turn asked for: \/skill:missing \(not found\)/, + }); }); test('retires a pending question when another client answers it', async () => { diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index dfb19820c3..0a190d35b2 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -93,7 +93,10 @@ import type { RewindTarget, SessionResumeAvailability, } from './session-driver.js'; -import { inspectSessionResumeAvailability, SkillInvocationBlockedError } from './session-driver.js'; +import { + inspectSessionResumeAvailability, + skillInvocationBlockedMessage, +} from './session-driver.js'; import { cwdRank, firstLine, @@ -321,7 +324,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { }; const result = await this.#connection.request('turn.start', startInput); if (result.kind === 'blocked') { - throw new SkillInvocationBlockedError(result.skillInvocation); + throw new Error(skillInvocationBlockedMessage(result.skillInvocation)); } const started = result.turn; const skillInvocation = diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 29f6e56335..50114b7202 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -108,11 +108,21 @@ export interface MakaRetractedMessages { messageIds: readonly string[]; } -export class SkillInvocationBlockedError extends Error { - constructor(readonly skillInvocation: SkillInvocationResult) { - super('Explicit Skill invocation could not be resolved'); - this.name = 'SkillInvocationBlockedError'; - } +/** + * Why Runtime Host refused to open a Turn for an explicit Skill invocation. + * `turn.start`'s only remaining caller is headless `maka run`, which reports + * this as an ordinary failure, so the reasons belong in the message rather + * than in a payload nothing reads. + */ +export function skillInvocationBlockedMessage(skillInvocation: SkillInvocationResult): string { + const reasons = skillInvocation.failed.map((failure) => + failure.reason === 'too_many_requests' + ? `more than ${failure.requestLimit} Skill requests` + : `/skill:${failure.request} (${failure.reason.replaceAll('_', ' ')})`, + ); + return reasons.length > 0 + ? `Could not resolve the Skill this Turn asked for: ${reasons.join(', ')}` + : 'Explicit Skill invocation could not be resolved'; } export interface MakaSessionDriver { diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index f255b59ca1..4ac57c9315 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -2297,16 +2297,8 @@ function memoryMessageAdmissionStore( return admission; }, readMessageAdmission: async (_sessionId, messageId) => admissions.get(messageId)?.admission, - readCancelledMessageAdmission: async (_sessionId, messageId) => { - const entry = admissions.get(messageId); - return entry?.state === 'cancelled' - ? { - messageId, - submittedContentDigest: entry.admission.submittedContentDigest, - submittedPlacement: entry.admission.submittedPlacement, - } - : undefined; - }, + hasCancelledMessageAdmission: async (_sessionId, messageId) => + admissions.get(messageId)?.state === 'cancelled', listMessageAdmissions: async (sessionId) => [...admissions.values()] .filter(({ admission, state }) => admission.sessionId === sessionId && state === 'accepted') diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 0575d42012..c21b121f91 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -395,11 +395,9 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { }): Promise> { const cancelledMessageIds: string[] = []; for (const messageId of input.messageIds) { - const cancelled = await this.#admissions.readCancelledMessageAdmission( - input.sessionId, - messageId, - ); - if (cancelled) cancelledMessageIds.push(messageId); + if (await this.#admissions.hasCancelledMessageAdmission(input.sessionId, messageId)) { + cancelledMessageIds.push(messageId); + } } return success({ cancelledMessageIds }); } diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 93027587f8..f0b771fd53 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -445,11 +445,8 @@ describe('SqliteSessionMetadataStore', () => { await store.commitMessageAdmission(admission); await store.cancelMessageAdmissions('session-1', ['message-1']); assert.deepEqual(await store.listMessageAdmissions('session-1'), []); - assert.deepEqual(await store.readCancelledMessageAdmission('session-1', 'message-1'), { - messageId: 'message-1', - submittedContentDigest: messageContentDigest({ text: 'discard this draft' }), - submittedPlacement: 'next_turn', - }); + assert.equal(await store.hasCancelledMessageAdmission('session-1', 'message-1'), true); + assert.equal(await store.hasCancelledMessageAdmission('session-1', 'message-2'), false); await assert.rejects( store.commitMessageAdmission(admission), /identity is already cancelled/, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 88201cf48a..bd9dbe67e3 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -415,8 +415,8 @@ async function createExecutionStoresForWrite sessionStore.commitMessageAdmission(admission)), readMessageAdmission: (sessionId, messageId) => run(() => sessionStore.readMessageAdmission(sessionId, messageId)), - readCancelledMessageAdmission: (sessionId, messageId) => - run(() => sessionStore.readCancelledMessageAdmission(sessionId, messageId)), + hasCancelledMessageAdmission: (sessionId, messageId) => + run(() => sessionStore.hasCancelledMessageAdmission(sessionId, messageId)), listMessageAdmissions: (sessionId) => run(() => sessionStore.listMessageAdmissions(sessionId)), markMessagesHandedOff: (input) => run(() => sessionStore.markMessagesHandedOff(input)), diff --git a/packages/storage/src/message-admission-store.ts b/packages/storage/src/message-admission-store.ts index e0b728e109..25fe352c70 100644 --- a/packages/storage/src/message-admission-store.ts +++ b/packages/storage/src/message-admission-store.ts @@ -35,22 +35,18 @@ export interface PendingMessageAdmission { readonly admittedAt: number; } -export interface CancelledMessageAdmission { - readonly messageId: string; - readonly submittedContentDigest: `sha256:${string}`; - readonly submittedPlacement: 'current_turn' | 'next_turn'; -} - export interface MessageAdmissionStore { commitMessageAdmission(admission: PendingMessageAdmission): Promise; readMessageAdmission( sessionId: string, messageId: string, ): Promise; - readCancelledMessageAdmission( - sessionId: string, - messageId: string, - ): Promise; + /** + * Whether this Message identity carries a cancellation tombstone. That a + * Message was cancelled is the whole fact callers need — the tombstone's + * own columns never leave this layer. + */ + hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise; listMessageAdmissions(sessionId: string): Promise; markMessagesHandedOff(input: { sessionId: string; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 3d69a52679..10d045a4d5 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -888,9 +888,9 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readMessageAdmission(sessionId, messageId); } - async readCancelledMessageAdmission(sessionId: string, messageId: string) { + async hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise { await this.ensureReady(); - return this.metadata.readCancelledMessageAdmission(sessionId, messageId); + return this.metadata.hasCancelledMessageAdmission(sessionId, messageId); } async listMessageAdmissions(sessionId: string): Promise { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index a2ed11dfc7..8eb75134a8 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -1656,45 +1656,17 @@ export class SqliteSessionMetadataStore { }); } - async readCancelledMessageAdmission( - sessionId: string, - messageId: string, - ): Promise< - | { - messageId: string; - submittedContentDigest: `sha256:${string}`; - submittedPlacement: 'current_turn' | 'next_turn'; - } - | undefined - > { + async hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise { this.assertOpen(); assertSafeSessionId(sessionId); assertSafeSessionId(messageId); return this.readTransaction(() => { const row = this.db .prepare( - ` - SELECT submitted_content_digest, submitted_placement - FROM cancelled_message_admissions - WHERE session_id = ? AND message_id = ? - `, + 'SELECT 1 AS present FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', ) - .get(sessionId, messageId) as - | { submitted_content_digest?: unknown; submitted_placement?: unknown } - | undefined; - if (!row) return undefined; - if ( - typeof row.submitted_content_digest !== 'string' || - !/^sha256:[a-f0-9]{64}$/u.test(row.submitted_content_digest) || - (row.submitted_placement !== 'current_turn' && row.submitted_placement !== 'next_turn') - ) { - throw new SessionMetadataConflictError('Invalid cancelled Message admission identity'); - } - return { - messageId, - submittedContentDigest: row.submitted_content_digest as `sha256:${string}`, - submittedPlacement: row.submitted_placement, - }; + .get(sessionId, messageId); + return row !== undefined; }); } From 3b76ee9940b9010f15fd24382d6e40d88f70e650 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 13:07:09 +0800 Subject: [PATCH 18/26] test: fold the repeated driver and dependency scaffolding into one fixture each The CLI runner suite hand-wrote ten `MakaSessionDriver` implementations that differed in one behaviour apiece and repeated the other fourteen members verbatim, and the two `createAppShellChatActions` suites each carried a full copy of the actions' wide dependency surface. Both shapes have to be edited once per copy whenever the driver interface or the dependency list grows, and a copy that is missed drifts silently rather than failing. Give each its single owner: an abstract `FakeSessionDriver` base that supplies the inert members and the Host-admission `submitMessage`, leaving subclasses to state only the behaviour they exist to exercise; and a `app-shell-chat-actions-fixture` module holding the window installer, the turn and message state doubles, and the dependency factory. No test behaviour changes; the CLI suite and both Desktop suites pass unchanged. Generated-by: Claude Code --- .../app-shell-busy-race-settlement.test.ts | 95 +--- .../app-shell-chat-actions-fixture.ts | 123 +++++ .../app-shell-first-send-cleanup.test.ts | 82 +-- .../cli/src/__tests__/pi-tui-runner.test.ts | 499 +++--------------- 4 files changed, 197 insertions(+), 602 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index ecddbeaec3..1d0c62c7b3 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -32,94 +32,13 @@ import type { StoredMessage } from '@maka/core/session'; import type { LiveTurnProjection } from '@maka/ui'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -function installWindow(maka: unknown): () => void { - const target = globalThis as unknown as { window?: unknown }; - const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window'); - const previousWindow = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - value: { maka }, - writable: true, - }); - return () => { - if (hadWindow) { - Object.defineProperty(target, 'window', { - configurable: true, - value: previousWindow, - writable: true, - }); - } else { - delete target.window; - } - }; -} - -function createTurnState() { - const liveTurnBySession: Record = {}; - return { - liveTurnBySession, - setLiveTurnBySession( - updater: (c: Record) => Record, - ) { - const next = updater({ ...liveTurnBySession }); - for (const key of Object.keys(liveTurnBySession)) delete liveTurnBySession[key]; - Object.assign(liveTurnBySession, next); - }, - }; -} - -function createMessageState() { - const messages: StoredMessage[] = []; - return { - messages, - setMessages(updater: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) { - const next = typeof updater === 'function' ? updater([...messages]) : updater; - messages.length = 0; - messages.push(...next); - }, - }; -} - -function createActionsDeps() { - return { - uiLocale: 'en' as const, - activeIdRef: { current: undefined as string | undefined }, - addPendingSessionAction: () => true, - captureComposerImportOwner: () => ({ - sessionId: undefined, - navSection: 'sessions' as const, - }), - checkTaskSubmissionReadiness: async () => true, - clearPendingSessionAction: () => undefined, - isNewChatSendSurfaceActive: () => true, - isShellSurfaceOwnerActive: () => true, - markSessionReadLocally: () => undefined, - messageRetryPendingRef: { current: new Set() }, - refreshSessions: async () => [], - setActiveId: () => undefined, - setMessageLoadErrorBySession: () => undefined, - setMessageRetryPendingBySession: () => undefined, - setMessages: () => undefined, - addTransientMessage: () => undefined, - updateTransientMessage: () => undefined, - removeTransientMessage: () => undefined, - transcriptRangeRef: { current: undefined }, - setNavSelection: () => undefined, - setLiveTurnBySession: () => undefined, - setInteractionBySession: () => undefined, - showModelSetupToast: () => undefined, - toastApi: { error: () => undefined, info: () => undefined }, - newChatModel: null, - pendingNewChatThinkingLevel: null, - newChatPermissionChoice: undefined, - clearNewChatPermissionChoice: () => {}, - newChatCollaborationMode: 'agent' as const, - newChatOrchestrationMode: 'default' as const, - newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null }, - }; -} - -const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; +import { + createActionsDeps, + createMessageState, + createTurnState, + EMPTY_SKILL_INVOCATION, + installWindow, +} from './app-shell-chat-actions-fixture.js'; describe('busy-raced send settlement', () => { it('shows a Follow Up immediately and keeps its caller-owned identity', async () => { diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts new file mode 100644 index 0000000000..443d5dbb73 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -0,0 +1,123 @@ +/* + * 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. + */ + +/** + * Shared scaffolding for the `createAppShellChatActions` suites. The dependency + * surface is wide and the suites only ever vary a handful of entries, so a + * second copy of it drifts silently and has to be edited twice whenever the + * actions gain a dependency. + */ + +import type { StoredMessage } from '@maka/core/session'; +import type { LiveTurnProjection } from '@maka/ui'; + +/** Installs a `window.maka` bridge double; the returned function restores it. */ +export function installWindow(maka: unknown): () => void { + const target = globalThis as unknown as { window?: unknown }; + const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window'); + const previousWindow = target.window; + Object.defineProperty(target, 'window', { + configurable: true, + value: { maka }, + writable: true, + }); + return () => { + if (hadWindow) { + Object.defineProperty(target, 'window', { + configurable: true, + value: previousWindow, + writable: true, + }); + } else { + delete target.window; + } + }; +} + +/** + * The live-turn arm as a real map rather than a black-hole stub: a send that + * never lands must leave nothing behind, and that cannot be asserted against a + * no-op setter. + */ +export function createTurnState() { + const liveTurnBySession: Record = {}; + return { + liveTurnBySession, + setLiveTurnBySession( + updater: (c: Record) => Record, + ) { + const next = updater({ ...liveTurnBySession }); + for (const key of Object.keys(liveTurnBySession)) delete liveTurnBySession[key]; + Object.assign(liveTurnBySession, next); + }, + }; +} + +export function createMessageState() { + const messages: StoredMessage[] = []; + return { + messages, + setMessages(updater: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) { + const next = typeof updater === 'function' ? updater([...messages]) : updater; + messages.length = 0; + messages.push(...next); + }, + }; +} + +export function createActionsDeps() { + return { + uiLocale: 'en' as const, + activeIdRef: { current: undefined as string | undefined }, + addPendingSessionAction: () => true, + captureComposerImportOwner: () => ({ + sessionId: undefined, + navSection: 'sessions' as const, + }), + checkTaskSubmissionReadiness: async () => true, + clearPendingSessionAction: () => undefined, + isNewChatSendSurfaceActive: () => true, + isShellSurfaceOwnerActive: () => true, + markSessionReadLocally: () => undefined, + messageRetryPendingRef: { current: new Set() }, + refreshSessions: async () => [], + setActiveId: () => undefined, + setMessageLoadErrorBySession: () => undefined, + setMessageRetryPendingBySession: () => undefined, + setMessages: () => undefined, + addTransientMessage: () => undefined, + updateTransientMessage: () => undefined, + removeTransientMessage: () => undefined, + transcriptRangeRef: { current: undefined }, + setNavSelection: () => undefined, + setLiveTurnBySession: () => undefined, + setInteractionBySession: () => undefined, + showModelSetupToast: () => undefined, + toastApi: { error: () => undefined, info: () => undefined }, + newChatModel: null, + pendingNewChatThinkingLevel: null, + newChatPermissionChoice: undefined, + clearNewChatPermissionChoice: () => {}, + newChatCollaborationMode: 'agent' as const, + newChatOrchestrationMode: 'default' as const, + newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null }, + }; +} + +export const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 729865797c..bdaa74c167 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -39,83 +39,11 @@ import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -function installWindow(maka: unknown): () => void { - const target = globalThis as unknown as { window?: unknown }; - const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window'); - const previousWindow = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - value: { maka }, - writable: true, - }); - return () => { - if (hadWindow) { - Object.defineProperty(target, 'window', { - configurable: true, - value: previousWindow, - writable: true, - }); - } else { - delete target.window; - } - }; -} - -/** - * The live-turn arm as a real map rather than a black-hole stub: a send that - * never lands must leave nothing behind, and that cannot be asserted against a - * no-op setter. - */ -function createTurnState() { - const liveTurnBySession: Record = {}; - return { - liveTurnBySession, - setLiveTurnBySession(updater: (c: Record) => Record) { - const next = updater({ ...liveTurnBySession }); - for (const key of Object.keys(liveTurnBySession)) delete liveTurnBySession[key]; - Object.assign(liveTurnBySession, next); - }, - }; -} - -function createActionsDeps() { - return { - uiLocale: 'en' as const, - activeIdRef: { current: undefined as string | undefined }, - addPendingSessionAction: () => true, - captureComposerImportOwner: () => ({ - sessionId: undefined, - navSection: 'sessions' as const, - }), - checkTaskSubmissionReadiness: async () => true, - clearPendingSessionAction: () => undefined, - isNewChatSendSurfaceActive: () => true, - isShellSurfaceOwnerActive: () => true, - markSessionReadLocally: () => undefined, - messageRetryPendingRef: { current: new Set() }, - refreshSessions: async () => [], - setActiveId: () => undefined, - setMessageLoadErrorBySession: () => undefined, - setMessageRetryPendingBySession: () => undefined, - setMessages: () => undefined, - addTransientMessage: () => undefined, - updateTransientMessage: () => undefined, - removeTransientMessage: () => undefined, - transcriptRangeRef: { current: undefined }, - setNavSelection: () => undefined, - setLiveTurnBySession: () => undefined, - setInteractionBySession: () => undefined, - showModelSetupToast: () => undefined, - toastApi: { error: () => undefined, info: () => undefined }, - newChatModel: null, - pendingNewChatThinkingLevel: null, - newChatPermissionChoice: undefined, - clearNewChatPermissionChoice: () => {}, - newChatCollaborationMode: 'agent' as const, - newChatOrchestrationMode: 'default' as const, - newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null }, - }; -} +import { + createActionsDeps, + createTurnState, + installWindow, +} from './app-shell-chat-actions-fixture.js'; describe('composer first-send cleanup', () => { it('cancels when the composer owner changes during the readiness check', async () => { diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 0e6aa39b03..3c49c6de31 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -6554,9 +6554,26 @@ class ThrowingFocusReportTerminal extends FakeTerminal { } } -class RejectingStopDriver implements MakaSessionDriver { +/** + * The parts of `MakaSessionDriver` every fake in this file answers the same + * way. A driver here exists to vary one behaviour; without a shared base each + * of them restates the whole interface, and a change to it has to be made a + * dozen times over. + * + * Subclasses supply what a Turn is made of — `preparePrompt` and the event + * stream it hands back — and override only the members their scenario bends. + */ +abstract class FakeSessionDriver implements MakaSessionDriver { startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; hostSummary: Partial = {}; + protected sessionId = 'session-1'; + + abstract preparePrompt( + prompt: string, + options?: MakaPreparePromptOptions, + ): Promise; + + abstract promptEvents(prompt: string, turnId?: string): AsyncIterable; submitMessage( text: string, @@ -6576,29 +6593,19 @@ class RejectingStopDriver implements MakaSessionDriver { return { cancelledMessageIds: [] }; } - stopCalls = 0; - async listSessions(): Promise { return []; } - preparePrompt(prompt: string): Promise { - return prepareTestPrompt(this, prompt); - } - - async *promptEvents(_prompt: string): AsyncIterable {} - async *compactSession(): AsyncIterable {} - - async stop(): Promise { - this.stopCalls += 1; - throw new Error('stop failed'); - } + async *compactSession(): AsyncIterable {} + async stop(): Promise {} async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} + async renameSession(_name: string): Promise {} + async setModel(_model: string, _connectionSlug?: string): Promise {} + async setPermissionMode(_mode: PermissionMode): Promise {} + async setThinkingLevel(_level: ThinkingLevel | undefined): Promise {} + async switchSession(sessionId: string): Promise { return switchResult(fakeSessionSummary(sessionId)); } @@ -6606,19 +6613,19 @@ class RejectingStopDriver implements MakaSessionDriver { async listRewindTargets(): Promise { return []; } - async rewindToTurn(): Promise { + + async rewindToTurn(_turnId: string): Promise { throw new Error('rewind not supported in this fake'); } + startNewSession(): void {} - getSessionId(): string { - return 'session-1'; + + getSessionId(): string | null { + return this.sessionId; } } -class SandboxBoundaryPromptDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - +class RejectingStopDriver extends FakeSessionDriver { submitMessage( text: string, options: MakaSubmitMessageOptions, @@ -6637,6 +6644,21 @@ class SandboxBoundaryPromptDriver implements MakaSessionDriver { return { cancelledMessageIds: [] }; } + stopCalls = 0; + + preparePrompt(prompt: string): Promise { + return prepareTestPrompt(this, prompt); + } + + async *promptEvents(_prompt: string): AsyncIterable {} + + async stop(): Promise { + this.stopCalls += 1; + throw new Error('stop failed'); + } +} + +class SandboxBoundaryPromptDriver extends FakeSessionDriver { readonly boundaryResponses: SandboxBoundaryResponse[] = []; boundaryRequests = 0; stopCalls = 0; @@ -6646,18 +6668,14 @@ class SandboxBoundaryPromptDriver implements MakaSessionDriver { private readonly paths: readonly string[] = ['/outside'], private readonly beforeBoundaryAck: (index: number) => Promise = async () => {}, private readonly beforeBoundaryRequest: (index: number) => Promise = async () => {}, - ) {} - - async listSessions(): Promise { - return []; + ) { + super(); } preparePrompt(prompt: string): Promise { return prepareTestPrompt(this, prompt); } - async *compactSession(): AsyncIterable {} - async *promptEvents(_prompt: string): AsyncIterable { for (const [index, path] of this.paths.entries()) { await this.beforeBoundaryRequest(index); @@ -6716,59 +6734,16 @@ class SandboxBoundaryPromptDriver implements MakaSessionDriver { this.boundaryResponseWaiter = null; waiter?.(); } - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } -class UserQuestionPromptDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - - submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { - return admitMessageAsTurn(this, text, options); - } - - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.startedTurnListener = listener; - return () => { - if (this.startedTurnListener === listener) this.startedTurnListener = undefined; - }; - } - - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - +class UserQuestionPromptDriver extends FakeSessionDriver { readonly responses: UserQuestionResponse[] = []; stopCalls = 0; private release: (() => void) | undefined; - async listSessions(): Promise { - return []; - } preparePrompt(prompt: string): Promise { return prepareTestPrompt(this, prompt); } - async *compactSession(): AsyncIterable {} async *promptEvents(_prompt: string): AsyncIterable { yield { type: 'user_question_request', @@ -6799,63 +6774,21 @@ class UserQuestionPromptDriver implements MakaSessionDriver { this.stopCalls += 1; this.release?.(); } - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - async listRewindTargets(): Promise { - return []; - } async rewindToTurn(): Promise { throw new Error('rewind not supported'); } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } -class InterruptibleTurnDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - - submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { - return admitMessageAsTurn(this, text, options); - } - - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.startedTurnListener = listener; - return () => { - if (this.startedTurnListener === listener) this.startedTurnListener = undefined; - }; - } - - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - +class InterruptibleTurnDriver extends FakeSessionDriver { stopCalls = 0; readonly prompts: string[] = []; private releaseTurn: (() => void) | null = null; - async listSessions(): Promise { - return []; - } - preparePrompt(prompt: string): Promise { this.prompts.push(prompt); return prepareTestPrompt(this, prompt); } - async *compactSession(): AsyncIterable {} - /** Bumped when the drain first pulls the Turn's stream. */ streamPulls = 0; @@ -6879,36 +6812,12 @@ class InterruptibleTurnDriver implements MakaSessionDriver { this.releaseTurn?.(); this.releaseTurn = null; } - - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } // A parking turn plus an in-memory steering/followup mirror, so the runner's // keybindings (Enter steer, Alt+Enter queue, Alt+↑ retract, Esc Esc refill) can // be exercised end-to-end without a real runtime. -class SteeringTurnDriver implements MakaSessionDriver { - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - +class SteeringTurnDriver extends FakeSessionDriver { stopCalls = 0; goal: GoalProjection | null = null; readonly steered: string[] = []; @@ -6927,11 +6836,6 @@ class SteeringTurnDriver implements MakaSessionDriver { private turnOpen = false; private turnEnded = false; private eventSeq = 0; - private startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - - async listSessions(): Promise { - return []; - } preparePrompt( prompt: string, @@ -6940,14 +6844,12 @@ class SteeringTurnDriver implements MakaSessionDriver { const turnId = options.turnId ?? 'turn-1'; this.turnOrchestrations.push(options.turnOrchestration); return Promise.resolve({ - sessionId: this.getSessionId(), + sessionId: this.sessionId, turnId, events: this.promptEvents(prompt, turnId), }); } - async *compactSession(): AsyncIterable {} - getGoal(): GoalProjection | null { return this.goal; } @@ -7054,24 +6956,9 @@ class SteeringTurnDriver implements MakaSessionDriver { this.wakeTurn = null; } - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } async listRewindTargets(): Promise { return this.rewindTargets; } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } class FailingOrchestrationDriver extends SteeringTurnDriver { @@ -7080,43 +6967,16 @@ class FailingOrchestrationDriver extends SteeringTurnDriver { } } -class SlowStopDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - - submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { - return admitMessageAsTurn(this, text, options); - } - - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.startedTurnListener = listener; - return () => { - if (this.startedTurnListener === listener) this.startedTurnListener = undefined; - }; - } - - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - +class SlowStopDriver extends FakeSessionDriver { stopCalls = 0; readonly prompts: string[] = []; private releaseTurn: (() => void) | null = null; - async listSessions(): Promise { - return []; - } - preparePrompt(prompt: string): Promise { this.prompts.push(prompt); return prepareTestPrompt(this, prompt); } - async *compactSession(): AsyncIterable {} - async *promptEvents(_prompt: string): AsyncIterable { await new Promise((resolve) => { this.releaseTurn = resolve; @@ -7140,60 +7000,13 @@ class SlowStopDriver implements MakaSessionDriver { this.releaseTurn?.(); this.releaseTurn = null; } - - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } -class ToolOutputDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - - submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { - return admitMessageAsTurn(this, text, options); - } - - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.startedTurnListener = listener; - return () => { - if (this.startedTurnListener === listener) this.startedTurnListener = undefined; - }; - } - - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - - async listSessions(): Promise { - return []; - } - +class ToolOutputDriver extends FakeSessionDriver { preparePrompt(prompt: string): Promise { return prepareTestPrompt(this, prompt); } - async *compactSession(): AsyncIterable {} - async *promptEvents(_prompt: string): AsyncIterable { yield { type: 'tool_start', @@ -7232,26 +7045,6 @@ class ToolOutputDriver implements MakaSessionDriver { stopReason: 'end_turn', }; } - - async stop(): Promise {} - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } class BackgroundShellRunDriver extends ToolOutputDriver { @@ -7416,28 +7209,7 @@ function pipeOutput(stdout = '', stderr = '') { }; } -class SlashCommandDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - - submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { - return admitMessageAsTurn(this, text, options); - } - - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.startedTurnListener = listener; - return () => { - if (this.startedTurnListener === listener) this.startedTurnListener = undefined; - }; - } - - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - +class SlashCommandDriver extends FakeSessionDriver { /** Model-facing text (options.modelText when set, else the typed prompt). */ readonly prompts: string[] = []; /** Human-facing typed prompt for every prepared turn. */ @@ -7474,7 +7246,9 @@ class SlashCommandDriver implements MakaSessionDriver { private readonly sessions: SessionSummary[] = [fakeSessionSummary('session-2', '/repo')], private readonly sessionMessages: ReadonlyMap = new Map(), private readonly boundaryDisplayModeBySession: ReadonlyMap = new Map(), - ) {} + ) { + super(); + } async listSessions(): Promise { return this.sessions; @@ -7593,8 +7367,6 @@ class SlashCommandDriver implements MakaSessionDriver { }; } - async stop(): Promise {} - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} async setModel(model: string, connectionSlug?: string): Promise { this.models.push(model); this.modelConnections.push(connectionSlug); @@ -7639,9 +7411,6 @@ class SlashCommandDriver implements MakaSessionDriver { } return switchResult(nextSummary, [...(this.sessionMessages.get(nextSummary.id) ?? [])]); } - async listRewindTargets(): Promise { - return []; - } async rewindToTurn(_turnId: string): Promise { throw new Error('rewind not supported in this fake'); } @@ -8139,42 +7908,15 @@ class LongTranscriptDriver extends SlashCommandDriver { } } -class DeferredControlDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - - submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { - return admitMessageAsTurn(this, text, options); - } - - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.startedTurnListener = listener; - return () => { - if (this.startedTurnListener === listener) this.startedTurnListener = undefined; - }; - } - - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - +class DeferredControlDriver extends FakeSessionDriver { readonly prompts: string[] = []; readonly models: string[] = []; private resolveSetModel: (() => void) | null = null; - async listSessions(): Promise { - return []; - } - preparePrompt(prompt: string): Promise { return prepareTestPrompt(this, prompt); } - async *compactSession(): AsyncIterable {} - async *promptEvents(prompt: string): AsyncIterable { this.prompts.push(prompt); yield { @@ -8186,9 +7928,6 @@ class DeferredControlDriver implements MakaSessionDriver { }; } - async stop(): Promise {} - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async setModel(model: string): Promise { this.models.push(model); await new Promise((resolve) => { @@ -8200,60 +7939,15 @@ class DeferredControlDriver implements MakaSessionDriver { this.resolveSetModel?.(); this.resolveSetModel = null; } - - async renameSession(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } -class RejectingSandboxBoundaryDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - - submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { - return admitMessageAsTurn(this, text, options); - } - - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.startedTurnListener = listener; - return () => { - if (this.startedTurnListener === listener) this.startedTurnListener = undefined; - }; - } - - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - +class RejectingSandboxBoundaryDriver extends FakeSessionDriver { readonly responses: SandboxBoundaryResponse[] = []; - async listSessions(): Promise { - return []; - } - preparePrompt(prompt: string): Promise { return prepareTestPrompt(this, prompt); } - async *compactSession(): AsyncIterable {} - async *promptEvents(_prompt: string): AsyncIterable { yield { type: 'sandbox_boundary_request', @@ -8273,31 +7967,10 @@ class RejectingSandboxBoundaryDriver implements MakaSessionDriver { await new Promise(() => {}); } - async stop(): Promise {} - async respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise { this.responses.push(response); throw new Error('sandbox boundary response rejected'); } - - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } class DeferredListSessionsDriver extends SlashCommandDriver { @@ -8318,41 +7991,14 @@ class DeferredListSessionsDriver extends SlashCommandDriver { } } -class SandboxBoundaryThenErrorDriver implements MakaSessionDriver { - startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - hostSummary: Partial = {}; - - submitMessage( - text: string, - options: MakaSubmitMessageOptions, - ): Promise { - return admitMessageAsTurn(this, text, options); - } - - subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { - this.startedTurnListener = listener; - return () => { - if (this.startedTurnListener === listener) this.startedTurnListener = undefined; - }; - } - - async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { - return { cancelledMessageIds: [] }; - } - +class SandboxBoundaryThenErrorDriver extends FakeSessionDriver { respondCalls = 0; private resolveContinue: (() => void) | null = null; - async listSessions(): Promise { - return []; - } - preparePrompt(prompt: string): Promise { return prepareTestPrompt(this, prompt); } - async *compactSession(): AsyncIterable {} - async *promptEvents(_prompt: string): AsyncIterable { yield { type: 'sandbox_boundary_request', @@ -8379,30 +8025,9 @@ class SandboxBoundaryThenErrorDriver implements MakaSessionDriver { this.resolveContinue = null; } - async stop(): Promise {} - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise { this.respondCalls += 1; } - - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } } class RewindDriver extends SlashCommandDriver { From 8f0057d34233a5384a1f0e74db578a66288f3b30 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 14:05:07 +0800 Subject: [PATCH 19/26] fix(desktop): migrate the remount E2E off the removed steer channel Deleting `sessions:steer` left `streaming-remount.spec.ts` calling a bridge method that no longer exists, so the real-window E2E failed deterministically with `window.maka.sessions.steer is not a function` and took the required `test` check red. Send the background steering through `sessions.submitMessage` instead, which is the single Message admission path the production side chat already uses. Runtime Host owns the disposition, so the test asserts only that the Message was admitted: a refused result rejects immediately with the Host's reason rather than leaving the subscription to time out. Generated-by: Claude Code --- apps/desktop/e2e/streaming-remount.spec.ts | 24 +++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index ca47298563..9ac97f740f 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -231,11 +231,25 @@ test('returning to a live conversation settles output accumulated while away', a unsubscribe(); resolve(); }); - void window.maka.sessions.steer(sessionId, steering).catch((error) => { - window.clearTimeout(timeout); - unsubscribe(); - reject(error); - }); + // Runtime Host decides what this Message becomes; the test only needs it + // to reach the running Turn, so anything short of an accepted admission + // fails closed rather than waiting out the timeout. + void window.maka.sessions + .submitMessage(sessionId, 'current_turn', { + messageId: crypto.randomUUID(), + text: steering, + }) + .then((result) => { + if (result.ok) return; + window.clearTimeout(timeout); + unsubscribe(); + reject(new Error(`Runtime Host refused the steering Message: ${result.reason}`)); + }) + .catch((error) => { + window.clearTimeout(timeout); + unsubscribe(); + reject(error); + }); }), { sessionId: originalSessionId!, steering: backgroundSteering }, ); From b08bebf07e0446d569ddcd7d0d81c9c287634b42 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 14:08:06 +0800 Subject: [PATCH 20/26] fix(cli): give Host admission a single ordered client stream Runtime Host decides what a Message becomes from the state it holds when the Message arrives, so arrival order is part of the meaning. Removing the TUI's submit hold left nothing enforcing it: every Enter started an independent `driver.submitMessage()`, and `#ensureSession()` had no single-flight, so two Enters on a fresh TUI could issue two `session.create` calls and leave the first Message in a Session the TUI no longer displays. On an existing Session the per-submit configuration read could still reorder Host arrival, and a `/model` typed after a Message could overtake it and change the Turn that Message opens. Serialize admission and configuration through one tail in the driver, and share one in-flight Session creation. The tail is deliberately not a gate on user input: the transient row still appears immediately, and Session identity changes stay off the tail so `/session` and `/new` remain the way out of a stuck admission, fenced by the existing generation assert instead. Generated-by: Claude Code --- .../runtime-host-session-driver.test.ts | 90 +++++++++++++++++++ .../cli/src/runtime-host-session-driver.ts | 77 ++++++++++++++-- 2 files changed, 161 insertions(+), 6 deletions(-) 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 177b00a66e..a03b6021e0 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1169,6 +1169,88 @@ describe('Runtime Host Maka Session driver', () => { }); }); + test('admits concurrent first messages into one Session in submission order', async () => { + // Two subscriptions so a driver that creates two Sessions fails on the + // claim rather than on missing fake infrastructure. + const connection = new FakeConnection([ + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + ]); + const create = deferred(); + connection.heldOperations.set('session.create', create.promise); + let nextId = 0; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => `session-${++nextId}`, + }); + + // Two Enters before the first round trip resolves. Nothing about the TUI + // holds the second one back, so the driver is what has to keep them from + // racing into two Sessions or reaching the Host out of order. + const first = driver.submitMessage!('first', { + messageId: 'message-1', + placement: 'current_turn', + }); + const second = driver.submitMessage!('second', { + messageId: 'message-2', + placement: 'current_turn', + }); + create.resolve(); + await Promise.all([first, second]); + + const creates = connection.requests.filter(({ operation }) => operation === 'session.create'); + assert.equal(creates.length, 1); + const submits = connection.requests.filter( + ({ operation }) => operation === 'turn.message.submit', + ); + assert.deepEqual( + submits.map(({ input }) => (input as OperationInput<'turn.message.submit'>).messageId), + ['message-1', 'message-2'], + ); + assert.deepEqual( + new Set( + submits.map(({ input }) => (input as OperationInput<'turn.message.submit'>).sessionId), + ), + new Set(['session-1']), + ); + }); + + test('keeps a configuration change from crossing a pending admission', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + const submit = deferred(); + connection.heldOperations.set('turn.message.submit', submit.promise); + const admitted = driver.submitMessage!('before the model change', { + messageId: 'message-1', + placement: 'current_turn', + }); + // `/model` typed while the Message is still in flight. The Host must see + // it after the Message it was typed after, or the Turn that Message opens + // runs under a model the user had not chosen yet. + const changed = driver.setModel('gpt-5-codex'); + submit.resolve(); + await Promise.all([admitted, changed]); + + const ordered = connection.requests + .map(({ operation }) => operation) + .filter( + (operation) => + operation === 'turn.message.submit' || operation === 'session.configuration.update', + ); + assert.deepEqual(ordered, ['turn.message.submit', 'session.configuration.update']); + }); + test('keeps an unknown message admission available for transcript reconciliation', async () => { const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); const connection = new FakeConnection([subscription]); @@ -1782,6 +1864,12 @@ class FakeConnection { /** Scripted goal.query results, shifted per call; defaults to null (no goal). */ readonly goalQueryResults: Array = []; readonly messageSubmitOutcomes: Array | Error> = []; + /** + * Operations held open by a test. The request is recorded on entry and then + * waits, so a test can hold one round trip and observe what the driver does + * with a second call while the first is still in flight. + */ + readonly heldOperations = new Map>(); readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -1808,6 +1896,8 @@ class FakeConnection { input: OperationInput, ): Promise> { this.requests.push({ operation, input }); + const held = this.heldOperations.get(operation); + if (held) await held; if (operation === 'session.workspace.relocate') { const workspace = (input as OperationInput<'session.workspace.relocate'>).workspace; if (workspace.kind !== 'host_path') throw new Error('Expected Host-path workspace'); diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 0a190d35b2..f564a96369 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -387,7 +387,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { yield* events; } - async submitMessage( + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise | undefined> { + return this.#admit(() => this.#submitMessage(text, options)); + } + + async #submitMessage( text: string, options: MakaSubmitMessageOptions, ): Promise | undefined> { @@ -464,7 +471,11 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { if (pending) this.#channel?.publishInteractionAnswer(answered, pending); } - async setModel(model: string, connectionSlug?: string): Promise { + setModel(model: string, connectionSlug?: string): Promise { + return this.#admit(() => this.#setModel(model, connectionSlug)); + } + + async #setModel(model: string, connectionSlug?: string): Promise { const nextConnection = connectionSlug ?? this.#llmConnectionSlug; if (this.#sessionId) { const session = await this.#updateConfiguration(this.#sessionId, { @@ -479,7 +490,11 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#thinkingLevel = undefined; } - async setThinkingLevel(level: ThinkingLevel | undefined): Promise { + setThinkingLevel(level: ThinkingLevel | undefined): Promise { + return this.#admit(() => this.#setThinkingLevel(level)); + } + + async #setThinkingLevel(level: ThinkingLevel | undefined): Promise { if (this.#sessionId) { this.#adoptConfiguration( await this.#updateConfiguration(this.#sessionId, { thinkingLevel: level ?? null }), @@ -489,7 +504,11 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#thinkingLevel = level; } - async setPermissionMode(mode: PermissionMode): Promise { + setPermissionMode(mode: PermissionMode): Promise { + return this.#admit(() => this.#setPermissionMode(mode)); + } + + async #setPermissionMode(mode: PermissionMode): Promise { if (this.#sessionId) { const session = await this.#updateConfiguration(this.#sessionId, { permissionMode: mode }); this.#permissionMode = session.permissionMode; @@ -502,7 +521,11 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#permissionMode = mode; } - async setOrchestrationMode(mode: OrchestrationMode): Promise { + setOrchestrationMode(mode: OrchestrationMode): Promise { + return this.#admit(() => this.#setOrchestrationMode(mode)); + } + + async #setOrchestrationMode(mode: OrchestrationMode): Promise { if (this.#sessionId) { this.#adoptConfiguration( await this.#updateConfiguration(this.#sessionId, { orchestrationMode: mode }), @@ -912,9 +935,51 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } } + /** + * The ordered client → Host operation stream. + * + * Runtime Host decides what a Message becomes, and it decides from the state + * it holds when the Message arrives. That makes arrival order part of the + * meaning: two Enters typed before the first round trip resolves must not + * race into two Sessions, and a `/model` typed after a Message must not + * overtake it and change the Turn that Message opens. + * + * Session identity changes deliberately stay off this tail. `/session` and + * `/new` are how a user leaves a Session whose admission is stuck, so + * queueing them behind it would remove the only exit; `#assertCurrentSession` + * fences them instead, by failing an admission whose Session moved under it. + */ + #admissionTail: Promise = Promise.resolve(); + + #admit(operation: () => Promise): Promise { + const admitted = this.#admissionTail.then(operation, operation); + // A failed operation must not poison the tail: the next Message is a new + // intent, not a retry of the one that failed. + this.#admissionTail = admitted.then( + () => undefined, + () => undefined, + ); + return admitted; + } + + #sessionCreation: Promise | undefined; + + /** + * One in-flight creation, shared. Reads outside the admission tail + * (`queryCancelledMessages`) can reach this concurrently with an admission, + * and a second `session.create` would leave the first Message in a Session + * the TUI has already stopped displaying. + */ async #ensureSession(): Promise { if (this.#sessionId) return this.#sessionId; - return (await this.#createSession(DEFAULT_SESSION_NAME)).id; + if (this.#sessionCreation) return this.#sessionCreation; + const creation = this.#createSession(DEFAULT_SESSION_NAME).then((session) => session.id); + this.#sessionCreation = creation; + try { + return await creation; + } finally { + if (this.#sessionCreation === creation) this.#sessionCreation = undefined; + } } async #createSession(name: string): Promise { From 4ef27729176d1d751a92b57dcf7b47d9b81e42be Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 14:59:40 +0800 Subject: [PATCH 21/26] fix(desktop): bind the exact-Turn arm to the Turn Runtime Host named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exact-Turn send arms the processing indicator under the client's Message identity, because the model-wait window opens before any SessionEvent arrives. Runtime Host answers with its own Turn identity, and nothing adopted it: every later event carried the Host id, so a Turn that ended before its first text or tool event left the arm unretired and held "正在处理…" and Stop on for good. An admission whose outcome cannot be proven had the same problem from the other side — it kept an arm no event will ever carry. Rebind the arm on `turn_started`, but only while it is still the arm this send placed and still unconfirmed; release it when admission opened no Turn under it. The Message row is unaffected in the unproven case: that is a separate claim, settled by canonical transcript. The same dual identity was written into every transient row as a fabricated `turnId: messageId` beside the real `hostTurnId`, because the projection borrowed `StoredMessage`. Nothing rendered it — the transcript reads id, text, ts, attachments, quotes, inline references, placement and hostTurnId — so `TransientUserMessageProjection` now states those fields itself and the invented Turn identity is gone. Generated-by: Claude Code --- .../app-shell-busy-race-settlement.test.ts | 80 +++++-------- .../app-shell-chat-actions-fixture.ts | 25 +++- .../app-shell-exact-turn-arm.test.ts | 112 ++++++++++++++++++ .../__tests__/message-queue-ui-state.test.ts | 4 - .../main/__tests__/streaming-handoff.test.ts | 8 +- .../transient-message-projection.test.ts | 29 ++--- .../src/renderer/app-shell-chat-actions.ts | 40 ++++++- .../src/renderer/app-shell-session-events.ts | 2 - packages/ui/src/chat-turn.tsx | 3 +- packages/ui/src/chat-view.tsx | 27 ++++- 10 files changed, 240 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/app-shell-exact-turn-arm.test.ts diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 1d0c62c7b3..53540b79a0 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -28,13 +28,12 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { StoredMessage } from '@maka/core/session'; -import type { LiveTurnProjection } from '@maka/ui'; +import type { LiveTurnProjection, TransientUserMessageProjection } from '@maka/ui'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; import { createActionsDeps, - createMessageState, + createTransientState, createTurnState, EMPTY_SKILL_INVOCATION, installWindow, @@ -43,7 +42,7 @@ import { describe('busy-raced send settlement', () => { it('shows a Follow Up immediately and keeps its caller-owned identity', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; - const transient = new Map(); + const transient = new Map(); let submittedMessageId: string | undefined; let releaseAdmission!: () => void; const admission = new Promise((resolve) => { @@ -99,7 +98,7 @@ describe('busy-raced send settlement', () => { }); it('keeps a Follow Up visible when Host admission outcome is unknown', async () => { - const transient = new Map(); + const transient = new Map(); const restoreWindow = installWindow({ sessions: { submitMessage: async () => ({ ok: false, reason: 'outcome_unknown' as const }), @@ -117,14 +116,14 @@ describe('busy-raced send settlement', () => { await actions.enqueueMessage('session-a', 'do this next', 'next_turn'); assert.equal(transient.size, 1); - assert.equal([...transient.values()][0]?.type, 'user'); + assert.equal([...transient.values()][0]?.text, 'do this next'); } finally { restoreWindow(); } }); it('retires a Follow Up the Host refused outright', async () => { - const transient = new Map(); + const transient = new Map(); // A Follow Up submitted just as the running Turn settles is admitted as a // fresh Turn, so an unresolvable Skill token in it is refused outright. // No Turn opened and no canonical message will ever replace the row, so @@ -160,7 +159,7 @@ describe('busy-raced send settlement', () => { }); it('does not resurrect a Follow Up retracted before its IPC reply settles', async () => { - const transient = new Map(); + const transient = new Map(); let submittedMessageId: string | undefined; let releaseAdmission!: () => void; const admission = new Promise((resolve) => { @@ -216,7 +215,7 @@ describe('busy-raced send settlement', () => { it('shows one stable local message before Host admission settles', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; - const transient = new Map(); + const transient = new Map(); let submittedMessageId: string | undefined; let releaseAdmission!: () => void; const admission = new Promise((resolve) => { @@ -260,9 +259,7 @@ describe('busy-raced send settlement', () => { await submitted; assert.ok(submittedMessageId); - const localMessage = transient.get(submittedMessageId); - assert.equal(localMessage?.type, 'user'); - assert.equal(localMessage?.type === 'user' ? localMessage.text : undefined, 'also check the tests'); + assert.equal(transient.get(submittedMessageId)?.text, 'also check the tests'); releaseAdmission(); assert.equal(await sending, true); @@ -277,7 +274,7 @@ describe('busy-raced send settlement', () => { it('keeps one local row when Host admits the message as steering', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const restoreWindow = installWindow({ sessions: { submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ @@ -295,17 +292,13 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, - addTransientMessage: (_sessionId, message) => - messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), - removeTransientMessage: (_sessionId, messageId) => - messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); assert.equal(turnState.liveTurnBySession['session-a'], undefined); - const local = messageState.messages.filter((message) => message.type === 'user'); - assert.equal(local.length, 1); - assert.equal(local[0]?.id, local[0]?.turnId); + // One row for one Message, still under the identity the client sent it + // with: steering admission names no Turn to re-key it to. + assert.equal(transientState.rows.size, 1); } finally { restoreWindow(); } @@ -314,7 +307,7 @@ describe('busy-raced send settlement', () => { it('does not turn a Host-started admission into a renderer-owned LiveTurn', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const restoreWindow = installWindow({ sessions: { submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ @@ -333,17 +326,13 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, - addTransientMessage: (_sessionId, message) => - messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), - removeTransientMessage: (_sessionId, messageId) => - messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); assert.equal(turnState.liveTurnBySession['session-a'], undefined); - const optimistic = messageState.messages.filter((message) => message.type === 'user'); - assert.equal(optimistic.length, 1); - assert.notEqual(optimistic[0]?.id, 'host-turn'); + assert.equal(transientState.rows.size, 1); + assert.equal(transientState.rows.has('host-turn'), false); + assert.equal([...transientState.rows.values()][0]?.hostTurnId, 'host-turn'); } finally { restoreWindow(); } @@ -352,7 +341,7 @@ describe('busy-raced send settlement', () => { it('keeps an authoritative projection that arrived before the send response', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const restoreWindow = installWindow({ sessions: { submitMessage: async (_sessionId: string, command: { messageId: string }) => { @@ -378,11 +367,7 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, - addTransientMessage: (_sessionId, message) => - messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), - removeTransientMessage: (_sessionId, messageId) => - messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); const live = turnState.liveTurnBySession['session-a']; @@ -397,7 +382,7 @@ describe('busy-raced send settlement', () => { it('keeps the new-chat message through navigation when Host admits it as steering', async () => { const activeIdRef = { current: undefined as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const activated: string[] = []; const removed: string[] = []; const restoreWindow = installWindow({ @@ -427,16 +412,12 @@ describe('busy-raced send settlement', () => { activeIdRef.current = sessionId; }, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, - addTransientMessage: (_sessionId, message) => - messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), - removeTransientMessage: (_sessionId, messageId) => - messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); assert.deepEqual(activated, ['session-new']); assert.equal(turnState.liveTurnBySession['session-new'], undefined); - assert.equal(messageState.messages.filter((message) => message.type === 'user').length, 1); + assert.equal(transientState.rows.size, 1); assert.deepEqual(removed, []); } finally { restoreWindow(); @@ -446,7 +427,7 @@ describe('busy-raced send settlement', () => { it('keeps the new-chat messageId when Host chooses another turnId', async () => { const activeIdRef = { current: undefined as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const restoreWindow = installWindow({ newTasks: { create: async () => ({ id: 'session-new' }), @@ -471,17 +452,12 @@ describe('busy-raced send settlement', () => { activeIdRef.current = sessionId; }, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, - addTransientMessage: (_sessionId, message) => - messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), - removeTransientMessage: (_sessionId, messageId) => - messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); assert.equal(turnState.liveTurnBySession['session-new'], undefined); - const optimistic = messageState.messages.filter((message) => message.type === 'user'); - assert.equal(optimistic.length, 1); - assert.notEqual(optimistic[0]?.id, 'host-turn'); + assert.equal(transientState.rows.size, 1); + assert.equal(transientState.rows.has('host-turn'), false); } finally { restoreWindow(); } diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index 443d5dbb73..d4ba820ef5 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -25,7 +25,7 @@ */ import type { StoredMessage } from '@maka/core/session'; -import type { LiveTurnProjection } from '@maka/ui'; +import type { LiveTurnProjection, TransientUserMessageProjection } from '@maka/ui'; /** Installs a `window.maka` bridge double; the returned function restores it. */ export function installWindow(maka: unknown): () => void { @@ -69,6 +69,29 @@ export function createTurnState() { }; } +/** + * The transient arm as a real map. Transient rows are not `StoredMessage`s — + * they have no Turn to belong to yet — so they are held apart from the + * canonical transcript here, exactly as the shell holds them. + */ +export function createTransientState() { + const rows = new Map(); + return { + rows, + deps: { + addTransientMessage: (_sessionId: string, message: TransientUserMessageProjection) => { + rows.set(message.id, message); + }, + updateTransientMessage: (_sessionId: string, message: TransientUserMessageProjection) => { + rows.set(message.id, message); + }, + removeTransientMessage: (_sessionId: string, messageId: string) => { + rows.delete(messageId); + }, + }, + }; +} + export function createMessageState() { const messages: StoredMessage[] = []; return { diff --git a/apps/desktop/src/main/__tests__/app-shell-exact-turn-arm.test.ts b/apps/desktop/src/main/__tests__/app-shell-exact-turn-arm.test.ts new file mode 100644 index 0000000000..e9f7053009 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-exact-turn-arm.test.ts @@ -0,0 +1,112 @@ +/* + * 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. + */ + +/** + * An exact-Turn send arms the processing indicator before it knows the Turn's + * identity, because the model-wait window opens before any SessionEvent + * arrives. Runtime Host names the Turn, so the client-side arm has to adopt + * that name the moment it is answered: a Turn that ends before its first + * text/tool event has nothing else to retire the arm, and an arm nobody can + * retire holds "正在处理…" and Stop on forever. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { TransientUserMessageProjection } from '@maka/ui'; +import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; + +import { + createActionsDeps, + createTurnState, + EMPTY_SKILL_INVOCATION, + installWindow, +} from './app-shell-chat-actions-fixture.js'; + +const GRAPH_TURN = { mode: 'graph', source: 'slash_command' } as const; + +describe('exact-Turn arm identity', () => { + it('adopts the Host Turn identity the admission answered with', async () => { + const turnState = createTurnState(); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ + ok: true, + disposition: 'turn_started' as const, + turnId: 'host-turn-1', + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }), + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + setLiveTurnBySession: turnState.setLiveTurnBySession, + }); + + assert.equal( + await actions.send('run the graph', undefined, { turnOrchestration: GRAPH_TURN }), + true, + ); + } finally { + restoreWindow(); + } + + const armed = turnState.liveTurnBySession['session-a']; + assert.equal(armed?.turnId, 'host-turn-1'); + // Still unconfirmed: the Host named the Turn, it has not yet said anything + // about running it. + assert.equal(armed?.unconfirmed, true); + }); + + it('releases the arm when Host admission opened no Turn under it', async () => { + const turnState = createTurnState(); + const transient = new Map(); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ ok: false, reason: 'outcome_unknown' as const }), + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + setLiveTurnBySession: turnState.setLiveTurnBySession, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + + await actions.send('run the graph', undefined, { turnOrchestration: GRAPH_TURN }); + } finally { + restoreWindow(); + } + + // The Message may well have been admitted, so its row stays for canonical + // transcript to settle. The Turn arm is a different claim: nothing proves + // a Turn opened under this identity, and no event will ever retire it. + assert.equal(turnState.liveTurnBySession['session-a'], undefined); + assert.equal(transient.size, 1); + }); +}); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index cc5de97b64..d9db2f1602 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -86,18 +86,14 @@ test('queue_update events drive the independent desktop queue projection', () => }); assert.deepEqual(transientMessages, [ { - type: 'user', id: 'message-steer', - turnId: 'message-steer', transientPlacement: 'current_turn', hostTurnId: 'turn-1', ts: 1, text: 'adjust this run', }, { - type: 'user', id: 'message-next', - turnId: 'message-next', transientPlacement: 'next_turn', ts: 1, text: 'do this next', diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index fcc91c8e5a..53fb370497 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -102,7 +102,7 @@ describe('single live-turn handoff', () => { ], transientMessages: [ { - type: 'user', id: 'message-pending', turnId: 'message-pending', ts: 2, + id: 'message-pending', ts: 2, text: 'send now', transientPlacement: 'current_turn', }, ], @@ -125,7 +125,7 @@ describe('single live-turn handoff', () => { messages: [], transientMessages: [ { - type: 'user', id: 'turn-1', turnId: 'turn-1', ts: 1, text: 'send now', + id: 'turn-1', ts: 1, text: 'send now', transientPlacement: 'current_turn', }, ], @@ -159,11 +159,11 @@ describe('single live-turn handoff', () => { messages: [], transientMessages: [ { - type: 'user', id: 'message-1', turnId: 'message-1', ts: 1, text: 'send now', + id: 'message-1', ts: 1, text: 'send now', transientPlacement: 'current_turn', }, { - type: 'user', id: 'message-next', turnId: 'message-next', ts: 2, text: 'do this next', + id: 'message-next', ts: 2, text: 'do this next', transientPlacement: 'next_turn', }, ], diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index bfa0358b0f..7755365af8 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -27,10 +27,13 @@ import { reconcileTransientMessages, } from '../../renderer/transient-message-projection.js'; +/** The durable Message that replaces the transient row above. */ +function canonicalSend(): StoredMessage { + return { type: 'user', id: 'message-1', turnId: 'turn-1', ts: 3, text: 'canonical send' }; +} + const transient: TransientUserMessageProjection = { - type: 'user', id: 'message-1', - turnId: 'message-1', ts: 2, text: 'send now', transientPlacement: 'current_turn', @@ -62,28 +65,19 @@ test('updates a transient message without treating its previous render as canoni test('replaces a transient message by canonical message id exactly once', () => { const pending = new Map([[transient.id, transient]]); - const canonical = { ...transient, ts: 3, text: 'canonical send' }; - const projected = reconcileTransientMessages(pending, [canonical]); + const projected = reconcileTransientMessages(pending, [canonicalSend()]); assert.deepEqual(projected, []); assert.equal(pending.size, 0); }); test('canonicalizing one send does not hide a later transient send', () => { - const second = { - ...transient, - id: 'message-2', - turnId: 'message-2', - ts: 4, - text: 'send next', - }; + const second = { ...transient, id: 'message-2', ts: 4, text: 'send next' }; const pending = new Map([ [transient.id, transient], [second.id, second], ]); - const canonical = { ...transient, ts: 3, text: 'canonical send' }; - - const projected = reconcileTransientMessages(pending, [canonical]); + const projected = reconcileTransientMessages(pending, [canonicalSend()]); assert.deepEqual(projected, [second]); assert.deepEqual([...pending.keys()], ['message-2']); @@ -109,10 +103,11 @@ test('keeps transient messages ordered independently from a sparse durable tail' }); test('keeps a transient message out of a sparse historical range', () => { - const live = { ...transient, id: 'message-live', turnId: 'message-live', text: 'latest prompt' }; - const old = { ...transient, id: 'message-old', turnId: 'turn-old', ts: 1, text: 'old prompt' }; + const live = { ...transient, id: 'message-live', text: 'latest prompt' }; const pending = new Map([[live.id, live]]); - const historical = [old]; + const historical: StoredMessage[] = [ + { type: 'user', id: 'message-old', turnId: 'turn-old', ts: 1, text: 'old prompt' }, + ]; const projected = reconcileTransientMessages(pending, historical, { includeTransient: false, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index b2664a7fe0..024d7b671d 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -249,12 +249,7 @@ export function createAppShellChatActions(deps: { ): void { const quotes = options.quotes ?? []; const next: TransientUserMessageProjection = { - type: 'user', id: messageId, - // StoredMessage requires a grouping key, but a transient row is not a - // Turn member yet: `hostTurnId` carries the Host grouping once it exists, - // and canonical transcript replaces the row by this same message id. - turnId: messageId, ts: Date.now(), text, ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), @@ -290,6 +285,23 @@ export function createAppShellChatActions(deps: { }); } + /** + * The arm was placed under the client's Message identity because that is all + * the client had; Runtime Host answers with the Turn identity every later + * event will carry. Adopt it, but only while the arm is still the one this + * send placed and still waiting — once the authority has said anything about + * a Turn here, that Turn is the one on screen and renaming it would retire + * the wrong claim. + */ + function rebindTurnActive(sessionId: string, fromTurnId: string, toTurnId: string): void { + if (fromTurnId === toTurnId) return; + setLiveTurnBySession((current) => { + const active = current[sessionId]; + if (active?.turnId !== fromTurnId || !active.unconfirmed) return current; + return { ...current, [sessionId]: { ...active, turnId: toTurnId } }; + }); + } + function disarmTurnActive(sessionId: string, turnId: string): void { setLiveTurnBySession((current) => { if (current[sessionId]?.turnId !== turnId) return current; @@ -339,7 +351,14 @@ export function createAppShellChatActions(deps: { }); const surfaceVisible = input.isSurfaceVisible?.() ?? true; if (!result.ok) { - if (result.reason === 'outcome_unknown') return { kind: 'unreconciled' }; + if (result.reason === 'outcome_unknown') { + // The Message may well have been admitted, so its row stays for + // canonical transcript to settle. The Turn arm is a different claim: + // nothing proves a Turn opened under this identity, and no event + // carrying it will ever arrive to retire it. + if (input.exactTurn) disarmTurnActive(sessionId, messageId); + return { kind: 'unreconciled' }; + } removeOptimisticUserMessage(sessionId, messageId); if (input.exactTurn) disarmTurnActive(sessionId, messageId); if (surfaceVisible) { @@ -347,6 +366,15 @@ export function createAppShellChatActions(deps: { } return { kind: 'refused', skillInvocation: result.skillInvocation }; } + if (input.exactTurn) { + if (result.disposition === 'turn_started' && result.turnId) { + rebindTurnActive(sessionId, messageId, result.turnId); + } else { + // Host admitted the Message into a Turn this send did not open, so the + // arm placed for an exact Turn describes nothing. + disarmTurnActive(sessionId, messageId); + } + } if (surfaceVisible) { showSkillInvocationFeedback(uiLocale, toastApi, result.skillInvocation, sessionId); } diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 22d56bd87b..5ce1ecc885 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -296,9 +296,7 @@ export function createAppShellSessionEventHandlers(options: { [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])] .filter((entry) => entry.state === 'queued') .map((entry) => ({ - type: 'user', id: entry.messageId, - turnId: entry.messageId, transientPlacement: entry.placement, ...(entry.placement === 'current_turn' ? { hostTurnId: event.turnId } : {}), ts: event.ts, diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 48e270d482..1a5bff3fc7 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -58,6 +58,7 @@ import { type QuoteRef, } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from './chat-view.js'; import { finalAssistantReplyText, type TurnTimelineItem, @@ -278,7 +279,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { }); export function TransientUserMessage(props: { - message: Extract; + message: TransientUserMessageProjection; onReadAttachmentBytes?: ReadAttachmentBytes; }) { const copy = getConversationCopy(useUiLocale()).messages; diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index fb701f18c7..0102d5f6e1 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -30,7 +30,12 @@ import { useMessageSelectionQuote } from './use-message-selection-quote.js'; import type { DeepResearchClientProgress } from '@maka/core/deep-research-run'; import type { ProviderType } from '@maka/core/llm-connections'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; -import type { ShellRunUpdate } from '@maka/core/events'; +import type { + AttachmentRef, + InlineReference, + QuoteRef, + ShellRunUpdate, +} from '@maka/core/events'; import { isDeepResearchSession } from '@maka/core/explore-agent'; import { Button, ButtonGroup, ChatMessageList, EmptyState, Spinner } from '@astryxdesign/core'; import { useChatLayoutContext } from '@astryxdesign/core/Chat'; @@ -60,7 +65,23 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } -export type TransientUserMessageProjection = Extract & { +/** + * A user Message this client has shown but cannot yet prove is durable. + * + * Deliberately not a `StoredMessage`: a stored one belongs to a Turn, and the + * Turn identity is exactly what a client does not have while Runtime Host is + * still deciding what the Message becomes. Borrowing that shape forced a + * fabricated `turnId`, which then had to be kept from being read as the real + * grouping. These are the presentation fields the transcript actually renders, + * plus `hostTurnId` for the grouping once the Host names one. + */ +export interface TransientUserMessageProjection { + id: string; + text: string; + ts: number; + attachments?: readonly AttachmentRef[]; + quotes?: readonly QuoteRef[]; + inlineReferences?: readonly InlineReference[]; /** * Presentation-only placement until canonical transcript grouping arrives: * `current_turn` renders beside the tail Turn, `next_turn` below it. @@ -68,7 +89,7 @@ export type TransientUserMessageProjection = Extract Date: Wed, 26 Aug 2026 15:01:28 +0800 Subject: [PATCH 22/26] fix(desktop): carry the Host admission answer through to its caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime Host answers a submitted Message with one of four outcomes, and every caller collapsed that to "threw" or "did not throw". A refused Follow Up returned normally, so `enqueueFollowUp` reported it as sent: attachments, quotes and the composer draft were cleared for a Message that opened nothing and left no row. `enqueueMessage` now resolves with whether the Message was sent — an unproven outcome still counts as sent, since the Host may hold it and offering the text twice is worse. A second dispatched interruption escaped `submitMessageWithReconnect` as an exception, even though `dispatched` says the request reached the Host and only the answer was lost. The renderer's catch then deleted a Message the Host may be running, and on a first send the Session created for it. It maps to `outcome_unknown` now, as the CLI driver already does. The cancellation-proof query submitted every transient row at once against a protocol cap of 64. A legal full Host queue plus one unreconciled root Message exceeds it, and the whole proof failed, so no cancelled row was ever retired. It queries in chunks of that cap and merges the answers. Generated-by: Claude Code --- .../app-shell-busy-race-settlement.test.ts | 52 +++++++++++++++++++ ...runtime-host-session-execution-ipc-main.ts | 11 ++++ .../src/renderer/app-shell-chat-actions.ts | 14 +++-- apps/desktop/src/renderer/app-shell.tsx | 5 +- .../use-app-shell-session-workspace.ts | 18 +++++-- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 53540b79a0..b4661d00d6 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -158,6 +158,58 @@ describe('busy-raced send settlement', () => { } }); + it('reports a refused Follow Up as not sent', async () => { + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ + ok: false as const, + reason: 'skill_invocation_failed' as const, + skillInvocation: { + loaded: [], + failed: [{ request: 'typo', reason: 'not_found' }], + receipts: [], + }, + }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + }); + + // Refusal is not an exception, so a caller that only watches for a throw + // reads it as sent and clears the draft the user still needs. + assert.equal( + await actions.enqueueMessage('session-a', '/skill:typo do this next', 'next_turn'), + false, + ); + } finally { + restoreWindow(); + } + }); + + it('reports an unproven Follow Up as sent so its text is not offered twice', async () => { + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ ok: false as const, reason: 'outcome_unknown' as const }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + }); + + assert.equal( + await actions.enqueueMessage('session-a', 'do this next', 'next_turn'), + true, + ); + } finally { + restoreWindow(); + } + }); + it('does not resurrect a Follow Up retracted before its IPC reply settles', async () => { const transient = new Map(); let submittedMessageId: string | undefined; 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 822fa81f44..36991da0bd 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 @@ -128,6 +128,17 @@ async function submitMessageWithReconnect( if (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') { return undefined; } + // `dispatched` means the request reached Runtime Host and the answer was + // lost, which is the same thing `outcome_unknown` says. The retry above + // covers one interruption; a second one is still an unknown outcome, and + // raising it would have the renderer delete a Message the Host may hold + // — and, on a first send, the Session created for it. + if ( + error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched' + ) { + return undefined; + } throw error; } } diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 024d7b671d..69373f745a 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -114,6 +114,11 @@ export interface AppShellChatActions { onSessionResolved?: (sessionId: string) => void; }, ): Promise; + /** + * Resolves with whether the Message was sent. An unproven outcome counts as + * sent — Runtime Host may well have it — so the caller does not offer the + * same text twice; only a refusal is `false`. + */ enqueueMessage( sessionId: string, text: string, @@ -123,7 +128,7 @@ export interface AppShellChatActions { quotes?: readonly QuoteRef[]; workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; }, - ): Promise; + ): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; @@ -669,7 +674,7 @@ export function createAppShellChatActions(deps: { quotes?: readonly QuoteRef[]; workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; } = {}, - ): Promise { + ): Promise { const messageId = crypto.randomUUID(); const quotes = options.quotes ?? []; showTransientUserMessage(sessionId, messageId, text, retainedAttachmentRefs(pending ?? []), { @@ -680,7 +685,7 @@ export function createAppShellChatActions(deps: { try { const attachmentItems = pending?.length ? toComposerIngestItems(pending) : []; const retainedAttachments = pending?.length ? retainedAttachmentRefs(pending) : []; - await submitAndProject({ + const submitted = await submitAndProject({ sessionId, messageId, placement, @@ -696,6 +701,9 @@ export function createAppShellChatActions(deps: { ...(quotes.length > 0 ? { quotes } : {}), isSurfaceVisible: () => activeIdRef.current === sessionId, }); + // A refused Message opened nothing and left no row. Reporting it as sent + // would clear the composer draft the user has to retry from. + return submitted.kind !== 'refused'; } catch (error) { removeOptimisticUserMessage(sessionId, messageId); throw error; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 96c114476e..4180a18188 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1876,7 +1876,7 @@ function AppShellContent({ const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; try { - await enqueueMessage( + const sent = await enqueueMessage( sessionId, text, mode === 'steer' ? 'current_turn' : 'next_turn', @@ -1888,6 +1888,9 @@ function AppShellContent({ : {}), }, ); + // Refused: the composer keeps the draft, the attachments and the quotes, + // because the user has to change something and send it again. + if (!sent) return false; if (pending) clearSubmittedAttachments(pending); if (quotes) clearQuotes(); return true; diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index dc51c891bf..b5d6fa92a4 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -20,6 +20,7 @@ import { useRef, useState } from 'react'; import type { StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; +import { MESSAGE_QUEUE_MAX_ENTRIES } from '@maka/runtime-host/protocol'; import { useAppShellSessionUiState } from './app-shell-session-ui-state'; import { useAppShellSessionList } from './use-app-shell-session-list'; import { createBootstrapSelectionLease } from './bootstrap-selection-lease'; @@ -137,12 +138,21 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { const pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending || pending.size === 0) return; try { - const result = await window.maka.sessions.queryCancelledMessages(sessionId, [ - ...pending.keys(), - ]); + // A legal Host queue already fills the protocol's per-query cap, and an + // unreconciled root Message sits beside it, so asking about every row at + // once fails the whole proof and retires nothing. + const messageIds = [...pending.keys()]; + const cancelled: string[] = []; + for (let from = 0; from < messageIds.length; from += MESSAGE_QUEUE_MAX_ENTRIES) { + const result = await window.maka.sessions.queryCancelledMessages( + sessionId, + messageIds.slice(from, from + MESSAGE_QUEUE_MAX_ENTRIES), + ); + cancelled.push(...result.cancelledMessageIds); + } const current = transientMessagesBySessionRef.current.get(sessionId); if (!current) return; - for (const messageId of result.cancelledMessageIds) current.delete(messageId); + for (const messageId of cancelled) current.delete(messageId); if (current.size === 0) transientMessagesBySessionRef.current.delete(sessionId); if (activeIdRef.current === sessionId) { setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); From 2afff83643e2f5533f2636e6218cb2d98d2bc1a8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 15:03:31 +0800 Subject: [PATCH 23/26] fix(cli): project the Skill receipt Runtime Host answers a submit with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI read only the `blocked` disposition, so an admitted Message that named Skills reported nothing: a successful load printed no card, and a partially failed one dropped the failures silently. The Turn arrives through the started-Turn subscription, which carries Session state rather than this Message's admission, so the submit answer is the client's only sight of the receipt. The suite did not catch this because the fake spread its prepared turn — the `turn.start` shape, which production no longer uses for this — into the started-Turn push, and the receipt reached the runner through a path that no longer exists. The fake now answers the submit with `turn_started` and strips the receipt from the push, so the assertion runs against the shape production actually produces; it fails without this fix. Generated-by: Claude Code --- .../cli/src/__tests__/pi-tui-runner.test.ts | 25 ++++++++++++++----- packages/cli/src/pi-tui-runner.ts | 9 +++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 3c49c6de31..18acbcf61f 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -7449,16 +7449,22 @@ class HostSkillDriver extends SlashCommandDriver { if (this.#refuses()) { return { disposition: 'blocked', skillInvocation: this.skillInvocation }; } - return super.submitMessage(text, options); + // Admitted: the receipt for what was resolved rides the answer, which is + // the client's only sight of it. + const admitted = await super.submitMessage(text, options); + return admitted?.disposition === 'turn_started' + ? { ...admitted, skillInvocation: this.skillInvocation } + : admitted; } override async preparePrompt( prompt: string, options: MakaPreparePromptOptions = {}, ): Promise { + // `turn.start` still refuses outright; its only caller is headless + // `maka run`, which reports the refusal as an ordinary failure. if (this.#refuses()) throw new Error(skillInvocationBlockedMessage(this.skillInvocation)); - const turn = await super.preparePrompt(prompt, options); - return { ...turn, skillInvocation: this.skillInvocation }; + return super.preparePrompt(prompt, options); } } @@ -8126,12 +8132,19 @@ interface HostAdmittingDriver { * for that Session, so a test whose TUI runs on a non-default model points it * there instead of letting the default summary rewrite the status line. */ +/** + * The Host admitting a Message as a fresh Turn: it answers the submit with + * `turn_started`, and the Turn itself arrives separately through the + * started-Turn subscription. That push carries Session state, NOT this + * Message's admission — anything the client learns about the admission has to + * come back through the answer, which is why the receipt is stripped here. + */ async function admitMessageAsTurn( driver: HostAdmittingDriver, text: string, options: MakaSubmitMessageOptions, -): Promise { - const turn = await driver.preparePrompt(text, { +): Promise { + const { skillInvocation: _admissionReceipt, ...turn } = await driver.preparePrompt(text, { turnId: options.messageId, ...(options.modelText !== undefined ? { modelText: options.modelText } : {}), ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), @@ -8143,7 +8156,7 @@ async function admitMessageAsTurn( summary: { ...fakeSessionSummary(turn.sessionId), ...driver.hostSummary }, }), ); - return undefined; + return { disposition: 'turn_started', turnId: turn.turnId }; } function fakeSessionSummary( diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 09c0778b02..7df3990cef 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -957,6 +957,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (result?.disposition === 'blocked') { removeTransientUserMessage(messageId); showSkillInvocation(result.skillInvocation); + return; + } + // It admitted them instead. The receipt says what was loaded and what + // was dropped, and the submit answer is the only place it appears: the + // Turn arrives through the started-Turn subscription, which carries + // Session state rather than this Message's admission. + if (result?.disposition === 'turn_started' && result.skillInvocation) { + const { loaded, failed } = result.skillInvocation; + if (loaded.length > 0 || failed.length > 0) showSkillInvocation(result.skillInvocation); } }) .catch((error) => { From 450a320635039dbd45707f43446fd4e29bf7dde6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 15:14:10 +0800 Subject: [PATCH 24/26] fix(runtime-host): bind exact-Turn intent into durable Message identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `turn.message.submit` gained `skillIds` and `turnOrchestration`, but nothing that decides whether a retry is the same submit was told about them. Durable replay compared only message id, content digest and placement, so submitting one Message identity as a graph Turn and retrying it as a swarm Turn was answered with the graph Turn's success. The root admission now records a digest of the exact-Turn intent alongside the content digest, and the retry comparison covers it. A receipt carrying no intent digest was written for a submit that asked for none, so any intent on a retry is a different request — the comparison fails closed. Pending Message admission stored no execution mode either. Admission commits before the root admission that carries it, so a Host that stopped between the two recovered the Message and re-opened it under the Session default rather than the graph or swarm the user asked for. The mode is now part of the pending record (schema 32) and travels with the recovery batch into the recovered Turn. Two test doubles were rebuilding records from parts instead of storing what the coordinator produced, which is why neither gap showed up; they store the real thing now. Generated-by: Claude Code --- .../src/__tests__/message-coordinator.test.ts | 99 +++++++++++++++++-- .../src/server/message-coordinator.ts | 48 ++++++++- .../src/server/root-turn-coordinator.ts | 4 + .../sqlite-session-metadata-store.test.ts | 3 + packages/storage/src/agent-run-store.ts | 23 ++++- .../storage/src/message-admission-store.ts | 28 ++++++ .../src/sqlite-session-metadata-schema.ts | 15 ++- .../src/sqlite-session-metadata-store.ts | 29 ++++-- 8 files changed, 227 insertions(+), 22 deletions(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 4ac57c9315..39ca6852d1 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -37,6 +37,7 @@ import { HostMessageCoordinator, type HostMessageCoordinatorOptions, type HostMessageRootPort, + type HostMessageRecoveryBatch, type HostMessageRootState, } from '../server/message-coordinator.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; @@ -72,6 +73,43 @@ test('idle submit starts exactly one root Turn and retry identity is connection- assert.equal(fixture.liveResidencies(), 0); }); +test('a retry that changes exact-Turn intent is a conflict, not the earlier success', async () => { + const fixture = createFixture(); + fixture.setRootState({ kind: 'idle' }); + const submitted = (mode: 'graph' | 'swarm') => + ({ + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + messageId: 'exact-message', + content: { text: 'run this exactly' }, + placement: 'current_turn', + turnOrchestration: { mode, source: 'slash_command' }, + }) as const; + + const first = await fixture.coordinator.handlers['turn.message.submit']( + submitted('graph'), + operationContext(), + ); + assert.equal(first.ok, true); + + // Same Message identity, same text, same placement — but a different + // execution mode. Answering the earlier success here would run one exact + // request and report it as another. + const changed = await fixture.coordinator.handlers['turn.message.submit']( + submitted('swarm'), + operationContext(), + ); + assert.equal(changed.ok, false); + if (!changed.ok) assert.equal(changed.error.code, 'operation_conflict'); + + const unchanged = await fixture.coordinator.handlers['turn.message.submit']( + submitted('graph'), + operationContext(), + ); + assert.deepEqual(unchanged, first); + assert.equal(fixture.startCalls(), 1); +}); + test('message query reports only durable cancellation proof', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -314,6 +352,34 @@ test('recovered followups without a connection owner still form one successor ba ); }); +test('recovery re-opens a Turn under the orchestration the Message asked for', async () => { + const fixture = createFixture(); + fixture.setRootState({ kind: 'idle' }); + // The Host stopped after the Message admission committed and before the root + // admission that carries the execution mode was written. + await fixture.admissions.commitMessageAdmission({ + sessionId: ROOT.sessionId, + turnId: ROOT.turnId, + runId: ROOT.runId, + messageId: 'recovered-exact', + content: { text: 'run this as a graph' }, + submittedContentDigest: messageContentDigest({ text: 'run this as a graph' }), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + turnOrchestration: { mode: 'graph', source: 'slash_command' }, + admittedAt: 1, + }); + + await fixture.coordinator.recoverPendingAfterHostRestart([ROOT.sessionId]); + + assert.equal(fixture.recoveredBatches.length, 1); + assert.deepEqual(fixture.recoveredBatches[0]?.turnOrchestration, { + mode: 'graph', + source: 'slash_command', + }); +}); + test('recovery treats a durable steering event as the handoff proof', async () => { const fixture = createFixture(); await fixture.admissions.commitMessageAdmission({ @@ -2154,6 +2220,7 @@ function createFixture( } | undefined; const receipts = new Map(); + const recoveredBatches: HostMessageRecoveryBatch[] = []; const events: RuntimeEvent[] = []; const messageAdmissions = new Map< string, @@ -2192,20 +2259,35 @@ function createFixture( startFromMessage: async (input) => { startCalls += 1; const turnId = 'idle-turn'; - receipts.set( + // Store the source message the coordinator actually produced. Rebuilding + // one from parts drops whatever the coordinator recorded about the + // submit, which is the very thing a retry is compared against. + const receipt = sourceReceipt( input.sourceMessage.messageId, - sourceReceipt( - input.sourceMessage.messageId, - input.sourceMessage.content, - input.sourceMessage.placement, - 'turn_started', - turnId, - ), + input.sourceMessage.content, + input.sourceMessage.placement, + 'turn_started', + turnId, ); + receipts.set(input.sourceMessage.messageId, { + admission: { + ...receipt.admission, + sourceMessages: [input.sourceMessage], + ...(input.turnOrchestration ? { turnOrchestration: input.turnOrchestration } : {}), + }, + sourceMessage: input.sourceMessage, + }); rootState = { kind: 'active', sessionId: input.sessionId, turnId, runId: 'idle-run' }; coordinator.reserveRootTurn(rootState); return { turnId }; }, + startRecoveredMessages: async (input) => { + recoveredBatches.push(input); + const turnId = 'recovered-turn'; + rootState = { kind: 'active', sessionId: input.sessionId, turnId, runId: 'recovered-run' }; + coordinator.reserveRootTurn(rootState); + return { turnId }; + }, prepareMessage: (input) => prepareMessage(input), claimStop: async (_input, commitQueueFence) => { commitQueueFence(); @@ -2264,6 +2346,7 @@ function createFixture( startCalls: () => startCalls, events, receipts, + recoveredBatches, readMessageAdmission: (messageId: string) => messageAdmissions.get(messageId)?.admission, stopClaimed, resolveTerminal: terminal.resolve, diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index c21b121f91..88e32db588 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -17,7 +17,7 @@ * under the License. */ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { SteeringLease } from '@maka/core/backend-types'; import { @@ -126,6 +126,13 @@ export interface HostMessageRecoveryBatch { readonly content: MessageContent; readonly submittedContent: MessageContent; readonly sources: readonly RootTurnSourceMessage[]; + /** + * The execution mode the recovered Message asked for. Only a lone Message + * can carry one — exact-Turn intent needs an idle Session and opens its own + * root Turn — and without it the recovered Turn silently runs under the + * Session default instead of the graph or swarm that was requested. + */ + readonly turnOrchestration?: TurnOrchestration; } export interface HostMessagePreparationInput { @@ -694,6 +701,9 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { content: aggregateMessageContents(pending.map((entry) => entry.content)), submittedContent: aggregateMessageContents(pending.map((entry) => entry.content)), sources: pending.map(pendingMessageSource), + ...(pending.length === 1 && pending[0]!.turnOrchestration + ? { turnOrchestration: pending[0]!.turnOrchestration } + : {}), }, admission, ), @@ -846,10 +856,12 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'Root reported idle while the message authority retained live state', ); } + const intentDigest = submittedIntentDigest(payload); const sourceMessage: RootTurnSourceMessage = { messageId: input.messageId, content: payload.content, submittedContentDigest: messageContentDigest(payload.content), + ...(intentDigest ? { submittedIntentDigest: intentDigest } : {}), placement: input.placement, disposition: 'turn_started', }; @@ -860,7 +872,8 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if ( pendingAdmission && (pendingAdmission.submittedContentDigest !== messageContentDigest(payload.content) || - pendingAdmission.submittedPlacement !== input.placement) + pendingAdmission.submittedPlacement !== input.placement || + !isDeepStrictEqual(pendingAdmission.turnOrchestration, payload.turnOrchestration)) ) { return failure('operation_conflict', 'Message admission has a different payload'); } @@ -891,6 +904,9 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { submittedPlacement: input.placement, placement: 'current_turn', disposition: 'steering', + ...(payload.turnOrchestration + ? { turnOrchestration: payload.turnOrchestration } + : {}), admittedAt: pendingAdmission?.admittedAt ?? Date.now(), }); }, @@ -2128,6 +2144,12 @@ function sameRun(left: RuntimeMessageRunIdentity, right: RuntimeMessageRunIdenti ); } +/** + * Whether a durable receipt answers the submit being retried. The receipt's own + * record of the exact-Turn intent is authoritative; a receipt that carries none + * was written for a submit that asked for none, so any intent now is a + * different request. + */ function sameSourcePayload( receipt: RootTurnSourceMessageReceipt, input: CanonicalSubmitPayload, @@ -2146,7 +2168,8 @@ function sameSourcePayload( (durableDigest ? durableDigest === messageContentDigest(input.content) : messageContentsEqual(source.content, input.content)) && - source.placement === input.placement + source.placement === input.placement && + source.submittedIntentDigest === submittedIntentDigest(input) ); } @@ -2303,6 +2326,25 @@ function requiresExactTurn(payload: CanonicalSubmitPayload): boolean { return payload.skillIds.length > 0 || payload.turnOrchestration !== undefined; } +/** + * The exact-Turn intent as a durable value, or undefined when the submit asked + * for none. Content and placement say nothing about how a Turn runs, so this is + * the rest of what makes a submit the same submit: without it, a retry under + * one Message identity can change the execution mode and still be answered with + * the earlier Turn's success. + */ +function submittedIntentDigest(payload: CanonicalSubmitPayload): `sha256:${string}` | undefined { + if (!requiresExactTurn(payload)) return undefined; + return `sha256:${createHash('sha256') + .update( + JSON.stringify({ + skillIds: payload.skillIds, + turnOrchestration: payload.turnOrchestration ?? null, + }), + ) + .digest('hex')}`; +} + function aggregateMessageContent(contents: readonly MessageContent[]): MessageContent { return aggregateMessageContents(contents); } diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index b8987e2882..f30caaeabb 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1163,6 +1163,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if (!reservation) return { error: 'Another root Turn is being admitted' }; try { const turnId = randomUUID(); + // The recovered Message asked for this mode before the Host stopped; + // admitting without it would run a different Turn than was requested. + await this.prepareFreshAgentGraphEpoch(header, input.turnOrchestration); const admitted = await this.rootAdmissionOwner.admitRootTurn({ sessionId: input.sessionId, turnId, @@ -1173,6 +1176,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { inputDigest: messageContentDigest(input.submittedContent), }, normalizedInput: input.content, + ...(input.turnOrchestration ? { turnOrchestration: input.turnOrchestration } : {}), sourceMessages: input.sources, admittedAt: Date.now(), }); diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index f0b771fd53..6559d971c5 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -321,6 +321,9 @@ describe('SqliteSessionMetadataStore', () => { submittedPlacement: 'current_turn', placement: 'current_turn', disposition: 'steering', + // Exact-Turn intent is durable: recovery re-opens the Turn from this + // record, and content and placement say nothing about execution mode. + turnOrchestration: { mode: 'graph', source: 'slash_command' }, admittedAt: 10, }; diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 9b1af4fe73..716d9e5c0b 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -95,6 +95,14 @@ export interface RootTurnSourceMessage { messageId: string; content: MessageContent; submittedContentDigest?: `sha256:${string}`; + /** + * Digest of the exact-Turn intent this Message was submitted with — the + * Skill ids and the orchestration override. Content and placement do not + * describe it, so without this a retry that asks for a different execution + * mode under the same Message identity aliases the earlier success. Absent + * on a record written for a submit that carried no exact intent. + */ + submittedIntentDigest?: `sha256:${string}`; placement: 'current_turn' | 'next_turn'; disposition: 'steering' | 'followup' | 'turn_started'; } @@ -1653,11 +1661,19 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc 'placement', 'disposition', ...(Object.hasOwn(item, 'submittedContentDigest') ? ['submittedContentDigest'] : []), + ...(Object.hasOwn(item, 'submittedIntentDigest') ? ['submittedIntentDigest'] : []), ]) ) { throw new Error(`Invalid root turn source message at index ${index}`); } - const { messageId, content, submittedContentDigest, placement, disposition } = item; + const { + messageId, + content, + submittedContentDigest, + submittedIntentDigest, + placement, + disposition, + } = item; if ( typeof messageId !== 'string' || !isSafeId(messageId) || @@ -1667,7 +1683,8 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc disposition !== 'turn_started') || (disposition === 'steering' && placement !== 'current_turn') || (disposition === 'followup' && placement !== 'next_turn') || - (submittedContentDigest !== undefined && !isSha256Digest(submittedContentDigest)) + (submittedContentDigest !== undefined && !isSha256Digest(submittedContentDigest)) || + (submittedIntentDigest !== undefined && !isSha256Digest(submittedIntentDigest)) ) { throw new Error(`Invalid root turn source message at index ${index}`); } @@ -1683,6 +1700,7 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc MAX_ATTACHMENT_COUNT, ), ...(submittedContentDigest !== undefined ? { submittedContentDigest } : {}), + ...(submittedIntentDigest !== undefined ? { submittedIntentDigest } : {}), placement, disposition, }); @@ -1710,6 +1728,7 @@ function rootTurnAdmissionPayloadsEqual( source.placement === other.placement && source.disposition === other.disposition && source.submittedContentDigest === other.submittedContentDigest && + source.submittedIntentDigest === other.submittedIntentDigest && messageContentsEqual(source.content, other.content) ); }) diff --git a/packages/storage/src/message-admission-store.ts b/packages/storage/src/message-admission-store.ts index 25fe352c70..dba6e75e0f 100644 --- a/packages/storage/src/message-admission-store.ts +++ b/packages/storage/src/message-admission-store.ts @@ -19,6 +19,11 @@ import { isDeepStrictEqual } from 'node:util'; import { normalizeMessageContent, type MessageContent } from '@maka/core/events'; +import { + isOrchestrationMode, + isTurnOrchestrationSource, + type TurnOrchestration, +} from '@maka/core/orchestration'; const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; @@ -32,6 +37,14 @@ export interface PendingMessageAdmission { readonly submittedPlacement: 'current_turn' | 'next_turn'; readonly placement: 'current_turn' | 'next_turn'; readonly disposition: 'steering' | 'followup'; + /** + * The orchestration this Message asked its Turn to run under, when it asked + * for one. Recovery re-opens the Turn from this record, and content and + * placement say nothing about execution mode, so without it a crash between + * this commit and the root admission silently downgrades an explicit + * graph or swarm request to the Session default. + */ + readonly turnOrchestration?: TurnOrchestration; readonly admittedAt: number; } @@ -81,9 +94,23 @@ export function normalizePendingMessageAdmission( if (!Number.isSafeInteger(admission.admittedAt) || admission.admittedAt < 0) { throw new Error('Invalid message admission timestamp'); } + if (admission.turnOrchestration !== undefined) { + const { mode, source } = admission.turnOrchestration; + if (!isOrchestrationMode(mode) || !isTurnOrchestrationSource(source)) { + throw new Error('Invalid pending Message orchestration'); + } + } const normalized = Object.freeze({ ...admission, content: normalizeMessageContent(admission.content), + ...(admission.turnOrchestration + ? { + turnOrchestration: Object.freeze({ + mode: admission.turnOrchestration.mode, + source: admission.turnOrchestration.source, + }), + } + : {}), }); if (!/^sha256:[a-f0-9]{64}$/u.test(normalized.submittedContentDigest)) { throw new Error('Invalid pending Message submitted content digest'); @@ -107,6 +134,7 @@ export function samePendingMessageAdmission( a.placement === b.placement && a.disposition === b.disposition && a.admittedAt === b.admittedAt && + isDeepStrictEqual(a.turnOrchestration, b.turnOrchestration) && isDeepStrictEqual(a.content, b.content) ); } diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 2cc54783af..c168bb25d9 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 31; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 32; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1194,6 +1194,12 @@ const MIGRATIONS: ReadonlyMap = new Map([ WHERE json_extract(payload_json, '$.role') = 'workhub_coordination'; `, ], + [ + 32, + ` + ALTER TABLE message_admissions ADD COLUMN turn_orchestration_json TEXT; + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { @@ -1251,7 +1257,12 @@ export function migrateSqliteSessionMetadataDatabase( ) { const sql = MIGRATIONS.get(version); if (!sql) throw new Error(`Missing SQLite session metadata migration ${version}`); - db.exec(sql); + // Version 32 adds one column, and the post-merge convergence path replays + // it onto a database that may already carry it. SQLite has no + // `ADD COLUMN IF NOT EXISTS`, so the guard lives here. + if (version !== 32 || !hasColumn(db, 'message_admissions', 'turn_orchestration_json')) { + db.exec(sql); + } if (version === 29 && hasColumn(db, 'session_metadata', 'last_used_at')) { db.exec('ALTER TABLE session_metadata DROP COLUMN last_used_at'); } diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 8eb75134a8..9f4f6750ca 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -247,6 +247,7 @@ interface MessageAdmissionRow { readonly disposition?: unknown; readonly queue_order?: unknown; readonly admitted_at?: unknown; + readonly turn_orchestration_json?: unknown; } function decodeMessageAdmissionRow( @@ -280,6 +281,13 @@ function decodeMessageAdmissionRow( submittedPlacement: row.submitted_placement, placement: row.placement, disposition: row.disposition, + ...(typeof row.turn_orchestration_json === 'string' + ? { + turnOrchestration: JSON.parse(row.turn_orchestration_json) as NonNullable< + PendingMessageAdmission['turnOrchestration'] + >, + } + : {}), admittedAt: row.admitted_at, }); } @@ -1574,7 +1582,8 @@ export class SqliteSessionMetadataStore { .prepare( ` SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, - submitted_placement, placement, disposition, queue_order, admitted_at + submitted_placement, placement, disposition, queue_order, admitted_at, + turn_orchestration_json FROM message_admissions WHERE session_id = ? AND message_id = ? `, @@ -1612,8 +1621,9 @@ export class SqliteSessionMetadataStore { ` INSERT INTO message_admissions( session_id, turn_id, run_id, message_id, content_json, submitted_content_digest, - submitted_placement, placement, disposition, queue_order, admitted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + submitted_placement, placement, disposition, queue_order, admitted_at, + turn_orchestration_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -1628,6 +1638,7 @@ export class SqliteSessionMetadataStore { stored.disposition, orderRow.next_order, stored.admittedAt, + stored.turnOrchestration ? JSON.stringify(stored.turnOrchestration) : null, ); return stored; @@ -1646,7 +1657,8 @@ export class SqliteSessionMetadataStore { .prepare( ` SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, - submitted_placement, placement, disposition, queue_order, admitted_at + submitted_placement, placement, disposition, queue_order, admitted_at, + turn_orchestration_json FROM message_admissions WHERE session_id = ? AND message_id = ? `, @@ -1678,7 +1690,8 @@ export class SqliteSessionMetadataStore { .prepare( ` SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, - submitted_placement, placement, disposition, queue_order, admitted_at + submitted_placement, placement, disposition, queue_order, admitted_at, + turn_orchestration_json FROM message_admissions WHERE session_id = ? ORDER BY queue_order, sequence @@ -1718,7 +1731,8 @@ export class SqliteSessionMetadataStore { .prepare( ` SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, - submitted_placement, placement, disposition, queue_order, admitted_at + submitted_placement, placement, disposition, queue_order, admitted_at, + turn_orchestration_json FROM message_admissions WHERE session_id = ? AND message_id = ? `, @@ -1827,7 +1841,8 @@ export class SqliteSessionMetadataStore { .prepare( ` SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, - submitted_placement, placement, disposition, queue_order, admitted_at + submitted_placement, placement, disposition, queue_order, admitted_at, + turn_orchestration_json FROM message_admissions WHERE session_id = ? AND message_id = ? `, From 474f883006adf220f9a9af56154620cbbc584f16 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 15:15:00 +0800 Subject: [PATCH 25/26] refactor(desktop): require a caller-owned identity on the submit IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The submit IPC minted a Message id when the command carried none. Every production caller assigns one — it is what reconciles the row the surface has already rendered, and what makes a retry the same Message — so the fallback could only ever hand back an identity nobody was showing. Fail closed instead. Generated-by: Claude Code --- .../src/main/runtime-host-session-execution-ipc-main.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 36991da0bd..c5107d5198 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 @@ -445,6 +445,10 @@ export function registerRuntimeHostSessionExecutionIpc( type: "send", }); if (!command) throw new Error("Invalid submitted message"); + // The submitting surface owns the Message identity: it is what reconciles + // the row it already rendered, and what makes a retry the same Message. + // Minting one here would hand back an identity the caller never showed. + if (!command.messageId) throw new Error("Submitted message has no identity"); const session = await deps.client.getSession(sessionId); if (!session) { throw new Error(`Runtime Host Session not found: ${sessionId}`); @@ -487,7 +491,7 @@ export function registerRuntimeHostSessionExecutionIpc( displayText, workspaceFileReferences: command.workspaceFileReferences, }); - const messageId = command.messageId ?? newId(); + const messageId = command.messageId; // Skill and orchestration intent travels with the Message. Runtime Host // decides whether it opens its own Turn, steers the running one, or // fails closed; the Desktop never routes on message content. From 668bf1577ff32ab29434fd224d73b5f3ff208d5a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 15:16:17 +0800 Subject: [PATCH 26/26] test(desktop): drop the message-state double the transient fixture replaced Transient rows moved out of the canonical `messages` array into their own map, so nothing constructs `createMessageState` any more and Knip fails the build on it. Generated-by: Claude Code --- .../__tests__/app-shell-chat-actions-fixture.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index d4ba820ef5..19c8e86b74 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -24,7 +24,6 @@ * actions gain a dependency. */ -import type { StoredMessage } from '@maka/core/session'; import type { LiveTurnProjection, TransientUserMessageProjection } from '@maka/ui'; /** Installs a `window.maka` bridge double; the returned function restores it. */ @@ -92,18 +91,6 @@ export function createTransientState() { }; } -export function createMessageState() { - const messages: StoredMessage[] = []; - return { - messages, - setMessages(updater: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) { - const next = typeof updater === 'function' ? updater([...messages]) : updater; - messages.length = 0; - messages.push(...next); - }, - }; -} - export function createActionsDeps() { return { uiLocale: 'en' as const,