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 }, ); 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..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 @@ -28,107 +28,311 @@ 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'; -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, +import { + createActionsDeps, + createTransientState, + 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 () => { + 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: { + submitMessage: async ( + _sessionId: string, + _placement: string, + command: { messageId: string }, + ) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + ok: true, + disposition: 'followup', + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...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( + '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(); + } }); - return () => { - if (hadWindow) { - Object.defineProperty(target, 'window', { - configurable: true, - value: previousWindow, - writable: true, + + it('keeps a Follow Up visible when Host admission outcome is unknown', async () => { + 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' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), }); - } else { - delete target.window; + + await actions.enqueueMessage('session-a', 'do this next', 'next_turn'); + + assert.equal(transient.size, 1); + assert.equal([...transient.values()][0]?.text, 'do this next'); + } finally { + restoreWindow(); } - }; -} + }); -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); - }, - }; -} + 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), + }); -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); - }, - }; -} + await actions.enqueueMessage('session-a', '/skill:typo do this next', 'next_turn'); + + assert.deepEqual([...transient.keys()], []); + } finally { + restoreWindow(); + } + }); -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, - 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 }, - }; -} + 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' }, + }); -const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; + // 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(); + } + }); -describe('busy-raced send settlement', () => { - it('a steered send on an existing session disarms its turn and shows no optimistic message', async () => { + 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; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async ( + _sessionId: string, + _placement: string, + command: { messageId: string }, + ) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + ok: true as const, + disposition: 'followup' as const, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + 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(); + 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: { + submitMessage: async ( + _sessionId: string, + _placement: 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), + updateTransientMessage: (_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); + assert.equal(transient.get(submittedMessageId)?.text, '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 transientState = createTransientState(); const restoreWindow = installWindow({ sessions: { - send: async (_sessionId: string, command: { turnId: string }) => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, - steered: true, - turnId: command.turnId, + disposition: 'steering', + messageId: command.messageId, attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION, @@ -140,24 +344,28 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); assert.equal(turnState.liveTurnBySession['session-a'], undefined); - assert.deepEqual(messageState.messages, []); + // 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(); } }); - 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 transientState = createTransientState(); const restoreWindow = installWindow({ sessions: { - send: async () => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -170,15 +378,13 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); 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); - const optimistic = messageState.messages.filter((message) => message.type === 'user'); - assert.equal(optimistic.length, 1); - assert.equal(optimistic[0]?.turnId, 'host-turn'); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); + 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(); } @@ -187,10 +393,10 @@ 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: { - send: async () => { + submitMessage: async (_sessionId: string, command: { messageId: string }) => { // The Host streamed under its own turn id before the IPC response. turnState.setLiveTurnBySession((current) => ({ ...current, @@ -198,6 +404,8 @@ describe('busy-raced send settlement', () => { })); return { ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -211,7 +419,7 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); const live = turnState.liveTurnBySession['session-a']; @@ -223,10 +431,10 @@ 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(); + const transientState = createTransientState(); const activated: string[] = []; const removed: string[] = []; const restoreWindow = installWindow({ @@ -237,10 +445,10 @@ describe('busy-raced send settlement', () => { remove: async (sessionId: string) => { removed.push(sessionId); }, - send: async (_sessionId: string, command: { turnId: string }) => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, - steered: true, - turnId: command.turnId, + disposition: 'steering', + messageId: command.messageId, attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION, @@ -256,29 +464,31 @@ describe('busy-raced send settlement', () => { activeIdRef.current = sessionId; }, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...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.deepEqual(messageState.messages, []); + assert.equal(transientState.rows.size, 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(); + const transientState = createTransientState(); const restoreWindow = installWindow({ newTasks: { create: async () => ({ id: 'session-new' }), }, sessions: { - send: async () => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -294,13 +504,12 @@ describe('busy-raced send settlement', () => { activeIdRef.current = sessionId; }, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); - assert.equal(turnState.liveTurnBySession['session-new']?.turnId, 'host-turn'); - const optimistic = messageState.messages.filter((message) => message.type === 'user'); - assert.equal(optimistic.length, 1); - assert.equal(optimistic[0]?.turnId, 'host-turn'); + assert.equal(turnState.liveTurnBySession['session-new'], undefined); + 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 new file mode 100644 index 0000000000..19c8e86b74 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -0,0 +1,133 @@ +/* + * 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 { LiveTurnProjection, TransientUserMessageProjection } 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); + }, + }; +} + +/** + * 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 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-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__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 967d777dc9..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 @@ -35,87 +35,15 @@ 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 }; - 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, - 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 () => { @@ -124,7 +52,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: [] } }; }, @@ -163,7 +91,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -213,7 +141,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -249,7 +177,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -294,7 +222,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -330,7 +258,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); }, @@ -351,7 +279,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: [] }, @@ -379,7 +307,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); }, @@ -416,7 +344,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: [] } }; }, @@ -463,7 +391,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, }, @@ -491,11 +419,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 +456,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__/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 1dd2451cfa..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 @@ -24,6 +24,8 @@ 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' }, @@ -33,6 +35,9 @@ test('queue_update events drive the independent desktop queue projection', () => setLiveTurnBySession: controller.setLiveTurnBySession, setInteractionBySession: controller.setInteractionBySession, setMessageQueueBySession: controller.setMessageQueueBySession, + projectQueuedTransientMessages: (_sessionId, messages) => transientMessages.push(...messages), + removeTransientMessage: (_sessionId, messageId) => + removedTransientMessageIds.push(messageId), showModelSetupToast() {}, toastApi: { error() {} }, }); @@ -45,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, }; @@ -59,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', @@ -82,17 +84,68 @@ test('queue_update events drive the independent desktop queue projection', () => }, ], }); + assert.deepEqual(transientMessages, [ + { + id: 'message-steer', + transientPlacement: 'current_turn', + hostTurnId: 'turn-1', + ts: 1, + text: 'adjust this run', + }, + { + id: 'message-next', + transientPlacement: 'next_turn', + ts: 1, + text: 'do this next', + }, + ]); + + handlers.handleEvent('session-1', { + type: 'steering_message', + id: 'steering-message-steer', + turnId: 'turn-1', + messageId: 'message-steer', + ts: 2, + content: { text: 'adjust this run' }, + }); + assert.deepEqual(removedTransientMessageIds, ['message-steer']); handlers.handleEvent('session-1', { type: 'queue_update', id: 'queue-2', turnId: 'turn-1', - ts: 2, + 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.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', + id: 'retracted-message-next', + turnId: 'turn-1', + ts: 4, + messageId: 'message-next', + outcome: 'retracted', }); - assert.equal(controller.getState().messageQueueBySession['session-1'], undefined); + 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__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 18ae97ccbd..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 @@ -578,6 +578,131 @@ 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:submitMessage", "session-1", "current_turn", { + messageId: "message-1", + text: "check the projection", + }); + + assert.deepEqual(submits, [ + { + sessionId: "session-1", + messageId: "message-1", + content: { + text: "check the projection", + inlineReferences: [], + }, + placement: "current_turn", + }, + ]); + assert.deepEqual(result, { + ok: true, + disposition: "turn_started", + turnId: "host-turn", + attachments: [], + inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }); +}); + +test('returns Host-owned cancellation proof to the renderer', async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + queryMessages: async (input) => ({ + cancelledMessageIds: input.messageIds.filter( + (messageId) => messageId === 'message-cancelled', + ), + }), + }), + }, + ipc, + ); + + assert.deepEqual( + await ipc.invoke('sessions:queryCancelledMessages', 'session-1', [ + 'message-accepted', + 'message-cancelled', + ]), + { cancelledMessageIds: ['message-cancelled'] }, + ); +}); + +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(), + startTurn: async () => { + throw new Error('a Skill Message must not route around Host admission'); + }, + submitMessage: async (input) => { + submits.push(input); + return { + disposition: 'blocked', + skillInvocation: { + loaded: [], + failed: [{ request: 'missing', reason: 'not_found' }], + receipts: [], + }, + }; + }, + }), + newId: () => 'unexpected-generated-id', + }, + ipc, + ); + + const result = await ipc.invoke('sessions:submitMessage', 'session-1', 'current_turn', { + messageId: 'message-skill', + text: '/skill:missing inspect this', + }); + + assert.deepEqual(submits, [{ + sessionId: 'session-1', + messageId: 'message-skill', + placement: 'current_turn', + content: { text: '/skill:missing inspect this', inlineReferences: [] }, + }]); + assert.deepEqual(result, { + ok: false, + reason: 'skill_invocation_failed', + 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[] = []; @@ -677,6 +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", + turnId: 'message-1', text: "keep this Turn identity", }); @@ -684,18 +810,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: [] }, @@ -817,14 +943,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", 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", { @@ -841,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[] = []; @@ -1002,7 +1110,8 @@ 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" }], retainedAttachments: [ @@ -1020,7 +1129,8 @@ test("queues explicit Desktop follow-ups", async () => { ], }), { - kind: "queued", + ok: true, + disposition: "followup", attachments: [ { kind: "other", @@ -1035,12 +1145,13 @@ test("queues explicit Desktop follow-ups", async () => { }, ], inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, }, ); assert.deepEqual(submits, [ { sessionId: "session-1", - messageId: "id-2", + messageId: "followup-message", content: { text: "do this next", attachments: [ @@ -1064,6 +1175,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:submitMessage', 'session-1', 'next_turn', { + messageId: 'followup-unknown', + text: 'keep this visible', + }), + { ok: false, reason: 'outcome_unknown' }, + ); +}); + test("routes per-entry queue mutations to the Runtime Host", async () => { const calls: unknown[] = []; let sequence = 0; @@ -1177,7 +1321,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, @@ -1238,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", { @@ -1282,10 +1441,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', @@ -1295,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', }, ]); @@ -1379,6 +1538,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__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index b35a635ce5..53fb370497 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -90,6 +90,101 @@ 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: [ + { + id: 'message-pending', ts: 2, + text: 'send now', transientPlacement: 'current_turn', + }, + ], + 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 = 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: [ + { + id: 'turn-1', ts: 1, text: 'send now', + transientPlacement: 'current_turn', + }, + ], + 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('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: [ + { + id: 'message-1', ts: 1, text: 'send now', + transientPlacement: 'current_turn', + }, + { + id: '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 new file mode 100644 index 0000000000..7755365af8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -0,0 +1,151 @@ +/* + * 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 type { TransientUserMessageProjection } from '@maka/ui'; +import { + mergeTransientMessageProjection, + projectQueuedTransientMessages, + 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 = { + id: 'message-1', + ts: 2, + text: 'send now', + transientPlacement: 'current_turn', +}; + +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('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 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', ts: 4, text: 'send next' }; + const pending = new Map([ + [transient.id, transient], + [second.id, second], + ]); + const projected = reconcileTransientMessages(pending, [canonicalSend()]); + + assert.deepEqual(projected, [second]); + assert.deepEqual([...pending.keys()], ['message-2']); +}); + +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' }, + { + 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), ['message-1']); +}); + +test('keeps a transient message out of a sparse historical range', () => { + const live = { ...transient, id: 'message-live', text: 'latest prompt' }; + const pending = new Map([[live.id, live]]); + const historical: StoredMessage[] = [ + { type: 'user', id: 'message-old', turnId: 'turn-old', ts: 1, text: 'old prompt' }, + ]; + + const projected = reconcileTransientMessages(pending, historical, { + includeTransient: false, + }); + + 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', hostTurnId: 'host-turn' }; + const lateIpcUpdate = { ...transient, id: 'message-current', text: 'uploaded content' }; + + assert.deepEqual(mergeTransientMessageProjection(hostBound, lateIpcUpdate), { + ...lateIpcUpdate, + hostTurnId: 'host-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/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-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 8e42f395c9..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 @@ -96,6 +96,7 @@ type RuntimeHostSessionExecutionClient = Pick< | "interruptTurn" | 'listSessionTurns' | 'listSessionTurnLandmarks' + | 'queryMessages' | "queryTurnResume" | "readExecutionBoundary" | "regenerateTurn" @@ -111,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], @@ -124,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; } } @@ -190,6 +205,14 @@ export function registerRuntimeHostSessionExecutionIpc( const newId = deps.newId ?? randomUUID; const stopSession = createRuntimeHostSessionStop(deps, newId); + ipcMain.handle( + 'sessions:queryCancelledMessages', + 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", @@ -352,22 +375,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") { @@ -379,7 +399,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 @@ -392,7 +412,7 @@ export function registerRuntimeHostSessionExecutionIpc( ...(sideConversation ? { messageId } : {}), attachments, inlineReferences, - skillInvocation: emptySkillInvocation, + skillInvocation: EMPTY_SKILL_INVOCATION, }; } if (startResult.kind === "blocked") { @@ -415,27 +435,7 @@ 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:enqueue", + "sessions:submitMessage", async (event, sessionId: string, placement: unknown, value: unknown) => { if (placement !== "current_turn" && placement !== "next_turn") { throw new Error("Invalid message placement"); @@ -443,12 +443,12 @@ 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) { - throw new Error("Queued control input is not available"); - } + 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}`); @@ -482,14 +482,22 @@ 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 result = await deps.client.submitMessage({ + 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. + const result = await submitMessageWithReconnect(deps.client, { sessionId, - messageId: newId(), + messageId, placement, content: { text: command.text, @@ -500,22 +508,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 { 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, }; }, ); @@ -869,7 +896,7 @@ function createRuntimeHostSessionStop( } return; } - await deps.client.interruptTurn({ + const interrupted = await deps.client.interruptTurn({ sessionId, interruptId: newId(), turnId: turn.turnId, @@ -878,6 +905,10 @@ function createRuntimeHostSessionStop( deps.emitSessionsChanged("turn-status-change", sessionId, { turnId: turn.turnId, }); + return { + kind: 'interrupted', + retractedMessageIds: interrupted.retracted.map((message) => message.messageId), + }; }; } @@ -917,16 +948,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 f2aa6d8817..eda0ce3ab3 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 & { @@ -785,22 +786,20 @@ export interface MakaBridge { create(input?: CreateSessionRequestInput): Promise; send( sessionId: string, - command: - | SessionCommand - | { - 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 - >; - }, + 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; @@ -845,21 +844,20 @@ 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 } - >; - 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[]; @@ -867,12 +865,26 @@ export interface MakaBridge { Pick >; }, - ): Promise<{ - kind: 'queued' | 'started'; - turnId?: string; - attachments: import('@maka/core/events').AttachmentRef[]; - inlineReferences: import('@maka/core/events').InlineReference[]; - }>; + ): 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' } + >; + queryCancelledMessages( + 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 7fc166f717..a0bfc310f3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1589,65 +1589,21 @@ const makaBridge = { const scope = await activeRuntimeHostRef(); return createDesktopSessionOnScope(scope, input); }, - async send( - sessionId: string, - command: - | SessionCommand - | { - type: 'send'; - turnId: string; - text: string; - displayText?: string; - skillIds?: string[]; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: AttachmentRef[]; - turnOrchestration?: TurnOrchestration; - quotes?: QuoteRef[]; - workspaceFileReferences?: Array>; - }, - ): Promise< - | { - 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; - } - > { + async send(sessionId, command) { 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); + 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); @@ -1668,40 +1624,13 @@ 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 enqueue( - sessionId: string, - placement: 'current_turn' | 'next_turn', - command: { - text: string; - displayText?: string; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: AttachmentRef[]; - quotes?: QuoteRef[]; - workspaceFileReferences?: Array>; - }, - ): Promise<{ - kind: 'queued' | 'started'; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - }> { + 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, @@ -1709,16 +1638,13 @@ const makaBridge = { ...command, ...(attachmentItems ? { attachmentItems } : {}), }, - ) as { - kind: 'queued' | 'started'; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - }; - return { - ...result, - attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), - }; + )) as Awaited>; + return result.ok + ? { ...result, attachments: projectDesktopAttachmentRefs(session.scope, result.attachments) } + : result; + }, + 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 3c74ddfd47..69373f745a 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'; @@ -36,6 +37,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'; @@ -112,6 +114,21 @@ 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, + 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,6 +161,15 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession: MessageLoadErrorUpdater; setMessageRetryPendingBySession: BooleanRecordUpdater; setMessages: MessageListUpdater; + addTransientMessage: ( + sessionId: string, + message: TransientUserMessageProjection, + ) => void; + updateTransientMessage: ( + sessionId: string, + message: TransientUserMessageProjection, + ) => 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 +218,9 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, + addTransientMessage, + updateTransientMessage, + removeTransientMessage, transcriptRangeRef, setNavSelection, setLiveTurnBySession, @@ -210,75 +239,49 @@ export function createAppShellChatActions(deps: { } = deps; const copy = getShellCopy(uiLocale).chatActions; - function optimisticUserMessage( - turnId: string, - text: string, - attachments: readonly import('@maka/core/events').AttachmentRef[] = [], - quotes: readonly QuoteRef[] = [], - inlineReferences: readonly InlineReference[] = [], - ): StoredMessage { - return { - type: 'user', - id: `optimistic-user-${turnId}`, - turnId, - ts: Date.now(), - text, - ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), - ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), - inlineReferences: [...inlineReferences], - }; - } - - function showOptimisticUserMessage( + function showTransientUserMessage( sessionId: string, - turnId: string, + messageId: string, text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], options: { - replaceCurrentMessages?: boolean; + placement?: TransientUserMessageProjection['transientPlacement']; + hostTurnId?: string; + updateOnly?: boolean; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; } = {}, ): void { + const quotes = options.quotes ?? []; + const next: TransientUserMessageProjection = { + id: messageId, + ts: Date.now(), + text, + ...(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; - const next = { ...current }; - delete next[sessionId]; - return next; - }); - 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]; + const cleared = { ...current }; + delete cleared[sessionId]; + return cleared; }); } function removeOptimisticUserMessage(sessionId: string, turnId: string): void { - if (activeIdRef.current !== sessionId) return; - setMessages((current) => current.filter((message) => message.id !== `optimistic-user-${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]; @@ -287,6 +290,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; @@ -296,38 +316,95 @@ 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) }; - }); - } + /** + * 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 }; - // 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; + /** + * 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') { + // 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) { + showSkillInvocationFeedback(uiLocale, toastApi, result.skillInvocation, sessionId); + } + return { kind: 'refused', skillInvocation: result.skillInvocation }; } - const startedTurnId = sendResult.turnId ?? requestedTurnId; - if (startedTurnId !== requestedTurnId) { - rebindTurnActive(sessionId, requestedTurnId, startedTurnId); + 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); + } } - return startedTurnId; + 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( @@ -342,6 +419,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(); @@ -355,7 +433,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 @@ -377,7 +455,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); @@ -399,8 +477,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; + showTransientUserMessage( + 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) @@ -409,12 +497,9 @@ export function createAppShellChatActions(deps: { pending && pending.length > 0 ? retainedAttachmentRefs(pending) : undefined; - const sendResult = await window.maka.sessions.send(session.id, { - type: 'send', - turnId, + const sendCommand = { text, ...(options.displayText ? { displayText: options.displayText } : {}), - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } @@ -423,49 +508,32 @@ export function createAppShellChatActions(deps: { ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), + }; + const submitted = await submitAndProject({ + sessionId: session.id, + messageId, + placement: 'current_turn', + command: { + ...sendCommand, + ...(options.turnOrchestration + ? { turnOrchestration: options.turnOrchestration } + : {}), + }, + ...(options.displayText ? { displayText: options.displayText } : {}), + ...(quotes && quotes.length > 0 ? { quotes } : {}), + exactTurn, + isSurfaceVisible: () => + Boolean(newChatOwner && isNewChatSendSurfaceActive(newChatOwner)), }); - if (!sendResult.ok) { - if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - session.id, - ); - } - disarmTurnActive(session.id, turnId); + if (submitted.kind === 'refused') { 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( - uiLocale, - toastApi, - sendResult.skillInvocation, - session.id, - ); - } 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 ?? [], - }, - ); - } } await refreshSessions(); return true; @@ -489,8 +557,18 @@ export function createAppShellChatActions(deps: { } } optimisticSessionId = sessionId; - optimisticTurnId = turnId; - armTurnActive(sessionId, turnId); + optimisticMessageId = messageId; + showTransientUserMessage( + sessionId, + messageId, + options.displayText ?? text, + [], + { + ...(quotes && quotes.length > 0 ? { quotes } : {}), + inlineReferences: [], + }, + ); + if (exactTurn) armTurnActive(sessionId, messageId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -499,12 +577,9 @@ export function createAppShellChatActions(deps: { pending && pending.length > 0 ? retainedAttachmentRefs(pending) : undefined; - const sendResult = await window.maka.sessions.send(sessionId, { - type: 'send', - turnId, + const sendCommand = { text, ...(options.displayText ? { displayText: options.displayText } : {}), - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } @@ -513,53 +588,36 @@ export function createAppShellChatActions(deps: { ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), - }); - if (!sendResult.ok) { - if (activeIdRef.current === sessionId) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - sessionId, - ); - } - disarmTurnActive(sessionId, turnId); - return false; - } - const startedTurnId = settleSendBookkeeping(sessionId, turnId, sendResult); - options.onSessionResolved?.(sessionId); - if (startedTurnId === undefined) return true; - optimisticTurnId = startedTurnId; - if (activeIdRef.current === sessionId) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - sessionId, - ); - } - showOptimisticUserMessage( + }; + const submitted = await submitAndProject({ sessionId, - startedTurnId, - options.displayText ?? - skillInvocationDisplayText(text, sendResult.skillInvocation), - sendResult.attachments, - { - ...(quotes && quotes.length > 0 ? { quotes } : {}), - inlineReferences: sendResult.inlineReferences ?? [], + messageId, + 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(); - 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 @@ -607,6 +665,51 @@ 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 ?? []; + 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 submitted = await submitAndProject({ + sessionId, + messageId, + 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 } : {}), + 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; + } + } + async function respondToSandboxBoundary(response: SandboxBoundaryResponse) { const sessionId = activeIdRef.current; if (!sessionId) return; @@ -723,6 +826,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..5ce1ecc885 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,6 +85,11 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession: StateUpdater>; setInteractionBySession: StateUpdater; setMessageQueueBySession?: StateUpdater>; + projectQueuedTransientMessages?: ( + sessionId: string, + messages: readonly TransientUserMessageProjection[], + ) => 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; @@ -111,6 +117,8 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession, setInteractionBySession, setMessageQueueBySession, + projectQueuedTransientMessages, + removeTransientMessage, onInteractionChanged, onExecutionBoundaryChanged, onContextCompactionOutcome, @@ -283,6 +291,23 @@ export function createAppShellSessionEventHandlers(options: { switch (event.type) { case 'queue_update': + projectQueuedTransientMessages?.( + sessionId, + [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])] + .filter((entry) => entry.state === 'queued') + .map((entry) => ({ + id: 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] } : {}), + ...(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; @@ -302,6 +327,17 @@ export function createAppShellSessionEventHandlers(options: { }; }); break; + case 'message_admission': + if (event.outcome === 'retracted') { + 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 d398cbff8e..4180a18188 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, @@ -351,7 +347,13 @@ function AppShellContent({ startNewSession, clearOwnedSessionState, messages, + transientMessages, setMessages, + addTransientMessage, + updateTransientMessage, + projectQueuedTransientMessages, + retireCancelledTransientMessages, + removeTransientMessage, transcriptRangeRef, messageLoadPending, setMessageLoadPending, @@ -801,6 +803,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 = 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 @@ -1755,6 +1758,7 @@ function AppShellContent({ const { send, + enqueueMessage, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, @@ -1774,6 +1778,9 @@ function AppShellContent({ setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, + addTransientMessage, + updateTransientMessage, + removeTransientMessage, transcriptRangeRef, setNavSelection, setLiveTurnBySession, @@ -1868,28 +1875,24 @@ 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( + const sent = 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] } : {}), }, ); + // 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(); - if (result.kind === 'started') { - await refreshMessages(sessionId); - await refreshSessions(); - } return true; } catch (error) { if (activeIdRef.current === sessionId) { @@ -2140,18 +2143,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; @@ -2184,6 +2192,7 @@ function AppShellContent({ clearPendingSessionAction, setStopPendingBySession, stopPendingRef, + removeTransientMessage, toastApi, }); @@ -2204,6 +2213,8 @@ function AppShellContent({ setLiveTurnBySession, setInteractionBySession, setMessageQueueBySession, + projectQueuedTransientMessages, + removeTransientMessage, displayBatch: sessionDisplayBatch, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -2297,6 +2308,7 @@ function AppShellContent({ const next = completeLiveContentSeed(current, sessionId, expected); activeEventSeedRef.current = next; setActiveEventSeed(next); + void retireCancelledTransientMessages(sessionId); }; useActiveSessionEvents({ uiLocale, @@ -2876,7 +2888,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} @@ -3018,6 +3030,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/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 2cfd56a22e..2863994d66 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -120,16 +120,38 @@ 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, - ), - steer: (sessionId, text, admissionId) => bridge.sessions.steer(sessionId, text, admissionId), + ); + return result?.kind === 'retracted' ? result : undefined; + }, + // 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) => 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 new file mode 100644 index 0000000000..49eb1505d5 --- /dev/null +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -0,0 +1,69 @@ +/* + * 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'; +import type { TransientUserMessageProjection } from '@maka/ui'; + +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); +} + +/** + * 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 { + return current.hostTurnId !== undefined && update.hostTurnId === undefined + ? { ...update, hostTurnId: current.hostTurnId } + : update; +} + +/** + * 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, + durable: readonly StoredMessage[], + options: { includeTransient?: boolean } = {}, +): TransientUserMessage[] { + for (const message of durable) transient.delete(message.id); + if (transient.size === 0 || options.includeTransient === false) return []; + 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 a43043eeda..b5d6fa92a4 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,8 @@ 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'; @@ -28,11 +30,22 @@ import { markNewTaskReloadIntent, } from './new-task-reload-intent'; import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; +import { + mergeTransientMessageProjection, + projectQueuedTransientMessages as applyQueuedTransientProjection, + reconcileTransientMessages, +} from './transient-message-projection.js'; type ToastApi = { error(title: string, description?: string): void; }; +type MessageListUpdater = ( + next: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[]), +) => void; + +type TransientUserMessage = TransientUserMessageProjection; + export function useAppShellSessionWorkspace(toastApi: ToastApi) { const [activeId, setActiveIdState] = useState(); const activeIdRef = useRef(undefined); @@ -45,11 +58,119 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { const selectionRevisionRef = useRef(0); const bootstrapSelectionLeaseRef = useRef | null>(null); const [messages, setMessages] = useState([]); + 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 projectTransientMessages( + sessionId: string, + durable: readonly StoredMessage[], + ): TransientUserMessage[] { + const pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending || pending.size === 0) return []; + 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); + } + return projected; + } + + const setMessagesForActiveSession: MessageListUpdater = (next) => { + 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: TransientUserMessage): 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) { + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); + } + } + + function updateTransientMessage(sessionId: string, message: TransientUserMessage): void { + const pending = transientMessagesBySessionRef.current.get(sessionId); + 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 && messages.length === 0) return; + if (!pending) { + pending = new Map(); + transientMessagesBySessionRef.current.set(sessionId, pending); + } + applyQueuedTransientProjection(pending, messages); + if (activeIdRef.current === sessionId) { + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); + } + } + + async function retireCancelledTransientMessages(sessionId: string): Promise { + const pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending || pending.size === 0) return; + try { + // 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 cancelled) current.delete(messageId); + 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; + if (pending.size === 0) transientMessagesBySessionRef.current.delete(sessionId); + if (activeIdRef.current === sessionId) { + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); + } + } + function setActiveId(next: string | undefined): void { selectionRevisionRef.current += 1; // Clear here, not in the read effect: a layout-effect clear would wipe an @@ -57,7 +178,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; @@ -77,12 +200,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); } @@ -95,7 +222,13 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { startNewSession, clearOwnedSessionState, messages, - setMessages, + transientMessages, + setMessages: setMessagesForActiveSession, + addTransientMessage, + updateTransientMessage, + projectQueuedTransientMessages, + retireCancelledTransientMessages, + removeTransientMessage, transcriptRangeRef, messageLoadPending, setMessageLoadPending, diff --git a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts index 370a5b9a15..55ebdb1c9e 100644 --- a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts +++ b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts @@ -80,7 +80,7 @@ export function scopeWorkHubSessionsToCoordinationHost( }, async send(sessionId: string, command: { type: 'send'; turnId: string; text: string }) { requireTargetHost(sessionId); - return await sessions.send(sessionId, command); + return sessions.send(sessionId, command); }, async stop( sessionId: string, diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..3331b16877 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, + retireCancelledTransientMessages, replaceTranscriptWithStoredMessages, submitCompactToTranscript, toggleAllThinkingExpansion, @@ -246,7 +247,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 +305,207 @@ 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, [], { preserveClientLocalEntries: true }); + + assert.deepEqual(state.entries, [ + { kind: 'user', messageId: 'message-1', text: 'send now', transient: true }, + ]); + }); + + 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); + + retireCancelledTransientMessages(state, ['message-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, [ + { 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', + }, + ], + { preserveClientLocalEntries: true }, + ); + + assert.deepEqual( + state.entries.map((entry) => + entry.kind === 'user' || entry.kind === 'assistant' ? entry.messageId : entry.kind, + ), + ['old-user', 'message-1', 'later-assistant'], + ); + }); + + 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', + }, + ], + { preserveClientLocalEntries: 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', + }, + ], + { preserveClientLocalEntries: 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); + + replaceTranscriptWithStoredMessages( + state, + [{ type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'send now' }], + { preserveClientLocalEntries: true }, + ); + + 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); + 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('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' }); + + 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', transient: true }, + { kind: 'notice', level: 'error', text: 'later row' }, + ]); + }); + test('uses a shared message gutter and trims trailing block rows', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( @@ -373,7 +575,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 +606,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 +1063,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..18acbcf61f 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -29,17 +29,16 @@ 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'; 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'; @@ -52,10 +51,11 @@ import type { MakaSessionRewindResult, MakaSessionSwitchOptions, MakaSessionSwitchResult, + MakaSubmitMessageOptions, 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, @@ -298,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); @@ -1612,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(); @@ -1630,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); @@ -1698,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, @@ -1768,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, @@ -2011,6 +2019,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(); @@ -2176,6 +2251,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 @@ -2293,11 +2405,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. 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 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,133 +2465,11 @@ 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 () => { - 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('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('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('a queued retry settling at the turn boundary is not also flushed as a new turn', async () => { + test('a second Enter during Host admission submits another Message', async () => { const terminal = new FakeTerminal(); - const driver = new DeferredRetryDriver(); + const driver = new SteeringTurnDriver(); + const admission = deferred(); + driver.submitGate = admission.promise; const run = runMakaPiTui({ title: 'Maka', driver, @@ -2484,59 +2480,22 @@ describe('Maka Pi TUI runner', () => { terminal, }); - terminal.input('start'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('lands on retry'); + terminal.input('first prompt'); terminal.input('\r'); - await waitForUpTo(() => driver.steerCalls === 2, 1_000); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('first prompt')); - 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'); + // 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'); - await run; - }); - - test('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('z'); + await waitFor(() => editorInputText(terminal) === 'z'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('second prompt')); - terminal.input('start the work'); - terminal.input('\r'); + admission.resolve(); 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'); + exitMaka(terminal); await run; }); @@ -2586,49 +2545,6 @@ 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 () => { - 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(); @@ -2759,10 +2675,9 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('inspects a historical Agent Graph run without starting a turn', async () => { + test('removes a one-shot Swarm transient when turn admission fails', async () => { const terminal = new FakeTerminal(); - const driver = new SlashCommandDriver(); - const requestedGraphIds: string[] = []; + const driver = new FailingOrchestrationDriver(); const run = runMakaPiTui({ title: 'Maka', driver, @@ -2771,11 +2686,37 @@ describe('Maka Pi TUI runner', () => { connectionSlug: 'deepseek', permissionMode: 'ask', terminal, - agentGraphHistory: { - listEpochs: async () => ({ - epochs: [ - { epoch: 2, graphId: 'graph-2', createdAt: 2, current: true }, - { epoch: 1, graphId: 'graph-1', createdAt: 1, current: false }, + }); + + 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(); + const requestedGraphIds: string[] = []; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'deepseek-v4-flash', + connectionSlug: 'deepseek', + permissionMode: 'ask', + terminal, + agentGraphHistory: { + listEpochs: async () => ({ + epochs: [ + { epoch: 2, graphId: 'graph-2', createdAt: 2, current: true }, + { epoch: 1, graphId: 'graph-1', createdAt: 1, current: false }, ], truncated: false, }), @@ -2939,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, @@ -5762,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'), @@ -5778,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; @@ -6521,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'); @@ -6620,30 +6554,58 @@ class ThrowingFocusReportTerminal extends FakeTerminal { } } -class RejectingStopDriver implements MakaSessionDriver { - stopCalls = 0; +/** + * 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'; - async listSessions(): Promise { - return []; + abstract preparePrompt( + prompt: string, + options?: MakaPreparePromptOptions, + ): Promise; + + abstract promptEvents(prompt: string, turnId?: string): AsyncIterable; + + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + return admitMessageAsTurn(this, text, options); } - preparePrompt(prompt: string): Promise { - return prepareTestPrompt(this, prompt); + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; } - async *promptEvents(_prompt: string): AsyncIterable {} - async *compactSession(): AsyncIterable {} + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } - async stop(): Promise { - this.stopCalls += 1; - throw new Error('stop failed'); + async listSessions(): Promise { + return []; } + 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)); } @@ -6651,16 +6613,52 @@ 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 RejectingStopDriver extends FakeSessionDriver { + 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; + + 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 implements MakaSessionDriver { +class SandboxBoundaryPromptDriver extends FakeSessionDriver { readonly boundaryResponses: SandboxBoundaryResponse[] = []; boundaryRequests = 0; stopCalls = 0; @@ -6670,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); @@ -6740,38 +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 { +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', @@ -6802,43 +6774,26 @@ 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 { +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; 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; @@ -6857,50 +6812,31 @@ 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 { +class SteeringTurnDriver extends FakeSessionDriver { stopCalls = 0; goal: GoalProjection | null = null; readonly steered: string[] = []; readonly queuedMessages: string[] = []; readonly turnOrchestrations: Array = []; + nextSubmitError: Error | undefined; + submitGate: Promise | undefined; + startedTurnMessages: StoredMessage[] = []; + hostSummary: Partial = {}; 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; private turnEnded = false; private eventSeq = 0; - async listSessions(): Promise { - return []; - } - preparePrompt( prompt: string, options: MakaPreparePromptOptions = {}, @@ -6908,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; } @@ -6930,14 +6864,15 @@ 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; } async *promptEvents(_prompt: string, turnId: string): AsyncIterable { + this.turnOpen = true; this.turnEnded = false; for (;;) { while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!; @@ -6946,38 +6881,63 @@ 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); - this.emitQueueUpdate(); - return { kind: 'queued' }; - } - - async queueMessage(text: string): Promise { - this.queuedMessages.push(text); - this.followup.push(text); + 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; + if (this.nextSubmitError) { + const error = this.nextSubmitError; + this.nextSubmitError = undefined; + throw error; + } + if (!this.turnOpen) { + 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), ...this.hostSummary }, + }), + ); + return undefined; + } + if (options.placement === 'current_turn') { + this.steered.push(text); + this.steering.push({ messageId: options.messageId, text }); + } else { + this.queuedMessages.push(text); + this.followup.push({ messageId: options.messageId, text }); + } this.emitQueueUpdate(); - return { kind: 'queued' }; + return undefined; } - async takePendingFollowup(): Promise { - if (this.followup.length === 0) return null; - const joined = this.followup.join('\n\n'); - this.followup = []; - return joined; + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; } - async retractQueued(): Promise { + async retractQueued(): Promise<{ text: string; messageIds: readonly string[] }> { 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(); - return joined; + this.wakeTurn?.(); + this.wakeTurn = null; + return { text: joined, messageIds: retracted.map((entry) => entry.messageId) }; } // Simulates the runtime consuming the steering queue at a step boundary @@ -6996,253 +6956,27 @@ 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'; - } } -/** - * 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 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 = []; - 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 FailingOrchestrationDriver extends SteeringTurnDriver { + override preparePrompt(): Promise { + return Promise.reject(new Error('admission failed')); } } -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 { +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; @@ -7266,39 +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 { - 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', @@ -7337,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 { @@ -7521,7 +7209,7 @@ function pipeOutput(stdout = '', stderr = '') { }; } -class SlashCommandDriver implements MakaSessionDriver { +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. */ @@ -7558,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; @@ -7634,7 +7324,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', @@ -7673,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); @@ -7719,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'); } @@ -7746,15 +7435,36 @@ 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 hands that back as the submit result. + override async submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + if (this.#refuses()) { + return { disposition: 'blocked', skillInvocation: this.skillInvocation }; + } + // 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 { - if (this.skillInvocation.loaded.length === 0 && this.skillInvocation.failed.length > 0) { - throw new SkillInvocationBlockedError(this.skillInvocation); - } - const turn = await super.preparePrompt(prompt, options); - return { ...turn, skillInvocation: this.skillInvocation }; + // `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)); + return super.preparePrompt(prompt, options); } } @@ -7905,6 +7615,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; @@ -8003,7 +7719,6 @@ class DetachingSwitchDriver extends SlashCommandDriver { } class HostSuccessorDriver extends SlashCommandDriver { - #startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; readonly #probeFirst = deferred(); #finishFirst: (() => void) | undefined; successorPulls = 0; @@ -8033,17 +7748,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: [ @@ -8206,21 +7914,15 @@ class LongTranscriptDriver extends SlashCommandDriver { } } -class DeferredControlDriver implements MakaSessionDriver { +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 { @@ -8232,9 +7934,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) => { @@ -8246,39 +7945,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 { +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', @@ -8298,31 +7973,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 { @@ -8343,20 +7997,14 @@ class DeferredListSessionsDriver extends SlashCommandDriver { } } -class SandboxBoundaryThenErrorDriver implements MakaSessionDriver { +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', @@ -8383,30 +8031,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 { @@ -8490,6 +8117,48 @@ 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. + */ +/** + * 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 { skillInvocation: _admissionReceipt, ...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 { disposition: 'turn_started', turnId: turn.turnId }; +} + function fakeSessionSummary( sessionId: string, cwd = '/repo', 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..a692536194 100644 --- a/packages/cli/src/__tests__/pi-tui-turn.test.ts +++ b/packages/cli/src/__tests__/pi-tui-turn.test.ts @@ -24,48 +24,27 @@ import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { runMakaPiTuiTurn } from '../pi-tui-turn.js'; describe('Maka Pi TUI turn', () => { - 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[] = []; const outcome = await runMakaPiTuiTurn({ - driver: { - async preparePrompt(prompt, options) { - sequence.push('prepare'); - assert.equal(prompt, 'visible prompt'); - assert.deepEqual(options, { - 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', - 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: () => { - sequence.push('start'); - }, + onStart: () => sequence.push('start'), onEvent: (sessionEvent) => { sequence.push(`event:${sessionEvent.type}`); }, }); assert.deepEqual(outcome, { kind: 'completed', turnId: '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); }); @@ -74,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', sessionId: null }, + request: { turn: preparedTurn([]) }, shouldAbort: () => false, onFailure: (error) => { failures.push(errorMessage(error)); @@ -96,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', sessionId: 'session-1' }, shouldAbort: () => false, onFailure: (error) => { failures.push(errorMessage(error)); }, }); - assert.deepEqual(outcome, { kind: 'errored', 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/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index b163b6689f..a03b6021e0 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, @@ -44,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', () => { @@ -1096,12 +1100,18 @@ 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.equal(await driver.retractQueued!(), 'Later'); + await driver.submitMessage!('Later', { + messageId: 'message-1', + placement: 'next_turn', + }); + assert.deepEqual(await driver.retractQueued!(), { + text: 'Later', + messageIds: ['message-1'], + }); assert.deepEqual( connection.requests.filter( (request) => @@ -1130,6 +1140,170 @@ 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'); + + await driver.submitMessage!('Visible prompt', { + messageId: 'message-1', + placement: 'current_turn', + modelText: 'Expanded prompt', + }); + 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('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]); + 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'); + + await assert.doesNotReject(() => + driver.submitMessage!('Keep this visible', { + messageId: 'message-unknown', + placement: 'current_turn', + }), + ); + }); + + 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'); + + await assert.doesNotReject(() => + driver.submitMessage!('Keep this visible', { + messageId: 'message-interrupted', + placement: 'current_turn', + }), + ); + }); + test('projects the acknowledgement that releases a question answered through the Host', async () => { const subscription = new FakeSubscription( continuitySnapshot({ interactions: { pending: [pendingQuestion()] } }), @@ -1370,10 +1544,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 () => { @@ -1688,6 +1863,13 @@ class FakeConnection { readonly goalControlOutcomes: Array = []; /** 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( @@ -1714,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'); @@ -1792,7 +1976,19 @@ class FakeConnection { : operation === 'session.execution_boundary.query' ? this.executionBoundary : operation === 'turn.message.submit' - ? { disposition: 'queued', 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 35de5bbd80..0c737c9963 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,37 @@ 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 { + 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 retireCancelledTransientMessages( + state: MakaPiTranscriptState, + cancelledMessageIds: readonly string[], +): void { + 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), + ); } export function appendTurnFailureToTranscript(state: MakaPiTranscriptState, error: unknown): void { @@ -327,8 +347,48 @@ export function applyShellRunUpdateToTranscript( export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], + options: { preserveClientLocalEntries?: boolean } = {}, ): void { - state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + const durableMessageIds = new Set(messages.map((message) => message.id)); + const durableEntries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + const durableEntryIds = new Set(durableEntries.map(transcriptEntryId).filter(Boolean)); + // 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(); + 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) { + state.entries.push(...(transientEntriesByBoundary.get(boundary) ?? [])); + if (boundary < durableEntries.length) state.entries.push(durableEntries[boundary]!); + } clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; @@ -344,12 +404,24 @@ 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); } } +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. @@ -710,7 +782,28 @@ 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, + state.entries.some( + (entry) => + entry.kind === 'user' && + entry.messageId === event.messageId && + entry.transient === true, + ), + ); + 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': @@ -793,15 +886,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 +1562,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..7df3990cef 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, @@ -61,6 +61,7 @@ import { } 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 { @@ -95,6 +96,7 @@ import { completePendingInteraction, applyShellRunViewUpdateToTranscript, permissionModeLabel, + retireCancelledTransientMessages, replaceTranscriptWithStoredMessages, hydrateToolsWithStoredMessages, submitCompactToTranscript, @@ -288,9 +290,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: { preserveClientLocalEntries?: boolean } = {}, + ): void => { rememberTranscriptModel(messages); - replaceTranscriptWithStoredMessages(state, messages); + replaceTranscriptWithStoredMessages(state, messages, options); }; let cwd = input.cwd; let model = input.model; @@ -523,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); @@ -542,9 +551,22 @@ 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, { preserveClientLocalEntries: 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 + .queryCancelledMessages(messageIds) + .then((result) => { + if (closed || input.driver.getSessionId() !== sessionId) return; + retireCancelledTransientMessages(state, result.cancelledMessageIds); + requestRender(); + }) + .catch(() => undefined); + } return; } rememberTranscriptModel(messages); @@ -726,7 +748,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. @@ -808,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>(); @@ -845,9 +869,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // connection where both calls are asynchronous. void (async () => { await settlePendingEnqueues(); - const retracted = (await input.driver.retractQueued?.()) ?? ''; - const fallback = await takePendingFallbackSettled(); - refillEditorFromQueues([fallback, retracted].filter(Boolean).join('\n\n')); + const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [] }; + acceptRetraction(retracted); requestRender(); await input.driver.stop(); })().catch((error) => { @@ -858,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; } @@ -868,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; @@ -891,140 +920,77 @@ 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(), - }); - }; - - // 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(); + submitMessage(prompt, 'current_turn'); }; - 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 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); }; - const takePendingFallbackEntriesSettled = async (): Promise< - Array<{ text: string; enqueue: 'steer' | 'queue' }> - > => { - if (fallbackRetryTimer !== null) { - clearTimeout(fallbackRetryTimer); - fallbackRetryTimer = null; - } - await fallbackRetryTask; - return takePendingFallbackEntries(); + const acceptRetraction = (retracted: { text: string; messageIds: readonly string[] }) => { + for (const messageId of retracted.messageIds) removeTransientUserMessage(messageId); + refillEditorFromQueues(retracted.text); }; - 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) => { - if (!text.trim()) { - requestRender(); - return; - } + /** + * 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 enqueue = input.driver.steer; - if (!enqueue) { - deferFallback(text, 'steer'); - return; - } - const task = enqueue - .call(input.driver, text) - .then((outcome) => { - if (outcome.kind === 'fallback') { - if (turnRunning || busy) deferFallback(text, 'steer'); - else submitPrompt(text); + const messageId = randomUUID(); + appendUserPrompt(state, text, messageId, true); + 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); return; } - // Queued: the runtime's `queue_update` event refreshes the mirror. - requestRender(); + // 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) => { - refillEditorFromQueues(text); + // 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); reportError(error); + }) + .finally(() => { + requestRender(); }); 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; + } + submitMessage(text, 'current_turn'); + }; + // Alt+Enter: during a turn, queue the text to open the next turn; when idle, // it submits like Enter. const handleAltEnter = () => { @@ -1042,38 +1008,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { submitPrompt(text); return; } - editor.addToHistory(text); - const enqueue = input.driver.queueMessage; - if (!enqueue) { - deferFallback(text, 'queue'); - return; - } - const task = enqueue - .call(input.driver, text) - .then((outcome) => { - if (outcome.kind === 'fallback') { - if (turnRunning || busy) deferFallback(text, 'queue'); - else submitPrompt(text); - return; - } - // Queued: the runtime's `queue_update` event refreshes the mirror. - requestRender(); - }) - .catch((error) => { - refillEditorFromQueues(text); - reportError(error); - }); - trackEnqueue(task); + submitMessage(text, 'next_turn'); }; - // 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')); + const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [] }; + acceptRetraction(retracted); requestRender(); })().catch(reportError); }; @@ -1213,7 +1157,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); let permissionAlerted = false; - let optimisticUserEntry: (typeof state.entries)[number] | undefined; const finishTurnUi = () => { turnRunning = false; turnStartedAt = undefined; @@ -1228,7 +1171,6 @@ 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. @@ -1236,10 +1178,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // the runtime has emitted its terminal event and accepted the stop. shouldAbort: () => closed, onStart: () => { - if (request.kind !== 'attached') { - appendUserPrompt(state, request.prompt); - optimisticUserEntry = state.entries.at(-1); - } requestRender(); }, onPrepared: async (turn) => { @@ -1249,7 +1187,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (superseded()) return; if (authoritativeAttachedTurn) { adoptSessionMetadata(authoritativeAttachedTurn.summary); - replaceTranscript(authoritativeAttachedTurn.messages); + replaceTranscript(authoritativeAttachedTurn.messages, { + preserveClientLocalEntries: true, + }); shellRunHydration.reset(); if (input.listShellRunUpdates) { await shellRunHydration.hydrate(authoritativeAttachedTurn.sessionId); @@ -1265,15 +1205,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) => { @@ -1334,9 +1265,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 +1279,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(); @@ -1470,7 +1346,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, ); }; @@ -2488,11 +2364,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) { @@ -2738,10 +2611,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' }, }); }; @@ -2851,10 +2721,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 055b43e2ea..88da27c27e 100644 --- a/packages/cli/src/pi-tui-turn.ts +++ b/packages/cli/src/pi-tui-turn.ts @@ -19,42 +19,24 @@ import type { SessionEvent } from '@maka/core/events'; 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; - /** 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; @@ -67,12 +49,13 @@ export interface RunMakaPiTuiTurnInput { /** * 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 { const { request } = input; let activity: SessionActivityLease | undefined; - let preparedTurnId = request.kind === 'attached' ? request.turn.turnId : undefined; + let preparedTurnId = request.turn.turnId; const finishBeforeDrain = (outcome: GoalTurnOutcome): GoalTurnOutcome => { activity?.release(); @@ -86,25 +69,18 @@ export async function runMakaPiTuiTurn(input: RunMakaPiTuiTurnInput): Promise { - return this.#enqueue(text, 'current_turn'); + submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise | undefined> { + return this.#admit(() => this.#submitMessage(text, options)); } - async queueMessage(text: string): Promise { - return this.#enqueue(text, 'next_turn'); + async #submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise | undefined> { + 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; + try { + return await this.#request('turn.message.submit', { + originHostEpoch: this.#connection.hostEpoch, + sessionId, + messageId: options.messageId, + content: { + text: modelText, + ...(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 undefined; + } + throw error; + } } - async takePendingFollowup(): 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 queryCancelledMessages( + messageIds: readonly string[], + ): Promise> { + const sessionId = await this.#ensureSession(); + return this.#request('turn.message.query', { sessionId, messageIds }); } - 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 { @@ -428,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, { @@ -443,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 }), @@ -453,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; @@ -466,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 }), @@ -876,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 { @@ -950,26 +1051,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/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 e9fe200a6e..50114b7202 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'; @@ -28,7 +28,12 @@ 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, + TurnMessageSubmitResult, +} from '@maka/runtime-host/protocol'; export interface MakaSessionMoveResult { previousCwd: string; @@ -90,11 +95,34 @@ export interface MakaPreparePromptOptions { maxSteps?: number; } -export class SkillInvocationBlockedError extends Error { - constructor(readonly skillInvocation: SkillInvocationResult) { - super('Explicit Skill invocation could not be resolved'); - this.name = 'SkillInvocationBlockedError'; - } +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 { + text: string; + messageIds: readonly string[]; +} + +/** + * 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 { @@ -104,12 +132,18 @@ export interface MakaSessionDriver { prompt: string, options?: MakaPreparePromptOptions, ): 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; - steer?(text: string): Promise; - queueMessage?(text: string): Promise; - takePendingFollowup?(): Promise; - retractQueued?(): Promise; + retractQueued?(): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; setModel(model: string, connectionSlug?: string): 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__/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 4763c12670..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,73 @@ 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); + await submit(fixture, 'cancelled-message', 'discard me', 'next_turn'); + 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'), + ); + + const result = await fixture.coordinator.handlers['turn.message.query']( + { + sessionId: ROOT.sessionId, + messageIds: [ + 'cancelled-message', + 'accepted-message', + 'handed-off-message', + 'unknown-message', + ], + }, + operationContext(), + ); + + assert.deepEqual(result, { + ok: true, + result: { cancelledMessageIds: ['cancelled-message'] }, + }); +}); + test('submit re-runs admission when the queue revision moves during preflight', async () => { let preflightCalls = 0; const fixture = createFixture(undefined, async () => { @@ -284,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({ @@ -2124,6 +2220,7 @@ function createFixture( } | undefined; const receipts = new Map(); + const recoveredBatches: HostMessageRecoveryBatch[] = []; const events: RuntimeEvent[] = []; const messageAdmissions = new Map< string, @@ -2162,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(); @@ -2234,6 +2346,7 @@ function createFixture( startCalls: () => startCalls, events, receipts, + recoveredBatches, readMessageAdmission: (messageId: string) => messageAdmissions.get(messageId)?.admission, stopClaimed, resolveTerminal: terminal.resolve, @@ -2267,6 +2380,8 @@ function memoryMessageAdmissionStore( return admission; }, readMessageAdmission: async (_sessionId, messageId) => admissions.get(messageId)?.admission, + 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/__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/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 5f62a61b7b..5e041ebecd 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({ @@ -3499,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(); 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..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,18 +84,44 @@ 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 }; + | { + 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 cancelledMessageIds: readonly string[]; +} export interface QueueRetractInput { readonly originHostEpoch: string; @@ -163,6 +195,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', @@ -242,27 +281,93 @@ 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 } : {}), }; } +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', ['cancelledMessageIds']); + if ( + !Array.isArray(record.cancelledMessageIds) || + record.cancelledMessageIds.length > MESSAGE_QUEUE_MAX_ENTRIES + ) { + throw invalidProtocolFrame('Invalid turn.message.query cancelledMessageIds'); + } + const cancelledMessageIds = record.cancelledMessageIds.map((messageId) => + requireEntityId(messageId, 'messageId'), + ); + if (new Set(cancelledMessageIds).size !== cancelledMessageIds.length) { + throw invalidProtocolFrame('Duplicate turn.message.query cancelledMessageId'); + } + 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']); @@ -274,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/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/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 066d3d5e0c..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 { @@ -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,13 +108,31 @@ 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; 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 { @@ -145,7 +165,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, @@ -315,6 +335,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 +391,24 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return state ? hasLiveMessageState(state) : false; } + /** + * 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) { + if (await this.#admissions.hasCancelledMessageAdmission(input.sessionId, messageId)) { + cancelledMessageIds.push(messageId); + } + } + return success({ cancelledMessageIds }); + } + retireSessions(sessionIds: readonly string[]): void { for (const sessionId of new Set(sessionIds)) { const state = this.#sessions.get(sessionId); @@ -662,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, ), @@ -814,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', }; @@ -828,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'); } @@ -842,6 +887,10 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { initiatingConnectionId, turnId, runId, + ...(payload.skillIds.length > 0 ? { skillIds: payload.skillIds } : {}), + ...(payload.turnOrchestration + ? { turnOrchestration: payload.turnOrchestration } + : {}), }, admission, async (canonicalContent) => { @@ -855,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(), }); }, @@ -862,14 +914,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'); } @@ -2073,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, @@ -2091,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) ); } @@ -2222,6 +2300,8 @@ interface CanonicalSubmitPayload { readonly messageId: string; readonly content: MessageContent; readonly placement: MessagePlacement; + readonly skillIds: readonly string[]; + readonly turnOrchestration?: TurnOrchestration; } function canonicalSubmitPayload(input: TurnMessageSubmitInput): CanonicalSubmitPayload { @@ -2231,9 +2311,40 @@ 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; +} + +/** + * 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/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/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 9e36149c13..f30caaeabb 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); } @@ -1151,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, @@ -1161,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(), }); @@ -1507,7 +1523,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/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index f939ab6099..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, }; @@ -445,6 +448,8 @@ describe('SqliteSessionMetadataStore', () => { await store.commitMessageAdmission(admission); await store.cancelMessageAdmissions('session-1', ['message-1']); assert.deepEqual(await store.listMessageAdmissions('session-1'), []); + 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/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/execution-stores.ts b/packages/storage/src/execution-stores.ts index 32996da174..bd9dbe67e3 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)), + 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 a73a3f43e2..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; } @@ -41,6 +54,12 @@ export interface MessageAdmissionStore { 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; @@ -75,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'); @@ -101,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/session-store.ts b/packages/storage/src/session-store.ts index 5361318930..10d045a4d5 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 hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise { + await this.ensureReady(); + return this.metadata.hasCancelledMessageAdmission(sessionId, messageId); + } + async listMessageAdmissions(sessionId: string): Promise { await this.ensureReady(); return this.metadata.listMessageAdmissions(sessionId); 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 8943dea71f..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 = ? `, @@ -1656,6 +1668,20 @@ export class SqliteSessionMetadataStore { }); } + async hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeSessionId(messageId); + return this.readTransaction(() => { + const row = this.db + .prepare( + 'SELECT 1 AS present FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get(sessionId, messageId); + return row !== undefined; + }); + } + async listMessageAdmissions(sessionId: string): Promise { this.assertOpen(); assertSafeSessionId(sessionId); @@ -1664,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 @@ -1704,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 = ? `, @@ -1813,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 = ? `, 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/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index e0e6f27383..1a5bff3fc7 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -57,6 +57,8 @@ import { type ProviderRetryEvent, type QuoteRef, } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from './chat-view.js'; import { finalAssistantReplyText, type TurnTimelineItem, @@ -276,6 +278,33 @@ const UserMessageBody = memo(function UserMessageBody(props: { ); }); +export function TransientUserMessage(props: { + message: TransientUserMessageProjection; + 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..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'; @@ -43,6 +48,7 @@ import { LocalizedChatMessage, TurnRunningStatus, TurnView, + TransientUserMessage, type ReadAttachmentBytes, type TurnFooterActionMeta, type TurnPresentationDeriver, @@ -59,8 +65,35 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } +/** + * 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. + */ + 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: { messages: StoredMessage[]; + transientMessages?: readonly TransientUserMessageProjection[]; messageLoading?: boolean; liveTurn?: LiveTurnProjection; /** Live display content already present when the host activated this conversation surface. */ @@ -272,6 +305,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 @@ -454,6 +488,27 @@ export function ChatView(props: { } }, [revealTurn]); const mountedTurns = turns.slice(mountStart, mountEnd); + const inlineTransientMessages = tailTurnId + ? transientMessages.filter((message) => { + const turn = mountedTurns.find((candidate) => candidate.turnId === tailTurnId); + if ( + turn === undefined + || turn.user !== undefined + || turn.timeline.some((item) => item.kind === 'user' && item.messageId === message.id) + ) { + return false; + } + // 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( + inlineTransientMessages.map((message) => message.id), + ); const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, @@ -529,8 +584,10 @@ export function ChatView(props: { const hasVisibleConversationItem = conversationItemPlacement.byTurn.size > 0 || conversationItemPlacement.orphan !== undefined; const showEmptyState = - (chat.length === 0 && !streamingActive && !hasVisibleConversationItem) - || Boolean(props.messageLoading && chat.length === 0 && !hasVisibleConversationItem); + chat.length === 0 + && transientMessages.length === 0 + && !streamingActive + && !hasVisibleConversationItem; const emptyContent = props.messageLoading ? (
@@ -636,6 +693,15 @@ export function ChatView(props: { className="maka-turn-virtual-item" data-virtual-turn-id={turn.turnId} > + {turn.turnId === tailTurnId + ? inlineTransientMessages.map((message) => ( + + )) + : null} )} + {transientMessages.filter( + (message) => !inlineTransientMessageIds.has(message.id), + ).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 diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 65c25c64a1..a9f1def4c7 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -37,7 +37,11 @@ export { ToolResultPreview } from './tool-activity/tool-result-preview.js'; export { SandboxBoundaryPrompt } from './sandbox-boundary-prompt.js'; export { ChatSurfaceLayout } from './chat-surface-layout.js'; export type { ChatSurfaceLayoutProps } from './chat-surface-layout.js'; -export { ChatView, type LiveContentActivationSnapshot } from './chat-view.js'; +export { + ChatView, + type LiveContentActivationSnapshot, + type TransientUserMessageProjection, +} from './chat-view.js'; export { WorkspacePicker } from './workspace-picker.js'; export type { WorkspacePickerModel } from './workspace-picker.js'; export { 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' ? ( = 0 ? turns[targetIndex]!