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..433237be7f 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -100,6 +100,8 @@ function createActionsDeps() { setMessageLoadErrorBySession: () => undefined, setMessageRetryPendingBySession: () => undefined, setMessages: () => undefined, + addTransientMessage: () => undefined, + removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, @@ -119,16 +121,199 @@ function createActionsDeps() { const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; describe('busy-raced send settlement', () => { - it('a steered send on an existing session disarms its turn and shows no optimistic message', async () => { + it('shows a Follow Up immediately and keeps its caller-owned identity', async () => { + const activeIdRef = { current: 'session-a' as string | undefined }; + const transient = new Map(); + let submittedMessageId: string | undefined; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + kind: 'queued', + messageId: command.messageId, + attachments: [], + inlineReferences: [], + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.enqueueMessage( + 'session-a', + 'do this next', + 'next_turn', + ); + await submitted; + + assert.ok(submittedMessageId); + assert.equal(transient.get(submittedMessageId)?.id, submittedMessageId); + releaseAdmission(); + await sending; + assert.deepEqual([...transient.keys()], [submittedMessageId]); + } finally { + restoreWindow(); + } + }); + + it('keeps a Follow Up visible when Host admission outcome is unknown', async () => { + const transient = new Map(); + const restoreWindow = installWindow({ + sessions: { + enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => ({ + kind: 'outcome_unknown' as const, + messageId: command.messageId, + }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + + await actions.enqueueMessage('session-a', 'do this next', 'next_turn'); + + assert.equal(transient.size, 1); + assert.equal([...transient.values()][0]?.type, 'user'); + } finally { + restoreWindow(); + } + }); + + it('does not resurrect a Follow Up retracted before its IPC reply settles', async () => { + const transient = new Map(); + let submittedMessageId: string | undefined; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + kind: 'queued' as const, + messageId: command.messageId, + attachments: [], + inlineReferences: [], + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => { + if (transient.has(message.id)) transient.set(message.id, message); + }, + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.enqueueMessage('session-a', 'do this next', 'next_turn'); + await submitted; + + assert.ok(submittedMessageId); + transient.delete(submittedMessageId); + releaseAdmission(); + await sending; + + assert.deepEqual([...transient.keys()], []); + } finally { + restoreWindow(); + } + }); + + it('shows one stable local message before Host admission settles', async () => { + const activeIdRef = { current: 'session-a' as string | undefined }; + const transient = new Map(); + 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, command: { messageId: string }) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + ok: true, + disposition: 'turn_started', + messageId: command.messageId, + turnId: 'host-turn', + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.send('also check the tests'); + await submitted; + + assert.ok(submittedMessageId); + const localMessage = transient.get(submittedMessageId); + assert.equal(localMessage?.type, 'user'); + assert.equal(localMessage?.type === 'user' ? localMessage.text : undefined, 'also check the tests'); + + releaseAdmission(); + assert.equal(await sending, true); + assert.equal(transient.size, 1); + assert.equal(transient.has(submittedMessageId), true); + assert.equal(transient.has('host-turn'), false); + } finally { + restoreWindow(); + } + }); + + it('keeps one local row when Host admits the message as steering', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); const restoreWindow = installWindow({ sessions: { - send: async (_sessionId: string, command: { turnId: string }) => ({ + 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, @@ -141,23 +326,31 @@ describe('busy-raced send settlement', () => { activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); assert.equal(turnState.liveTurnBySession['session-a'], undefined); - assert.deepEqual(messageState.messages, []); + const local = messageState.messages.filter((message) => message.type === 'user'); + assert.equal(local.length, 1); + assert.equal(local[0]?.id, local[0]?.turnId); } finally { restoreWindow(); } }); - it('rebinds the unconfirmed arm onto a Host-chosen turn id', async () => { + it('does not turn a Host-started admission into a renderer-owned LiveTurn', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); const restoreWindow = installWindow({ sessions: { - send: async () => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -171,14 +364,16 @@ describe('busy-raced send settlement', () => { activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); - const live = turnState.liveTurnBySession['session-a']; - assert.equal(live?.turnId, 'host-turn'); - assert.equal(live?.unconfirmed, true); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); const optimistic = messageState.messages.filter((message) => message.type === 'user'); assert.equal(optimistic.length, 1); - assert.equal(optimistic[0]?.turnId, 'host-turn'); + assert.notEqual(optimistic[0]?.id, 'host-turn'); } finally { restoreWindow(); } @@ -190,7 +385,7 @@ describe('busy-raced send settlement', () => { const messageState = createMessageState(); 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 +393,8 @@ describe('busy-raced send settlement', () => { })); return { ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -212,6 +409,10 @@ describe('busy-raced send settlement', () => { activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); const live = turnState.liveTurnBySession['session-a']; @@ -223,7 +424,7 @@ describe('busy-raced send settlement', () => { } }); - it('a steered send on the new-chat path navigates without a ghost optimistic turn', async () => { + it('keeps the new-chat message through navigation when Host admits it as steering', async () => { const activeIdRef = { current: undefined as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); @@ -237,10 +438,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, @@ -257,18 +458,22 @@ describe('busy-raced send settlement', () => { }, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); assert.deepEqual(activated, ['session-new']); assert.equal(turnState.liveTurnBySession['session-new'], undefined); - assert.deepEqual(messageState.messages, []); + assert.equal(messageState.messages.filter((message) => message.type === 'user').length, 1); assert.deepEqual(removed, []); } finally { restoreWindow(); } }); - it('a Host-chosen turn id on the new-chat path keys the optimistic state to it', async () => { + it('keeps the new-chat messageId when Host chooses another turnId', async () => { const activeIdRef = { current: undefined as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); @@ -277,8 +482,10 @@ describe('busy-raced send settlement', () => { 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: [], @@ -295,12 +502,16 @@ describe('busy-raced send settlement', () => { }, setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, + addTransientMessage: (_sessionId, message) => + messageState.setMessages((current) => [...current.filter((item) => item.id !== message.id), message]), + removeTransientMessage: (_sessionId, messageId) => + messageState.setMessages((current) => current.filter((message) => message.id !== messageId)), }); assert.equal(await actions.send('also check the tests'), true); - assert.equal(turnState.liveTurnBySession['session-new']?.turnId, 'host-turn'); + assert.equal(turnState.liveTurnBySession['session-new'], undefined); const optimistic = messageState.messages.filter((message) => message.type === 'user'); assert.equal(optimistic.length, 1); - assert.equal(optimistic[0]?.turnId, 'host-turn'); + assert.notEqual(optimistic[0]?.id, 'host-turn'); } finally { restoreWindow(); } diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 967d777dc9..40b6cceb94 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -35,12 +35,9 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { SessionSummary } from '@maka/core/session'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js'; -import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js'; function installWindow(maka: unknown): () => void { const target = globalThis as unknown as { window?: unknown }; @@ -101,6 +98,8 @@ function createActionsDeps() { setMessageLoadErrorBySession: () => undefined, setMessageRetryPendingBySession: () => undefined, setMessages: () => undefined, + addTransientMessage: () => undefined, + removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, @@ -124,7 +123,7 @@ describe('composer first-send cleanup', () => { let sends = 0; const restoreWindow = installWindow({ sessions: { - send: async () => { + submitMessage: async () => { sends += 1; return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; }, @@ -163,7 +162,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -213,7 +212,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -249,7 +248,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -294,7 +293,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -330,7 +329,7 @@ describe('composer first-send cleanup', () => { newTasks: { create: async () => ({ id: 'session-1' }) }, sessions: { // What `prepareSkillInvocation` does when Skill discovery fails. - send: async () => Promise.reject(new Error('Skill discovery failed')), + submitMessage: async () => Promise.reject(new Error('Skill discovery failed')), remove: async (sessionId: string) => { removed.push(sessionId); }, @@ -351,7 +350,7 @@ describe('composer first-send cleanup', () => { const restoreWindow = installWindow({ newTasks: { create: async () => ({ id: 'session-1' }) }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -379,7 +378,7 @@ describe('composer first-send cleanup', () => { const removed: string[] = []; const restoreWindow = installWindow({ sessions: { - send: async () => Promise.reject(new Error('Skill discovery failed')), + submitMessage: async () => Promise.reject(new Error('Skill discovery failed')), remove: async (sessionId: string) => { removed.push(sessionId); }, @@ -416,7 +415,7 @@ describe('composer first-send cleanup', () => { const transcriptRangeRef = { current: transcript as DesktopTranscriptRangeController | undefined }; const restoreWindow = installWindow({ sessions: { - send: async () => { + submitMessage: async () => { order.push('send'); return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; }, @@ -463,7 +462,7 @@ function deferred() { describe('composer send failure feedback', () => { const readinessFailure = () => ({ sessions: { - send: async () => + submitMessage: async () => Promise.reject(new Error('NO_REAL_CONNECTION:missing_api_key: no ready connection')), remove: async () => undefined, }, @@ -491,11 +490,7 @@ describe('composer send failure feedback', () => { assert.deepEqual(setupToasts, [], 'a stale surface must not be navigated to 设置 · 模型'); }); - // A send that never reaches the runtime must take its arm with it. A leftover - // arm still carries its `unconfirmed` claim, which would make - // `settledSessionTransientIds` protect a turn that does not exist — leaving a - // Stop button nothing can clear. - it('leaves no arm behind when the send never lands', async () => { + it('does not invent a live turn when the send never lands', async () => { const turnState = createTurnState(); const restoreWindow = installWindow(readinessFailure()); @@ -532,92 +527,3 @@ describe('composer send failure feedback', () => { assert.equal(setupToasts.length, 1, 'the user who is still looking must get the answer'); }); }); - -/** - * The bug this guards, as the sequence that actually produced it: send arms the - * turn, a session list that was already in flight lands still carrying the - * pre-send status, and the settle reconcile runs against it. - * - * Nothing in that list is wrong — the runtime writes `status: 'running'` only at - * the end of `AgentRun.begin` and announces it to nobody until `onRunStarted`. - * The list simply predates the answer. Reading it as a settle used to drop the - * arm, so the first content event rebuilt the projection as `'streamed'` and the - * prominent "正在处理…" silently became the calm "继续中…". - * - * Asserted through the real `send`, the real state controller, and the real - * settle rule, because the defect lived in how those three compose — each one is - * individually correct. - */ -describe('a send in flight versus a stale session list', () => { - const sessionId = 'session-a'; - - function sendingWindow() { - return { - sessions: { - send: async () => ({ - ok: true, - attachments: [], - skillInvocation: { loaded: [], failed: [] }, - }), - }, - }; - } - - // The list as it reads before the runtime's `running` write — identical to how - // it reads after the turn is over, which is exactly why the status alone - // cannot settle anything. - const preSendList = [{ id: sessionId, status: 'active', statusUpdatedAt: 100 }] as SessionSummary[]; - - async function armViaSend(controller: ReturnType) { - const restoreWindow = installWindow(sendingWindow()); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: sessionId }, - setLiveTurnBySession: controller.setLiveTurnBySession, - }); - assert.equal(await actions.send('hello'), true); - } finally { - restoreWindow(); - } - const armed = controller.getState().liveTurnBySession[sessionId]; - assert.equal(armed?.unconfirmed, true, 'the send must arm an unconfirmed turn'); - return armed!.turnId; - } - - function settle(controller: ReturnType) { - return settledSessionTransientIds({ - activeId: sessionId, - sessions: preSendList, - liveTurnBySession: controller.getState().liveTurnBySession, - }); - } - - it('keeps the armed turn, and settles it once the authority names that turn', async () => { - const controller = createAppShellSessionUiStateController(); - const turnId = await armViaSend(controller); - - assert.deepEqual(settle(controller), [], 'a list older than the answer must not settle the turn'); - assert.equal( - controller.getState().liveTurnBySession[sessionId]?.phase, - 'waiting', - 'the first-token wait must survive the stale refresh', - ); - - // `sessions:changed` naming this turn — what `onRunStarted` now emits once - // the run has begun. This is the same controller entry point the shell - // wires that subscription to. - controller.confirmLiveTurn(sessionId, turnId); - - assert.deepEqual(settle(controller), [sessionId], 'an answered turn settles under the plain status rules'); - }); - - it('ignores an answer about a turn other than the one in flight', async () => { - const controller = createAppShellSessionUiStateController(); - await armViaSend(controller); - - controller.confirmLiveTurn(sessionId, 'turn-from-another-client'); - - assert.deepEqual(settle(controller), [], 'only this send\'s own turn may release its claim'); - }); -}); diff --git a/apps/desktop/src/main/__tests__/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..4ed78e51ca 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -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,71 @@ test('queue_update events drive the independent desktop queue projection', () => }, ], }); + assert.deepEqual(transientMessages, [ + { + type: 'user', + id: 'message-steer', + turnId: 'turn-1', + transientPlacement: 'current_turn', + ts: 1, + text: 'adjust this run', + }, + { + type: 'user', + id: 'message-next', + turnId: '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..165367d3ef 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -578,6 +578,146 @@ test("forwards explicit Skill invocation to the Host-owned Turn admission", asyn }); }); +test("submits an ordinary composer message once under its stable message identity", async () => { + const submits: unknown[] = []; + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => session(), + startTurn: async () => { + throw new Error("ordinary composer send must not choose Turn admission"); + }, + submitMessage: async (input) => { + submits.push(input); + return { disposition: "turn_started", turnId: "host-turn" }; + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + newId: () => "unexpected-generated-id", + }, + ipc, + ); + + const result = await ipc.invoke("sessions:send", "session-1", { + type: "send", + messageId: "message-1", + text: "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 Message lifecycle proof to the renderer', async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + queryMessages: async (input) => ({ + messages: input.messageIds.map((messageId) => ({ + messageId, + status: messageId === 'message-cancelled' ? 'cancelled' : 'accepted', + })), + }), + }), + }, + ipc, + ); + + assert.deepEqual( + await ipc.invoke('sessions:queryMessageStatuses', 'session-1', [ + 'message-accepted', + 'message-cancelled', + ]), + { + messages: [ + { messageId: 'message-accepted', status: 'accepted' }, + { messageId: 'message-cancelled', status: 'cancelled' }, + ], + }, + ); +}); + +test('keeps slash Skill sends on the exact-Turn path with their stable message identity', async () => { + const starts: unknown[] = []; + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => session(), + submitMessage: async () => { + throw new Error('slash Skill send must preserve Skill invocation feedback'); + }, + startTurn: async (input) => { + starts.push(input); + return { + kind: 'started', + turn: { + sessionId: input.sessionId, + turnId: input.turnId, + runId: 'run-skill', + status: 'running', + }, + skillInvocation: { + loaded: [], + failed: [{ request: 'missing', reason: 'not_found' }], + receipts: [], + }, + }; + }, + }), + newId: () => 'unexpected-generated-id', + }, + ipc, + ); + + const result = await ipc.invoke('sessions:send', 'session-1', { + type: 'send', + messageId: 'message-skill', + text: '/skill:missing inspect this', + }); + + assert.deepEqual(starts, [{ + sessionId: 'session-1', + turnId: 'message-skill', + content: { text: '/skill:missing inspect this', inlineReferences: [] }, + }]); + assert.deepEqual(result, { + ok: true, + turnId: 'message-skill', + attachments: [], + inlineReferences: [], + skillInvocation: { + loaded: [], + failed: [{ request: 'missing', reason: 'not_found' }], + receipts: [], + }, + }); +}); + test("queues a mid-turn send as steering when the Host reports the session busy", async () => { const submits: unknown[] = []; const changes: unknown[] = []; @@ -677,6 +817,7 @@ test("retries a dispatched normal send with its original Turn identity", async ( const result = await ipc.invoke("sessions:send", "session-1", { type: "send", + messageId: 'message-1', text: "keep this Turn identity", }); @@ -684,18 +825,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 +958,17 @@ test("retries a dispatched busy fallback with its original message identity", as inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] }, }); - await assert.rejects( - ipc.invoke("sessions:send", "session-1", { + assert.deepEqual( + await ipc.invoke("sessions:send", "session-1", { type: "send", - turnId: "turn-unknown", + messageId: "turn-unknown", text: "ordinary chat keeps the existing failure contract", }), - (error: unknown) => - error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown', + { + ok: false, + reason: "outcome_unknown", + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }, ); assert.deepEqual( await ipc.invoke("sessions:send", "side-session", { @@ -1003,6 +1147,7 @@ test("queues explicit Desktop follow-ups", async () => { assert.deepEqual( await ipc.invoke("sessions:enqueue", "session-1", "next_turn", { + messageId: "followup-message", text: "do this next", quotes: [{ text: "quoted context" }], retainedAttachments: [ @@ -1040,7 +1185,7 @@ test("queues explicit Desktop follow-ups", async () => { assert.deepEqual(submits, [ { sessionId: "session-1", - messageId: "id-2", + messageId: "followup-message", content: { text: "do this next", attachments: [ @@ -1064,6 +1209,39 @@ test("queues explicit Desktop follow-ups", async () => { ]); }); +test('keeps an unknown Desktop follow-up admission available for reconciliation', async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => session(), + submitMessage: async () => { + throw new RuntimeHostOperationError( + 'turn.message.submit', + 'outcome_unknown', + 'Message disposition cannot be proven in this Host Epoch', + ); + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + }, + ipc, + ); + + assert.deepEqual( + await ipc.invoke('sessions:enqueue', 'session-1', 'next_turn', { + messageId: 'followup-unknown', + text: 'keep this visible', + }), + { kind: 'outcome_unknown' }, + ); +}); + test("routes per-entry queue mutations to the Runtime Host", async () => { const calls: unknown[] = []; let sequence = 0; @@ -1177,7 +1355,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, @@ -1282,10 +1466,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', @@ -1379,6 +1563,7 @@ function executionClient(overrides: Partial): ExecutionClient { interruptTurn: unavailable, listSessionTurnLandmarks: unavailable, listSessionTurns: unavailable, + queryMessages: unavailable, queryTurnResume: unavailable, readExecutionBoundary: unavailable, regenerateTurn: unavailable, diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index b35a635ce5..3cd5846844 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -90,6 +90,95 @@ function renderLiveTurn(liveTurn: LiveTurnProjection): string { } describe('single live-turn handoff', () => { + it('renders a transient user message without manufacturing a Turn', () => { + const markup = renderWithLocale(createElement(ChatView, { + activeSession: { + id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'active', backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask', + }, + messages: [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + ], + transientMessages: [ + { type: 'user', id: 'message-pending', turnId: 'message-pending', ts: 2, text: 'send now' }, + ], + scrollBehavior: 'smooth', + onNew() {}, + } satisfies Parameters[0])); + + assert.equal((markup.match(/data-virtual-turn-id=/g) ?? []).length, 1); + assert.match(markup, /data-transient-message-id="message-pending"/); + assert.match(markup, />send now { + const markup = renderWithLocale(createElement(ChatView, { + activeSession: { + id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask', + }, + messages: [], + transientMessages: [ + { type: 'user', id: 'turn-1', turnId: 'turn-1', ts: 1, text: 'send now' }, + ], + messageLoading: true, + scrollBehavior: 'smooth', + liveTurn: { + turnId: 'turn-1', + phase: 'streamed', + steps: [{ + stepId: 'assistant-1', + text: { text: 'live answer', truncated: false, complete: false }, + tools: [], + }], + }, + onNew() {}, + } satisfies Parameters[0])); + + assert.doesNotMatch(markup, /maka-chat-message-loading/); + assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="turn-1"')); + assert.equal((markup.match(/data-transient-message-id="turn-1"/g) ?? []).length, 1); + assert.equal((markup.match(/data-virtual-turn-id="turn-1"/g) ?? []).length, 1); + }); + + it('keeps an unresolved root transient before a live Turn that arrived before IPC settled', () => { + const markup = renderWithLocale(createElement(ChatView, { + activeSession: { + id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask', + }, + messages: [], + transientMessages: [ + { + type: 'user', id: 'message-1', turnId: 'message-1', ts: 1, text: 'send now', + transientPlacement: 'turn_source', + }, + { + type: 'user', id: 'message-next', turnId: 'message-next', ts: 2, text: 'do this next', + transientPlacement: 'next_turn', + }, + ], + scrollBehavior: 'smooth', + liveTurn: { + turnId: 'host-turn', + phase: 'streamed', + steps: [{ + stepId: 'assistant-1', + text: { text: 'live answer', truncated: false, complete: false }, + tools: [], + }], + }, + onNew() {}, + } satisfies Parameters[0])); + + assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="host-turn"')); + assert.ok(markup.indexOf('do this next') > markup.indexOf('data-turn-id="host-turn"')); + assert.equal((markup.match(/data-transient-message-id=/g) ?? []).length, 2); + }); + it('renders one ordered timeline: thinking before its tool and answer', () => { const markup = renderLiveTurn({ turnId: 'turn-1', diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts new file mode 100644 index 0000000000..120bbd6a2b --- /dev/null +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -0,0 +1,183 @@ +/* + * 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 { + mergeTransientMessageProjection, + projectQueuedTransientMessages, + reconcileTransientMessageLifecycle, + reconcileTransientMessages, +} from '../../renderer/transient-message-projection.js'; + +const transient: Extract = { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 2, + text: 'send now', +}; + +test('keeps a transient message through sparse transcript replacement', () => { + const pending = new Map([[transient.id, transient]]); + const projected = reconcileTransientMessages(pending, []); + + assert.deepEqual(projected, [transient]); + assert.equal(pending.has(transient.id), true); +}); + +test('updates a transient message without treating its previous render as canonical', () => { + const pending = new Map([[transient.id, transient]]); + const firstProjection = reconcileTransientMessages(pending, []); + const updated = { + ...transient, + quotes: [{ text: 'quoted context' }], + }; + pending.set(updated.id, updated); + + const secondProjection = reconcileTransientMessages(pending, []); + + assert.deepEqual(firstProjection, [transient]); + assert.deepEqual(secondProjection, [updated]); + assert.equal(pending.has(updated.id), true); +}); + +test('replaces a transient message by canonical message id exactly once', () => { + const pending = new Map([[transient.id, transient]]); + const canonical = { ...transient, ts: 3, text: 'canonical send' }; + const projected = reconcileTransientMessages(pending, [canonical]); + + assert.deepEqual(projected, []); + assert.equal(pending.size, 0); +}); + +test('removes only messages with durable cancellation proof after reconnect', () => { + const accepted = { ...transient, id: 'message-accepted' }; + const handedOff = { ...transient, id: 'message-handed-off' }; + const cancelled = { ...transient, id: 'message-cancelled' }; + const unknown = { ...transient, id: 'message-unknown' }; + const pending = new Map( + [accepted, handedOff, cancelled, unknown].map((message) => [message.id, message]), + ); + + reconcileTransientMessageLifecycle(pending, [ + { messageId: accepted.id, status: 'accepted' }, + { messageId: handedOff.id, status: 'handed_off' }, + { messageId: cancelled.id, status: 'cancelled' }, + { messageId: unknown.id, status: 'unknown' }, + ]); + + assert.deepEqual([...pending.keys()], [accepted.id, handedOff.id, unknown.id]); +}); + +test('canonicalizing one send does not hide a later transient send', () => { + const second = { + ...transient, + id: 'message-2', + turnId: 'message-2', + ts: 4, + text: 'send next', + }; + const pending = new Map([ + [transient.id, transient], + [second.id, second], + ]); + const canonical = { ...transient, ts: 3, text: 'canonical send' }; + + const projected = reconcileTransientMessages(pending, [canonical]); + + assert.deepEqual(projected, [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', turnId: 'message-live', text: 'latest prompt' }; + const old = { ...transient, id: 'message-old', turnId: 'turn-old', ts: 1, text: 'old prompt' }; + const pending = new Map([[live.id, live]]); + const historical = [old]; + + const projected = reconcileTransientMessages(pending, historical, { + includeTransient: false, + }); + + assert.deepEqual(projected, []); + assert.equal(pending.has('message-live'), true); +}); + +test('uses the Host queue snapshot order for already-present transient messages', () => { + const localSecond = { + ...transient, + id: 'message-2', + turnId: 'message-2', + text: 'second', + }; + const remoteFirst = { + ...transient, + id: 'message-1', + turnId: 'message-1', + text: 'first', + }; + const pending = new Map([[localSecond.id, localSecond]]); + + projectQueuedTransientMessages(pending, [remoteFirst, localSecond]); + + assert.deepEqual( + reconcileTransientMessages(pending, []).map((message) => message.id), + ['message-1', 'message-2'], + ); +}); + +test('keeps a Host-bound current Turn when a later IPC result has no Turn identity', () => { + const hostBound = { + ...transient, + id: 'message-current', + turnId: 'host-turn', + transientPlacement: 'current_turn' as const, + }; + const lateIpcUpdate = { + ...hostBound, + turnId: hostBound.id, + text: 'uploaded content', + }; + + assert.deepEqual(mergeTransientMessageProjection(hostBound, lateIpcUpdate), { + ...lateIpcUpdate, + turnId: 'host-turn', + }); +}); diff --git a/apps/desktop/src/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 4aa110e773..2f40d14f07 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -26,6 +26,7 @@ import { } from '@maka/runtime-host/client'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { isSideConversationSession } from '@maka/core/side-conversation'; +import { parseSkillInvocationTokens } from '@maka/runtime/skill-invocation'; import { type SessionChangedEvent, type SessionChangedReason, @@ -96,6 +97,7 @@ type RuntimeHostSessionExecutionClient = Pick< | "interruptTurn" | 'listSessionTurns' | 'listSessionTurnLandmarks' + | 'queryMessages' | "queryTurnResume" | "readExecutionBoundary" | "regenerateTurn" @@ -190,6 +192,14 @@ export function registerRuntimeHostSessionExecutionIpc( const newId = deps.newId ?? randomUUID; const stopSession = createRuntimeHostSessionStop(deps, newId); + ipcMain.handle( + 'sessions:queryMessageStatuses', + async (_event, sessionId: string, messageIds: unknown) => { + if (!Array.isArray(messageIds)) throw new Error('Invalid Message identities'); + return deps.client.queryMessages({ sessionId, messageIds }); + }, + ); + handleReconnectableRead( ipcMain, "sessions:observe", @@ -263,7 +273,7 @@ export function registerRuntimeHostSessionExecutionIpc( if (!session) throw new Error(`Runtime Host Session not found: ${sessionId}`); const sideConversation = isSideConversationSession(session.labels); - const turnId = command.turnId ?? newId(); + const turnId = command.turnId ?? command.messageId ?? newId(); let attachments = retainedAttachmentsForSession( sessionId, command.retainedAttachments ?? [], @@ -321,6 +331,49 @@ export function registerRuntimeHostSessionExecutionIpc( ? { turnOrchestration: command.turnOrchestration } : {}), }; + if ( + command.messageId !== undefined && + !sideConversation && + (command.skillIds?.length ?? 0) === 0 && + parseSkillInvocationTokens(command.text).length === 0 && + command.turnOrchestration === undefined + ) { + const submitted = await submitMessageWithReconnect(deps.client, { + sessionId, + messageId: command.messageId, + content: startInput.content, + placement: 'current_turn', + }); + const skillInvocation = { loaded: [], failed: [], receipts: [] }; + if (!submitted) { + return { + ok: false as const, + reason: 'outcome_unknown' as const, + skillInvocation, + }; + } + if (submitted.disposition === 'turn_started') { + deps.emitSessionsChanged('status-change', sessionId, { + turnId: submitted.turnId, + }); + return { + ok: true as const, + disposition: submitted.disposition, + turnId: submitted.turnId, + attachments, + inlineReferences, + skillInvocation, + }; + } + deps.emitSessionsChanged('status-change', sessionId); + return { + ok: true as const, + disposition: submitted.disposition, + attachments, + inlineReferences, + skillInvocation, + }; + } let startResult; try { startResult = sideConversation @@ -443,7 +496,6 @@ export function registerRuntimeHostSessionExecutionIpc( const command = normalizeSessionSendCommand({ ...(value && typeof value === "object" ? value : {}), type: "send", - turnId: newId(), }); if (!command) throw new Error("Invalid queued message"); if ((command.skillIds?.length ?? 0) > 0 || command.turnOrchestration) { @@ -487,9 +539,10 @@ export function registerRuntimeHostSessionExecutionIpc( displayText, workspaceFileReferences: command.workspaceFileReferences, }); - const result = await deps.client.submitMessage({ + const messageId = command.messageId ?? newId(); + const result = await submitMessageWithReconnect(deps.client, { sessionId, - messageId: newId(), + messageId, placement, content: { text: command.text, @@ -501,6 +554,7 @@ export function registerRuntimeHostSessionExecutionIpc( inlineReferences, }, }); + if (!result) return { kind: 'outcome_unknown' as const }; if (result.disposition === "turn_started") { deps.emitSessionsChanged("status-change", sessionId, { turnId: result.turnId, @@ -868,7 +922,7 @@ function createRuntimeHostSessionStop( } return; } - await deps.client.interruptTurn({ + const interrupted = await deps.client.interruptTurn({ sessionId, interruptId: newId(), turnId: turn.turnId, @@ -877,6 +931,10 @@ function createRuntimeHostSessionStop( deps.emitSessionsChanged("turn-status-change", sessionId, { turnId: turn.turnId, }); + return { + kind: 'interrupted', + retractedMessageIds: interrupted.retracted.map((message) => message.messageId), + }; }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f2aa6d8817..24e779bd77 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 & { @@ -783,24 +784,58 @@ export interface MakaBridge { completeHostIds: string[]; }>; create(input?: CreateSessionRequestInput): Promise; + submitMessage( + sessionId: string, + command: { + type: 'send'; + messageId: string; + text: string; + displayText?: string; + skillIds?: string[]; + attachmentItems?: RendererIngestInput[]; + retainedAttachments?: import('@maka/core/events').AttachmentRef[]; + turnOrchestration?: never; + quotes?: import('@maka/core/events').QuoteRef[]; + workspaceFileReferences?: Array< + Pick + >; + }, + ): Promise< + | { + ok: true; + disposition: 'turn_started' | 'steering' | 'followup'; + turnId?: string; + attachments: import('@maka/core/events').AttachmentRef[]; + inlineReferences: import('@maka/core/events').InlineReference[]; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } + | { + ok: false; + reason: 'skill_invocation_failed'; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } + | { + ok: false; + reason: 'outcome_unknown'; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } + >; send( sessionId: string, - command: - | 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; @@ -858,6 +893,7 @@ export interface MakaBridge { sessionId: string, placement: 'current_turn' | 'next_turn', command: { + messageId: string; text: string; displayText?: string; attachmentItems?: RendererIngestInput[]; @@ -867,12 +903,19 @@ export interface MakaBridge { Pick >; }, - ): Promise<{ - kind: 'queued' | 'started'; - turnId?: string; - attachments: import('@maka/core/events').AttachmentRef[]; - inlineReferences: import('@maka/core/events').InlineReference[]; - }>; + ): Promise< + | { + kind: 'queued' | 'started'; + turnId?: string; + attachments: import('@maka/core/events').AttachmentRef[]; + inlineReferences: import('@maka/core/events').InlineReference[]; + } + | { kind: 'outcome_unknown' } + >; + queryMessageStatuses( + sessionId: string, + messageIds: readonly string[], + ): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; updateQueueEntry( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1671c2da6f..025df2e2a5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -850,6 +850,47 @@ async function createDesktopSessionOnScope( return projectSessionSummary(scope, session); } +function sendDesktopSessionCommand( + sessionId: string, + command: Parameters[1], +): ReturnType; +function sendDesktopSessionCommand( + sessionId: string, + command: Parameters[1], +): ReturnType; +async function sendDesktopSessionCommand( + sessionId: string, + command: + | Parameters[1] + | Parameters[1], +): Promise< + | Awaited> + | Awaited> +> { + const session = await runtimeHostSessionRef(sessionId); + const send = async (input: SessionCommand | Record) => { + const result = (await ipcRenderer.invoke( + 'sessions:send', + session.scope, + session.sessionId, + input, + )) as + | Awaited> + | Awaited>; + return result.ok + ? { + ...result, + attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), + } + : result; + }; + if (command.type === 'send' && 'attachmentItems' in command && command.attachmentItems) { + const encoded = await encodeIngestItems(command.attachmentItems as RendererIngestInput[]); + return send({ ...command, attachmentItems: encoded }); + } + return send(command); +} + function sendActiveRuntimeHost(channel: string, ...args: unknown[]): void { void activeRuntimeHostRef() .then((scope) => ipcRenderer.send(channel, scope, ...args)) @@ -1585,65 +1626,11 @@ 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; - } - > { - const session = await runtimeHostSessionRef(sessionId); - const send = async (input: SessionCommand | Record) => { - const result = await ipcRenderer.invoke( - 'sessions:send', - session.scope, - session.sessionId, - input, - ) as Awaited>; - return result.ok - ? { - ...result, - attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), - } - : result; - }; - if (command.type === 'send' && 'attachmentItems' in command && command.attachmentItems) { - const encoded = await encodeIngestItems(command.attachmentItems as RendererIngestInput[]); - return send({ - ...command, - attachmentItems: encoded, - }); - } - return send(command); + submitMessage(sessionId, command) { + return sendDesktopSessionCommand(sessionId, command); + }, + send(sessionId, command) { + return sendDesktopSessionCommand(sessionId, command); }, compact(sessionId: string): Promise> { return invokeSessionRuntimeHost('sessions:compact', sessionId); @@ -1679,6 +1666,7 @@ const makaBridge = { sessionId: string, placement: 'current_turn' | 'next_turn', command: { + messageId: string; text: string; displayText?: string; attachmentItems?: RendererIngestInput[]; @@ -1686,12 +1674,15 @@ const makaBridge = { quotes?: QuoteRef[]; workspaceFileReferences?: Array>; }, - ): Promise<{ - kind: 'queued' | 'started'; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - }> { + ): Promise< + | { + kind: 'queued' | 'started'; + turnId?: string; + attachments: AttachmentRef[]; + inlineReferences: InlineReference[]; + } + | { kind: 'outcome_unknown' } + > { const session = await runtimeHostSessionRef(sessionId); const attachmentItems = command.attachmentItems ? await encodeIngestItems(command.attachmentItems) @@ -1705,17 +1696,30 @@ const makaBridge = { ...command, ...(attachmentItems ? { attachmentItems } : {}), }, - ) as { - kind: 'queued' | 'started'; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - }; + ) as + | { + kind: 'queued' | 'started'; + turnId?: string; + attachments: AttachmentRef[]; + inlineReferences: InlineReference[]; + } + | { kind: 'outcome_unknown' }; + if (result.kind === 'outcome_unknown') return result; return { ...result, attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), }; }, + queryMessageStatuses( + sessionId: string, + messageIds: readonly string[], + ): Promise { + return invokeSessionRuntimeHost( + 'sessions:queryMessageStatuses', + sessionId, + messageIds, + ); + }, retractQueueEntry(sessionId: string, entryId: string): Promise { return invokeSessionRuntimeHost('sessions:retractQueueEntry', sessionId, entryId); }, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 3c74ddfd47..a691a80c95 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -36,6 +36,7 @@ import { type InteractionQueues, type LiveTurnProjection, type NavSelection, + type TransientUserMessageProjection, } from '@maka/ui'; import { messageRefreshErrorMessage } from './app-shell-copy.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -112,6 +113,16 @@ export interface AppShellChatActions { onSessionResolved?: (sessionId: string) => void; }, ): Promise; + enqueueMessage( + sessionId: string, + text: string, + placement: 'current_turn' | 'next_turn', + pending?: readonly PendingAttachment[], + options?: { + quotes?: readonly QuoteRef[]; + workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; + }, + ): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; @@ -144,6 +155,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 +212,9 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, + addTransientMessage, + updateTransientMessage, + removeTransientMessage, transcriptRangeRef, setNavSelection, setLiveTurnBySession, @@ -211,74 +234,74 @@ export function createAppShellChatActions(deps: { const copy = getShellCopy(uiLocale).chatActions; function optimisticUserMessage( + messageId: string, turnId: string, text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], quotes: readonly QuoteRef[] = [], inlineReferences: readonly InlineReference[] = [], - ): StoredMessage { + transientPlacement?: TransientUserMessageProjection['transientPlacement'], + ): TransientUserMessageProjection { return { type: 'user', - id: `optimistic-user-${turnId}`, + id: messageId, + // StoredMessage requires a grouping key, but transient messages are + // rendered beside the Turn projection. Canonical transcript data later + // supplies the Host-owned grouping for this same message id. turnId, ts: Date.now(), text, ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), inlineReferences: [...inlineReferences], + ...(transientPlacement ? { transientPlacement } : {}), }; } function showOptimisticUserMessage( sessionId: string, - turnId: string, + messageId: string, text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], options: { - replaceCurrentMessages?: boolean; + turnId?: string; + updateOnly?: boolean; + transientPlacement?: TransientUserMessageProjection['transientPlacement']; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; } = {}, ): void { + const next = optimisticUserMessage( + messageId, + options.turnId ?? messageId, + text, + attachments, + options.quotes, + options.inlineReferences, + options.transientPlacement, + ); + if (options.updateOnly) { + (updateTransientMessage ?? addTransientMessage)(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]; @@ -296,40 +319,6 @@ export function createAppShellChatActions(deps: { }); } - // Rename only the exact unconfirmed arm this send created. Host events can - // beat the IPC response (main emits the sessions-changed nudge before it - // returns), and an authoritative projection that already arrived for the - // Host-chosen turn must not be replaced with a fresh waiting arm. - function rebindTurnActive(sessionId: string, fromTurnId: string, toTurnId: string): void { - setLiveTurnBySession((current) => { - const active = current[sessionId]; - if (!active || active.turnId !== fromTurnId || !active.unconfirmed || active.phase !== 'waiting') { - return current; - } - return { ...current, [sessionId]: armLiveTurn(toTurnId) }; - }); - } - - // One interpretation of a successful sessions:send for both the new-chat and - // existing-session branches: a busy-raced send can come back `steered` (this - // send owns no turn — the steering_message event renders the text) or under - // a Host-chosen turnId. Returns the turn the send owns, if any. - function settleSendBookkeeping( - sessionId: string, - requestedTurnId: string, - sendResult: { steered?: true; turnId?: string }, - ): string | undefined { - if (sendResult.steered) { - disarmTurnActive(sessionId, requestedTurnId); - return undefined; - } - const startedTurnId = sendResult.turnId ?? requestedTurnId; - if (startedTurnId !== requestedTurnId) { - rebindTurnActive(sessionId, requestedTurnId, startedTurnId); - } - return startedTurnId; - } - async function send( text: string, pending?: readonly PendingAttachment[], @@ -342,6 +331,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 +345,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 +367,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 +389,19 @@ export function createAppShellChatActions(deps: { // draft's. A failed create leaves it in place so a retry keeps it. if (newChatPermissionChoice) clearNewChatPermissionChoice(); optimisticSessionId = session.id; - optimisticTurnId = turnId; - armTurnActive(session.id, turnId); + optimisticMessageId = messageId; + showOptimisticUserMessage( + session.id, + messageId, + options.displayText ?? text, + [], + { + transientPlacement: 'turn_source', + ...(quotes && quotes.length > 0 ? { quotes } : {}), + inlineReferences: [], + }, + ); + if (exactTurn) armTurnActive(session.id, messageId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -409,12 +410,10 @@ 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 = { + type: 'send' as const, text, ...(options.displayText ? { displayText: options.displayText } : {}), - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } @@ -423,8 +422,29 @@ export function createAppShellChatActions(deps: { ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), - }); + }; + const sendResult = options.turnOrchestration + ? await window.maka.sessions.send(session.id, { + ...sendCommand, + turnId: messageId, + turnOrchestration: options.turnOrchestration, + }) + : await window.maka.sessions.submitMessage(session.id, { + ...sendCommand, + messageId, + }); if (!sendResult.ok) { + if (sendResult.reason === 'outcome_unknown') { + unsentSessionId = undefined; + options.onSessionResolved?.(session.id); + if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { + setNavSelection({ section: 'sessions' }); + setActiveId(session.id); + } + await refreshSessions(); + return true; + } + removeOptimisticUserMessage(session.id, messageId); if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { showSkillInvocationFeedback( uiLocale, @@ -433,13 +453,11 @@ export function createAppShellChatActions(deps: { session.id, ); } - disarmTurnActive(session.id, turnId); + if (exactTurn) disarmTurnActive(session.id, messageId); await discardUnsentSession(); return false; } unsentSessionId = undefined; - const settledTurnId = settleSendBookkeeping(session.id, turnId, sendResult); - if (settledTurnId !== undefined) optimisticTurnId = settledTurnId; options.onSessionResolved?.(session.id); if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { showSkillInvocationFeedback( @@ -452,20 +470,20 @@ export function createAppShellChatActions(deps: { if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { setNavSelection({ section: 'sessions' }); setActiveId(session.id); - if (settledTurnId !== undefined) { - showOptimisticUserMessage( - session.id, - settledTurnId, - options.displayText ?? - skillInvocationDisplayText(text, sendResult.skillInvocation), - sendResult.attachments, - { - replaceCurrentMessages: true, - ...(quotes && quotes.length > 0 ? { quotes } : {}), - inlineReferences: sendResult.inlineReferences ?? [], - }, - ); - } + showOptimisticUserMessage( + session.id, + messageId, + options.displayText ?? + skillInvocationDisplayText(text, sendResult.skillInvocation), + sendResult.attachments, + { + turnId: sendResult.turnId ?? messageId, + updateOnly: true, + transientPlacement: 'turn_source', + ...(quotes && quotes.length > 0 ? { quotes } : {}), + inlineReferences: sendResult.inlineReferences ?? [], + }, + ); } await refreshSessions(); return true; @@ -489,8 +507,19 @@ export function createAppShellChatActions(deps: { } } optimisticSessionId = sessionId; - optimisticTurnId = turnId; - armTurnActive(sessionId, turnId); + optimisticMessageId = messageId; + showOptimisticUserMessage( + sessionId, + messageId, + options.displayText ?? text, + [], + { + transientPlacement: 'turn_source', + ...(quotes && quotes.length > 0 ? { quotes } : {}), + inlineReferences: [], + }, + ); + if (exactTurn) armTurnActive(sessionId, messageId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -499,12 +528,10 @@ export function createAppShellChatActions(deps: { pending && pending.length > 0 ? retainedAttachmentRefs(pending) : undefined; - const sendResult = await window.maka.sessions.send(sessionId, { - type: 'send', - turnId, + const sendCommand = { + type: 'send' as const, text, ...(options.displayText ? { displayText: options.displayText } : {}), - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } @@ -513,8 +540,20 @@ export function createAppShellChatActions(deps: { ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 ? { workspaceFileReferences: [...options.workspaceFileReferences] } : {}), - }); + }; + const sendResult = options.turnOrchestration + ? await window.maka.sessions.send(sessionId, { + ...sendCommand, + turnId: messageId, + turnOrchestration: options.turnOrchestration, + }) + : await window.maka.sessions.submitMessage(sessionId, { + ...sendCommand, + messageId, + }); if (!sendResult.ok) { + if (sendResult.reason === 'outcome_unknown') return true; + removeOptimisticUserMessage(sessionId, messageId); if (activeIdRef.current === sessionId) { showSkillInvocationFeedback( uiLocale, @@ -523,13 +562,10 @@ export function createAppShellChatActions(deps: { sessionId, ); } - disarmTurnActive(sessionId, turnId); + if (exactTurn) disarmTurnActive(sessionId, messageId); return false; } - const startedTurnId = settleSendBookkeeping(sessionId, turnId, sendResult); options.onSessionResolved?.(sessionId); - if (startedTurnId === undefined) return true; - optimisticTurnId = startedTurnId; if (activeIdRef.current === sessionId) { showSkillInvocationFeedback( uiLocale, @@ -540,11 +576,14 @@ export function createAppShellChatActions(deps: { } showOptimisticUserMessage( sessionId, - startedTurnId, + messageId, options.displayText ?? skillInvocationDisplayText(text, sendResult.skillInvocation), sendResult.attachments, { + turnId: sendResult.turnId ?? messageId, + updateOnly: true, + transientPlacement: 'turn_source', ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: sendResult.inlineReferences ?? [], }, @@ -552,14 +591,16 @@ export function createAppShellChatActions(deps: { return true; } catch (error) { await discardUnsentSession(); - if (optimisticSessionId && optimisticTurnId) { - removeOptimisticUserMessage(optimisticSessionId, optimisticTurnId); + if (optimisticSessionId && optimisticMessageId) { + removeOptimisticUserMessage(optimisticSessionId, optimisticMessageId); } // The turn never reached the runtime — close the model-wait window so the // "正在处理…" indicator doesn't hang after a failed send. Nothing else has // to be undone: the arm was the only claim the send made, and no // subscribeChanges event would reconcile a turn that never started. - if (optimisticSessionId && optimisticTurnId) disarmTurnActive(optimisticSessionId, optimisticTurnId); + if (exactTurn && optimisticSessionId && optimisticMessageId) { + disarmTurnActive(optimisticSessionId, optimisticMessageId); + } // Which surface is allowed to hear about this failure. The id alone is // not it: `selectNavigation` never clears `activeId` (nav-selection.ts), // so a user who left for 扩展 → 技能 mid-flight still "is" session A by @@ -607,6 +648,49 @@ export function createAppShellChatActions(deps: { } } + async function enqueueMessage( + sessionId: string, + text: string, + placement: 'current_turn' | 'next_turn', + pending?: readonly PendingAttachment[], + options: { + quotes?: readonly QuoteRef[]; + workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; + } = {}, + ): Promise { + const messageId = crypto.randomUUID(); + const quotes = options.quotes ?? []; + showOptimisticUserMessage(sessionId, messageId, text, retainedAttachmentRefs(pending ?? []), { + transientPlacement: placement, + ...(quotes.length > 0 ? { quotes } : {}), + inlineReferences: [], + }); + try { + const attachmentItems = pending?.length ? toComposerIngestItems(pending) : []; + const retainedAttachments = pending?.length ? retainedAttachmentRefs(pending) : []; + const result = await window.maka.sessions.enqueue(sessionId, placement, { + messageId, + text, + ...(attachmentItems.length > 0 ? { attachmentItems } : {}), + ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}), + ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), + ...(options.workspaceFileReferences?.length + ? { workspaceFileReferences: [...options.workspaceFileReferences] } + : {}), + }); + if (result.kind === 'outcome_unknown') return; + showOptimisticUserMessage(sessionId, messageId, text, result.attachments, { + updateOnly: true, + transientPlacement: placement, + ...(quotes.length > 0 ? { quotes } : {}), + inlineReferences: result.inlineReferences, + }); + } catch (error) { + removeOptimisticUserMessage(sessionId, messageId); + throw error; + } + } + async function respondToSandboxBoundary(response: SandboxBoundaryResponse) { const sessionId = activeIdRef.current; if (!sessionId) return; @@ -723,6 +807,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..1b09961a02 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -30,6 +30,7 @@ import { settleLiveTurnStep, type LiveTurnProjection, type InteractionQueues, + type TransientUserMessageProjection, } from '@maka/ui'; import type { RefreshMessagesOptions } from './app-shell-chat-actions.js'; import type { MessageQueueUiState } from './app-shell-session-ui-state.js'; @@ -84,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,24 @@ export function createAppShellSessionEventHandlers(options: { switch (event.type) { case 'queue_update': + projectQueuedTransientMessages?.( + sessionId, + [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])] + .filter((entry) => entry.state === 'queued') + .map((entry) => ({ + type: 'user', + id: entry.messageId, + turnId: entry.placement === 'current_turn' ? event.turnId : entry.messageId, + transientPlacement: entry.placement, + ts: event.ts, + text: entry.content.displayText ?? entry.content.text, + ...(entry.content.attachments ? { attachments: [...entry.content.attachments] } : {}), + ...(entry.content.quotes ? { quotes: [...entry.content.quotes] } : {}), + ...(entry.content.inlineReferences + ? { inlineReferences: [...entry.content.inlineReferences] } + : {}), + })), + ); setMessageQueueBySession?.((current) => { if (event.steering.length === 0 && event.followup.length === 0) { if (!(sessionId in current)) return current; @@ -302,6 +328,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..8ee255d3bc 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, + reconcileTransientMessageStatuses, + 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,16 +1875,13 @@ function AppShellContent({ ): Promise { const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; - const attachmentItems = pending ? toComposerIngestItems(pending) : []; - const retainedAttachments = pending ? retainedAttachmentRefs(pending) : []; try { - const result = await window.maka.sessions.enqueue( + await enqueueMessage( sessionId, + text, mode === 'steer' ? 'current_turn' : 'next_turn', + pending, { - text, - ...(attachmentItems.length > 0 ? { attachmentItems } : {}), - ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}), ...(quotes ? { quotes: [...quotes] } : {}), ...(metadata?.workspaceFileReferences?.length ? { workspaceFileReferences: [...metadata.workspaceFileReferences] } @@ -1886,10 +1890,6 @@ function AppShellContent({ ); if (pending) clearSubmittedAttachments(pending); if (quotes) clearQuotes(); - if (result.kind === 'started') { - await refreshMessages(sessionId); - await refreshSessions(); - } return true; } catch (error) { if (activeIdRef.current === sessionId) { @@ -2140,18 +2140,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 +2189,7 @@ function AppShellContent({ clearPendingSessionAction, setStopPendingBySession, stopPendingRef, + removeTransientMessage, toastApi, }); @@ -2204,6 +2210,8 @@ function AppShellContent({ setLiveTurnBySession, setInteractionBySession, setMessageQueueBySession, + projectQueuedTransientMessages, + removeTransientMessage, displayBatch: sessionDisplayBatch, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -2297,6 +2305,7 @@ function AppShellContent({ const next = completeLiveContentSeed(current, sessionId, expected); activeEventSeedRef.current = next; setActiveEventSeed(next); + void reconcileTransientMessageStatuses(sessionId); }; useActiveSessionEvents({ uiLocale, @@ -2876,7 +2885,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 +3027,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..2c3e36db07 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -120,15 +120,17 @@ export function createDesktopWorkbarServices( abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), send: (sessionId, command) => bridge.sessions.send(sessionId, command), - stop: (sessionId, target) => - bridge.sessions.stop( + stop: async (sessionId, target) => { + const result = await bridge.sessions.stop( sessionId, target?.kind === 'admission' ? { source: 'stop_button', expectedAdmissionId: target.messageId } : target?.kind === 'turn' ? { source: 'stop_button', expectedTurnId: target.turnId } : undefined, - ), + ); + return result?.kind === 'retracted' ? result : undefined; + }, steer: (sessionId, text, admissionId) => bridge.sessions.steer(sessionId, text, admissionId), setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/transient-message-projection.ts new file mode 100644 index 0000000000..7c89f01367 --- /dev/null +++ b/apps/desktop/src/renderer/transient-message-projection.ts @@ -0,0 +1,78 @@ +/* + * 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 { MessageLifecycleStatus } from '@maka/runtime-host/protocol'; +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); +} + +export function mergeTransientMessageProjection( + current: TransientUserMessage, + update: TransientUserMessage, +): TransientUserMessage { + const hostBoundCurrentTurn = + current.transientPlacement === 'current_turn' + && update.transientPlacement === 'current_turn' + && current.turnId !== current.id + && update.turnId === update.id; + return hostBoundCurrentTurn ? { ...update, turnId: current.turnId } : update; +} + +export function reconcileTransientMessageLifecycle( + transient: Map, + messages: readonly { messageId: string; status: MessageLifecycleStatus }[], +): void { + for (const message of messages) { + if (message.status === 'cancelled') transient.delete(message.messageId); + } +} + +/** + * 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..06223d38f0 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -19,6 +19,7 @@ import { useRef, useState } from 'react'; import type { StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from '@maka/ui'; import { useAppShellSessionUiState } from './app-shell-session-ui-state'; import { useAppShellSessionList } from './use-app-shell-session-list'; import { createBootstrapSelectionLease } from './bootstrap-selection-lease'; @@ -28,11 +29,23 @@ import { markNewTaskReloadIntent, } from './new-task-reload-intent'; import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; +import { + mergeTransientMessageProjection, + projectQueuedTransientMessages as applyQueuedTransientProjection, + reconcileTransientMessageLifecycle, + 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,111 @@ 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 reconcileTransientMessageStatuses(sessionId: string): Promise { + const pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending || pending.size === 0) return; + try { + const result = await window.maka.sessions.queryMessageStatuses( + sessionId, + [...pending.keys()], + ); + const current = transientMessagesBySessionRef.current.get(sessionId); + if (!current) return; + reconcileTransientMessageLifecycle(current, result.messages); + if (current.size === 0) transientMessagesBySessionRef.current.delete(sessionId); + if (activeIdRef.current === sessionId) { + setTransientMessages(projectTransientMessages(sessionId, messagesRef.current)); + } + } catch { + // A failed proof query leaves presentation intact until canonical proof arrives. + } + } + + function removeTransientMessage(sessionId: string, messageId: string): void { + const pending = transientMessagesBySessionRef.current.get(sessionId); + if (!pending?.delete(messageId)) return; + 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 +170,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 +192,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 +214,13 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { startNewSession, clearOwnedSessionState, messages, - setMessages, + transientMessages, + setMessages: setMessagesForActiveSession, + addTransientMessage, + updateTransientMessage, + projectQueuedTransientMessages, + reconcileTransientMessageStatuses, + 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..4874d76546 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -37,6 +37,7 @@ import { refreshRunningShellRunElapsed, hydrateToolsWithStoredMessages, makaPiToolPresentationStatus, + reconcileTransientMessageLifecycle, replaceTranscriptWithStoredMessages, submitCompactToTranscript, toggleAllThinkingExpansion, @@ -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,211 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('preserves a transient user row across a sparse transcript replacement', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'send now', 'message-1', true); + + replaceTranscriptWithStoredMessages(state, [], { preserveTransientMessages: true }); + + assert.deepEqual(state.entries, [ + { kind: 'user', messageId: 'message-1', text: 'send now', transient: true }, + ]); + }); + + test('removes only transient rows with durable cancellation proof', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'accepted', 'message-accepted', true); + appendUserPrompt(state, 'handed off', 'message-handed-off', true); + appendUserPrompt(state, 'cancelled', 'message-cancelled', true); + + reconcileTransientMessageLifecycle(state, [ + { messageId: 'message-accepted', status: 'accepted' }, + { messageId: 'message-handed-off', status: 'handed_off' }, + { messageId: 'message-cancelled', status: 'cancelled' }, + ]); + + assert.deepEqual( + state.entries.map((entry) => ('messageId' in entry ? entry.messageId : undefined)), + ['message-accepted', 'message-handed-off'], + ); + }); + + test('keeps a transient user row before later durable output in a sparse replacement', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + ]); + appendUserPrompt(state, 'send now', 'message-1', true); + state.entries.push({ kind: 'assistant', messageId: 'later-assistant', text: 'after' }); + + replaceTranscriptWithStoredMessages( + state, + [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + { + type: 'assistant', + id: 'later-assistant', + turnId: 'turn-1', + ts: 3, + text: 'after', + modelId: 'model-1', + }, + ], + { preserveTransientMessages: true }, + ); + + assert.deepEqual( + state.entries.map((entry) => + entry.kind === 'user' || entry.kind === 'assistant' ? entry.messageId : entry.kind, + ), + ['old-user', 'message-1', 'later-assistant'], + ); + }); + + test('keeps an unanchored transient user row after existing durable history', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + { + type: 'assistant', + id: 'old-assistant', + turnId: 'old-turn', + ts: 2, + text: 'answer', + modelId: 'model-1', + }, + ]); + appendUserPrompt(state, 'send now', 'message-1', true); + + replaceTranscriptWithStoredMessages( + state, + [ + { type: 'user', id: 'old-user', turnId: 'old-turn', ts: 1, text: 'before' }, + { + type: 'assistant', + id: 'old-assistant', + turnId: 'old-turn', + ts: 2, + text: 'answer', + modelId: 'model-1', + }, + ], + { preserveTransientMessages: true }, + ); + + assert.deepEqual( + state.entries.map((entry) => + entry.kind === 'user' || entry.kind === 'assistant' ? entry.messageId : entry.kind, + ), + ['old-user', 'old-assistant', 'message-1'], + ); + }); + + test('keeps a leading transient row before an entirely new durable replacement', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'current prompt', 'message-current', true); + state.entries.push({ kind: 'assistant', messageId: 'old-assistant', text: 'old live output' }); + + replaceTranscriptWithStoredMessages( + state, + [ + { type: 'user', id: 'next-user', turnId: 'next-turn', ts: 3, text: 'next prompt' }, + { + type: 'assistant', + id: 'next-assistant', + turnId: 'next-turn', + ts: 4, + text: 'next answer', + modelId: 'model-1', + }, + ], + { preserveTransientMessages: true }, + ); + + assert.deepEqual( + state.entries.map((entry) => + entry.kind === 'user' || entry.kind === 'assistant' ? entry.messageId : entry.kind, + ), + ['message-current', 'next-user', 'next-assistant'], + ); + }); + + test('reconciles a transient user row by messageId when durable history arrives', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'send now', 'message-1', true); + + replaceTranscriptWithStoredMessages( + state, + [{ type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'send now' }], + { preserveTransientMessages: true }, + ); + + assert.deepEqual(state.entries, [{ kind: 'user', messageId: 'message-1', text: 'send now' }]); + }); + + test('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 +579,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 +610,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 +1067,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 dd378337aa..2bd270dd58 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -29,11 +29,7 @@ import { visibleWidth } from '@earendil-works/pi-tui'; import { SHELL_RUN_UPDATE_BUFFER_MAX_ENTRIES } from '@maka/core/shell-run-result'; import { type PermissionMode } from '@maka/core/permission'; import { type OrchestrationMode } from '@maka/core/orchestration'; -import { - type QueueEnqueueOutcome, - type SessionEvent, - type ShellRunUpdate, -} from '@maka/core/events'; +import { type SessionEvent, type ShellRunUpdate } from '@maka/core/events'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import { type SessionSummary, type StoredMessage } from '@maka/core/session'; import { type ThinkingLevel } from '@maka/core/model-thinking'; @@ -52,6 +48,7 @@ import type { MakaSessionRewindResult, MakaSessionSwitchOptions, MakaSessionSwitchResult, + MakaSubmitMessageOptions, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; @@ -1971,6 +1968,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(); @@ -2104,6 +2168,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 @@ -2221,11 +2322,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 @@ -2275,133 +2382,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 () => { + test('Enter during Host admission keeps the second prompt in the editor', 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 () => { - 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, @@ -2412,57 +2397,21 @@ 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); - - driver.endTurn(); - driver.releaseRetry(); - await waitFor(() => terminal.progressStates.at(-1) === false); - assert.deepEqual(driver.prompts, ['start']); - assert.deepEqual(driver.delivered, ['lands on retry']); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('first prompt')); - terminal.input('/exit'); + 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) === 'second promptz'); - 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 + terminal.input('\x1b'); 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('\x03'); terminal.input('/exit'); terminal.input('\r'); await run; @@ -2514,49 +2463,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(); @@ -2687,6 +2593,31 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('removes a one-shot Swarm transient when turn admission fails', async () => { + const terminal = new FakeTerminal(); + const driver = new FailingOrchestrationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'deepseek-v4-flash', + connectionSlug: 'deepseek', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/swarm inspect the projection'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('admission failed')); + assert.equal( + plainTerminalOutput(terminal.screenOutput()).includes('inspect the projection'), + false, + ); + + exitMaka(terminal); + await run; + }); + test('inspects a historical Agent Graph run without starting a turn', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); @@ -6816,14 +6747,19 @@ class SteeringTurnDriver implements MakaSessionDriver { readonly steered: string[] = []; readonly queuedMessages: string[] = []; readonly turnOrchestrations: Array = []; + nextSubmitError: Error | undefined; + submitGate: Promise | undefined; + startedTurnMessages: StoredMessage[] = []; retractCalls = 0; rewindTargets: RewindTarget[] = []; - private steering: string[] = []; - private followup: string[] = []; + private steering: Array<{ messageId: string; text: string }> = []; + private followup: Array<{ messageId: string; text: string }> = []; private pendingEvents: SessionEvent[] = []; private wakeTurn: (() => void) | null = null; + private turnOpen = false; private turnEnded = false; private eventSeq = 0; + private startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; async listSessions(): Promise { return []; @@ -6858,14 +6794,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()!; @@ -6874,38 +6811,57 @@ 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) { + await this.submitGate; + this.submitGate = undefined; + if (this.nextSubmitError) { + const error = this.nextSubmitError; + this.nextSubmitError = undefined; + throw error; + } + if (!this.turnOpen) { + const turn = await this.preparePrompt(text); + queueMicrotask(() => + this.startedTurnListener?.({ + ...turn, + messages: this.startedTurnMessages, + summary: fakeSessionSummary(turn.sessionId), + }), + ); + return; + } + 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' }; } - 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 @@ -6944,214 +6900,9 @@ class SteeringTurnDriver implements MakaSessionDriver { } } -/** - * A driver whose enqueues hit the no-live-owner `fallback` outcome for the - * first N calls (configurable, default forever) while the turn parks until - * `endTurn()` — the begin-window shape behind review finding N2. - */ -class FallbackSteeringDriver implements MakaSessionDriver { - readonly prompts: string[] = []; - readonly steered: string[] = []; - readonly queuedMessages: string[] = []; - stopCalls = 0; - completedTurns = 0; - /** Enqueue calls that report `fallback` before the owner "appears". */ - steerFallbacks = Number.POSITIVE_INFINITY; - queueFallbacks = Number.POSITIVE_INFINITY; - /** Total enqueue attempts, including rejected ones — the observable retry count. */ - steerAttempts = 0; - queueAttempts = 0; - private steering: string[] = []; - private followup: string[] = []; - private pendingEvents: SessionEvent[] = []; - private wakeTurn: (() => void) | null = null; - private turnOpen = false; - private turnEnded = false; - private eventSeq = 0; - - get parked(): boolean { - return this.turnOpen && !this.turnEnded; - } - - async listSessions(): Promise { - return []; - } - - preparePrompt( - prompt: string, - options: MakaPreparePromptOptions = {}, - ): Promise { - this.prompts.push(options.modelText ?? prompt); - const turnId = options.turnId ?? `turn-${this.prompts.length}`; - return Promise.resolve({ - sessionId: this.getSessionId(), - turnId, - events: this.promptEvents(turnId), - }); - } - - async *compactSession(): AsyncIterable {} - - // Same single-path contract as the runtime: queue contents reach the CLI - // only through `queue_update` events on the turn stream. - private emitQueueUpdate(): void { - this.eventSeq += 1; - this.pendingEvents.push({ - type: 'queue_update', - id: `queue-update-${this.eventSeq}`, - turnId: `turn-${this.prompts.length}`, - ts: this.eventSeq, - steering: [...this.steering], - followup: [...this.followup], - }); - this.wakeTurn?.(); - this.wakeTurn = null; - } - - async *promptEvents(turnId: string): AsyncIterable { - this.turnOpen = true; - this.turnEnded = false; - for (;;) { - while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!; - if (this.turnEnded) break; - await new Promise((resolve) => { - this.wakeTurn = resolve; - }); - } - this.turnOpen = false; - if (this.abortNextTurn) { - this.abortNextTurn = false; - yield { - type: 'abort', - id: `abort-${this.prompts.length}`, - turnId, - ts: 1, - reason: 'user_stop', - }; - yield { - type: 'complete', - id: `complete-${this.prompts.length}`, - turnId, - ts: 2, - stopReason: 'user_stop', - }; - this.completedTurns += 1; - return; - } - yield { - type: 'complete', - id: `complete-${this.prompts.length}`, - turnId, - ts: 1, - stopReason: 'end_turn', - }; - this.completedTurns += 1; - } - - /** Next endTurn() finishes the turn as aborted instead of end_turn. */ - abortNextTurn = false; - - async steer(text: string): Promise { - this.steerAttempts += 1; - if (this.steerFallbacks > 0) { - this.steerFallbacks -= 1; - return { kind: 'fallback' }; - } - this.steered.push(text); - this.steering.push(text); - this.emitQueueUpdate(); - return { kind: 'queued' }; - } - - async queueMessage(text: string): Promise { - this.queueAttempts += 1; - if (this.queueFallbacks > 0) { - this.queueFallbacks -= 1; - return { kind: 'fallback' }; - } - this.queuedMessages.push(text); - this.followup.push(text); - this.emitQueueUpdate(); - return { kind: 'queued' }; - } - - async 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 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 FailingOrchestrationDriver extends SteeringTurnDriver { + override preparePrompt(): Promise { + return Promise.reject(new Error('admission failed')); } } 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..f3a61af77c 100644 --- a/packages/cli/src/__tests__/pi-tui-turn.test.ts +++ b/packages/cli/src/__tests__/pi-tui-turn.test.ts @@ -24,9 +24,46 @@ import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { runMakaPiTuiTurn } from '../pi-tui-turn.js'; describe('Maka Pi TUI turn', () => { + test('submits an ordinary message once and leaves Turn projection to the Host subscription', async () => { + const sequence: string[] = []; + const outcome = await runMakaPiTuiTurn({ + driver: { + async preparePrompt() { + throw new Error('ordinary admission must not start a renderer-owned Turn'); + }, + async submitMessage(prompt, options) { + sequence.push('submit'); + assert.equal(prompt, 'visible prompt'); + assert.deepEqual(options, { + messageId: 'message-1', + placement: 'current_turn', + modelText: 'expanded prompt', + }); + }, + }, + turnActivity: { activities: new SessionActivityRegistry() }, + request: { + kind: 'external', + prompt: 'visible prompt', + turnId: 'message-1', + sendText: 'expanded prompt', + sessionId: null, + }, + shouldAbort: () => false, + onStart: () => sequence.push('start'), + onPrepared: () => { + sequence.push('prepared'); + }, + }); + + assert.deepEqual(outcome, { kind: 'admitted' }); + assert.deepEqual(sequence, ['start', 'submit']); + }); + test('prepares and drains an external turn under one Session activity lease', async () => { const activities = new SessionActivityRegistry(); const sequence: string[] = []; + let startedTurnId: string | undefined; const outcome = await runMakaPiTuiTurn({ driver: { @@ -34,6 +71,7 @@ describe('Maka Pi TUI turn', () => { sequence.push('prepare'); assert.equal(prompt, 'visible prompt'); assert.deepEqual(options, { + turnId: 'turn-1', modelText: 'expanded prompt', turnOrchestration: { mode: 'swarm', source: 'slash_command' }, }); @@ -51,12 +89,14 @@ describe('Maka Pi TUI turn', () => { request: { kind: 'external', prompt: 'visible prompt', + turnId: 'turn-1', sendText: 'expanded prompt', sessionId: null, turnOrchestration: { mode: 'swarm', source: 'slash_command' }, }, shouldAbort: () => false, - onStart: () => { + onStart: (turnId) => { + startedTurnId = turnId; sequence.push('start'); }, onEvent: (sessionEvent) => { @@ -65,6 +105,7 @@ describe('Maka Pi TUI turn', () => { }); assert.deepEqual(outcome, { kind: 'completed', turnId: 'turn-1' }); + assert.equal(startedTurnId, 'turn-1'); assert.deepEqual(sequence, ['start', 'prepare', 'event:text_delta', 'event:complete']); assert.equal(activities.whenIdle('session-1'), undefined); }); @@ -80,7 +121,7 @@ describe('Maka Pi TUI turn', () => { }, }, turnActivity: { activities }, - request: { kind: 'external', prompt: 'hello', sessionId: null }, + request: { kind: 'external', prompt: 'hello', turnId: 'turn-1', sessionId: null }, shouldAbort: () => false, onFailure: (error) => { failures.push(errorMessage(error)); @@ -108,14 +149,14 @@ describe('Maka Pi TUI turn', () => { }, }, turnActivity: { activities }, - request: { kind: 'external', prompt: 'hello', sessionId: 'session-1' }, + request: { kind: 'external', prompt: 'hello', turnId: 'turn-1', sessionId: 'session-1' }, shouldAbort: () => false, onFailure: (error) => { failures.push(errorMessage(error)); }, }); - assert.deepEqual(outcome, { kind: 'errored', reason: 'prepare failed' }); + assert.deepEqual(outcome, { kind: 'errored', turnId: 'turn-1', reason: 'prepare failed' }); assert.deepEqual(failures, ['prepare failed']); assert.equal(activities.whenIdle('session-1'), undefined); }); diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index b163b6689f..9f8d85b44f 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, @@ -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,88 @@ 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('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()] } }), @@ -1688,6 +1780,7 @@ class FakeConnection { readonly goalControlOutcomes: Array = []; /** Scripted goal.query results, shifted per call; defaults to null (no goal). */ readonly goalQueryResults: Array = []; + readonly messageSubmitOutcomes: Array | Error> = []; readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -1792,7 +1885,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..4cd99926a2 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -60,7 +60,7 @@ import { goalStatusLineText, isLiveGoalStatus } from './pi-goal.js'; import { renderToolBlock } from './pi-transcript-tools.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; import { renderTuiShortcutCopy } from './tui-shortcut-copy.js'; -import type { GoalProjection } from '@maka/runtime-host/protocol'; +import type { GoalProjection, MessageLifecycleStatus } from '@maka/runtime-host/protocol'; export interface MakaPiUsageSummary { /** Cumulative cost in USD across the session. */ @@ -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,39 @@ 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 reconcileTransientMessageLifecycle( + state: MakaPiTranscriptState, + messages: readonly { messageId: string; status: MessageLifecycleStatus }[], +): void { + const cancelled = new Set( + messages.filter(({ status }) => status === 'cancelled').map(({ messageId }) => messageId), + ); + if (cancelled.size === 0) return; + state.entries = state.entries.filter( + (entry) => entry.kind !== 'user' || entry.transient !== true || !cancelled.has(entry.messageId), + ); } export function appendTurnFailureToTranscript(state: MakaPiTranscriptState, error: unknown): void { @@ -327,8 +349,64 @@ export function applyShellRunUpdateToTranscript( export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], + options: { preserveTransientMessages?: boolean } = {}, ): void { - state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + const durableMessageIds = new Set(messages.map((message) => message.id)); + const durableEntries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + const durableEntryIds = new Set(durableEntries.map(transcriptEntryId).filter(Boolean)); + const transientEntries = options.preserveTransientMessages + ? state.entries.flatMap((entry, index) => { + if ( + entry.kind !== 'user' || + entry.transient !== true || + durableMessageIds.has(entry.messageId) + ) { + return []; + } + const priorEntries = state.entries.slice(0, index); + const nextDurableId = state.entries + .slice(index + 1) + .map(transcriptEntryId) + .find((messageId) => messageId !== undefined && durableEntryIds.has(messageId)); + const previousDurableId = priorEntries + .map(transcriptEntryId) + .reverse() + .find((messageId) => messageId !== undefined && durableEntryIds.has(messageId)); + const hadPrecedingDurable = priorEntries.some( + (candidate) => + !(candidate.kind === 'user' && candidate.transient === true) && + transcriptEntryId(candidate) !== undefined, + ); + return [{ entry, nextDurableId, previousDurableId, hadPrecedingDurable }]; + }) + : []; + const transientEntriesByBoundary = new Map(); + for (const transient of transientEntries) { + const nextIndex = transient.nextDurableId + ? durableEntries.findIndex((entry) => transcriptEntryId(entry) === transient.nextDurableId) + : -1; + const previousIndex = transient.previousDurableId + ? durableEntries.findIndex( + (entry) => transcriptEntryId(entry) === transient.previousDurableId, + ) + : -1; + const boundary = + nextIndex >= 0 + ? nextIndex + : previousIndex >= 0 + ? previousIndex + 1 + : transient.hadPrecedingDurable + ? durableEntries.length + : 0; + const grouped = transientEntriesByBoundary.get(boundary); + if (grouped) grouped.push(transient.entry); + else transientEntriesByBoundary.set(boundary, [transient.entry]); + } + 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 +422,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 +800,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 +904,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 +1580,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 6a44cc4bb6..dfc722df52 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, @@ -60,7 +60,6 @@ import { type ForeignSessionSummary, } from '@maka/core/foreign-session'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; -import type { GoalTurnOutcome } from '@maka/runtime/goal-continuation'; import type { SessionActivityLease } from '@maka/runtime/goal-turn-lifecycle'; import { listApiKeyOnboardableProviders } from './onboarding-catalog.js'; import type { @@ -95,6 +94,7 @@ import { completePendingInteraction, applyShellRunViewUpdateToTranscript, permissionModeLabel, + reconcileTransientMessageLifecycle, replaceTranscriptWithStoredMessages, hydrateToolsWithStoredMessages, submitCompactToTranscript, @@ -102,7 +102,11 @@ import { toggleAllToolExpansion, type MakaPiTranscriptMetadata, } from './pi-transcript.js'; -import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; +import { + runMakaPiTuiTurn, + type MakaPiTuiTurnOutcome, + type MakaPiTuiTurnRequest, +} from './pi-tui-turn.js'; import { editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; import { TranscriptViewerOverlay } from './pi-tui-transcript-viewer.js'; @@ -284,9 +288,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const rememberTranscriptModel = (messages: readonly StoredMessage[]): void => { transcriptLastUsedModel = latestAssistantModelId(messages); }; - const replaceTranscript = (messages: readonly StoredMessage[]): void => { + const replaceTranscript = ( + messages: readonly StoredMessage[], + options: { preserveTransientMessages?: boolean } = {}, + ): void => { rememberTranscriptModel(messages); - replaceTranscriptWithStoredMessages(state, messages); + replaceTranscriptWithStoredMessages(state, messages, options); }; let cwd = input.cwd; let model = input.model; @@ -538,9 +545,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, { preserveTransientMessages: true }); shellRunElapsedTicker.sync(); requestRender(); + const messageIds = state.entries.flatMap((entry) => + entry.kind === 'user' && entry.transient === true ? [entry.messageId] : [], + ); + if (messageIds.length > 0) { + void input.driver + .queryMessageStatuses?.(messageIds) + .then((result) => { + if (closed || input.driver.getSessionId() !== sessionId) return; + reconcileTransientMessageLifecycle(state, result.messages); + requestRender(); + }) + .catch(() => undefined); + } return; } rememberTranscriptModel(messages); @@ -722,7 +742,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. @@ -841,9 +860,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) => { @@ -894,133 +912,50 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); }; - // Fallback handoff owner. A `fallback` outcome while the turn is running - // means the runtime has no live steering owner YET (the begin window) or - // just lost it; the runtime keeps no record of the text, so the CLI owns - // delivery: retry the SAME enqueue until the owner appears, and flush any - // remainder into the next turn at the turn boundary. Never a bounded wait — - // a normal turn outlives any fixed budget and the text must not vanish. - const FALLBACK_RETRY_MS = 100; - let fallbackRetryTimer: ReturnType | null = null; - let fallbackRetryInFlight = false; - let fallbackRetryTask: Promise | null = null; - let fallbackRetryGeneration = 0; - - const stopFallbackRetry = () => { - fallbackRetryGeneration += 1; - if (fallbackRetryTimer !== null) clearTimeout(fallbackRetryTimer); - fallbackRetryTimer = null; - }; - - const scheduleFallbackRetry = () => { - if (fallbackRetryTimer !== null || fallbackRetryInFlight) return; - fallbackRetryTimer = setTimeout(() => { - fallbackRetryTimer = null; - const task = retryPendingFallback(); - fallbackRetryTask = task; - void task.finally(() => { - if (fallbackRetryTask === task) fallbackRetryTask = null; - }); - }, FALLBACK_RETRY_MS); - }; - - const retryPendingFallback = async () => { - if (closed || !turnRunning || state.pendingFallback.length === 0) { - stopFallbackRetry(); - return; - } - const generation = fallbackRetryGeneration; - const attempted = [...state.pendingFallback]; - fallbackRetryInFlight = true; - const remaining: typeof state.pendingFallback = []; - let failed = false; - try { - for (const entry of attempted) { - const enqueue = entry.enqueue === 'steer' ? input.driver.steer : input.driver.queueMessage; - let outcome: QueueEnqueueOutcome | undefined; - try { - outcome = enqueue ? await enqueue.call(input.driver, entry.text) : undefined; - } catch (error) { - failed = true; - reportError(error); - } - if (outcome?.kind !== 'queued') remaining.push(entry); - } - } finally { - fallbackRetryInFlight = false; - } - if (generation !== fallbackRetryGeneration) return; - const attemptedEntries = new Set(attempted); - const appended = state.pendingFallback.filter((entry) => !attemptedEntries.has(entry)); - const changed = remaining.length !== attempted.length; - state.pendingFallback = [...remaining, ...appended]; - if (remaining.length === 0) stopFallbackRetry(); - else if (!failed) scheduleFallbackRetry(); - if (!changed) return; - // The queue mirror updates only from `queue_update` events (single path); - // this render just drops the delivered entries from the fallback list. - requestRender(); - }; - - const deferFallback = (text: string, enqueue: 'steer' | 'queue') => { - state.pendingFallback.push({ text, enqueue }); - scheduleFallbackRetry(); - requestRender(); - }; - - /** Drain the CLI-held fallback texts (delivery order), stopping the retry loop. */ - const takePendingFallbackEntries = (): Array<{ text: string; enqueue: 'steer' | 'queue' }> => { - stopFallbackRetry(); - const entries = state.pendingFallback; - state.pendingFallback = []; - return entries; + const 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; - } + const submitRunningMessage = (text: string, placement: 'current_turn' | 'next_turn') => { editor.addToHistory(text); - const enqueue = input.driver.steer; - if (!enqueue) { - deferFallback(text, 'steer'); + const submitMessage = input.driver.submitMessage; + if (!submitMessage) { + refillEditorFromQueues(text); return; } - const task = enqueue - .call(input.driver, text) - .then((outcome) => { - if (outcome.kind === 'fallback') { - if (turnRunning || busy) deferFallback(text, 'steer'); - else submitPrompt(text); - return; - } - // Queued: the runtime's `queue_update` event refreshes the mirror. - requestRender(); - }) + const messageId = randomUUID(); + appendUserPrompt(state, text, messageId, true); + requestRender(); + const task = submitMessage + .call(input.driver, text, { messageId, placement }) + .then(requestRender) .catch((error) => { + removeTransientUserMessage(messageId); refillEditorFromQueues(text); reportError(error); }); trackEnqueue(task); }; + // Enter during a turn asks the Host to place the message at the current + // step boundary. The Host alone decides whether it steers or starts a + // successor Turn if the previous Turn settled during admission. + const steerRunningTurn = (text: string) => { + if (!text.trim()) { + requestRender(); + return; + } + submitRunningMessage(text, 'current_turn'); + }; + // Alt+Enter: during a turn, queue the text to open the next turn; when idle, // it submits like Enter. const handleAltEnter = () => { @@ -1038,38 +973,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); + submitRunningMessage(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); }; @@ -1190,7 +1103,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { function runAgentTurn( request: MakaPiTuiTurnRequest, authoritativeAttachedTurn?: MakaAttachedSessionTurn, - ): Promise { + ): Promise { busy = true; const epoch = ++turnEpoch; // A mid-turn /session switch-away (#3380) bumps turnEpoch and orphans this @@ -1198,19 +1111,35 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // runner state — the adopted Session owns it now. const superseded = () => epoch !== turnEpoch; const activity = beginActivity(); - turnRunning = true; - turnStartedAt = Date.now(); - startTurnElapsedTicker(); - interruptRequested = false; - lastTurnEscapeAt = 0; - editor.disableSubmit = false; - setTaskbarProgress(true); - attention.promptTurnStarted(); + const ownsTurnUi = + request.kind === 'attached' || + request.turnOrchestration !== undefined || + input.driver.submitMessage === undefined; + if (ownsTurnUi) { + turnRunning = true; + turnStartedAt = Date.now(); + startTurnElapsedTicker(); + interruptRequested = false; + lastTurnEscapeAt = 0; + editor.disableSubmit = false; + setTaskbarProgress(true); + attention.promptTurnStarted(); + } else { + // The editor clears before invoking onSubmit. While Host admission is + // unresolved, disable submission so a second Enter cannot erase a draft + // that the busy gate would then refuse. + editor.disableSubmit = true; + } requestRender(); let permissionAlerted = false; let optimisticUserEntry: (typeof state.entries)[number] | undefined; + let turnPrepared = false; const finishTurnUi = () => { + if (!ownsTurnUi) { + editor.disableSubmit = false; + return; + } turnRunning = false; turnStartedAt = undefined; stopTurnElapsedTicker(); @@ -1231,9 +1160,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Cutting the iterator short here would make the UI appear idle before // the runtime has emitted its terminal event and accepted the stop. shouldAbort: () => closed, - onStart: () => { + onStart: (turnId) => { if (request.kind !== 'attached') { - appendUserPrompt(state, request.prompt); + if (!turnId) throw new Error('External TUI turn did not receive a stable identity'); + appendUserPrompt(state, request.prompt, turnId, true); optimisticUserEntry = state.entries.at(-1); } requestRender(); @@ -1243,9 +1173,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // switch resolved (preparePrompt was in flight), and the abandoned // Turn's metadata must not overwrite the adopted Session's view. if (superseded()) return; + turnPrepared = true; if (authoritativeAttachedTurn) { adoptSessionMetadata(authoritativeAttachedTurn.summary); - replaceTranscript(authoritativeAttachedTurn.messages); + replaceTranscript(authoritativeAttachedTurn.messages, { + preserveTransientMessages: true, + }); shellRunHydration.reset(); if (input.listShellRunUpdates) { await shellRunHydration.hydrate(authoritativeAttachedTurn.sessionId); @@ -1313,6 +1246,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // here as "ended without completion" — never report that against the // adopted Session. if (superseded()) return; + if (request.kind === 'external' && !turnPrepared && optimisticUserEntry?.kind === 'user') { + removeTransientUserMessage(optimisticUserEntry.messageId); + optimisticUserEntry = undefined; + } appendTurnFailureToTranscript(state, error); attention.attentionNeeded(); shellRunElapsedTicker.sync(); @@ -1330,9 +1267,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 @@ -1344,70 +1281,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return outcome; } - // Turn boundary flush: CLI-held fallback texts that never reached the - // runtime (the enqueue retry never found a live owner) are delivered - // FIRST, then queued followups (alt+Enter) — both open the next turn - // before any goal auto-continuation. Consumed here outside the turn - // stream, so clear the local mirror explicitly. await settlePendingEnqueues(); - const fallbackEntries = await takePendingFallbackEntriesSettled(); - const followup = await input.driver.takePendingFollowup?.(); if (outcome.kind === 'completed' && pendingAttachedTurn) { const attached = pendingAttachedTurn; pendingAttachedTurn = undefined; - const undelivered: string[] = []; - for (const entry of fallbackEntries) { - const enqueue = - entry.enqueue === 'steer' ? input.driver.steer : input.driver.queueMessage; - try { - if (!enqueue || (await enqueue.call(input.driver, entry.text)).kind === 'fallback') { - undelivered.push(entry.text); - } - } catch { - undelivered.push(entry.text); - } - } - if (followup) { - try { - if ( - !input.driver.queueMessage || - (await input.driver.queueMessage(followup)).kind === 'fallback' - ) { - undelivered.push(followup); - } - } catch { - undelivered.push(followup); - } - } busy = false; activity.finish(); startAttachedTurn?.(attached); - if (undelivered.length > 0) refillEditorFromQueues(undelivered.join('\n\n')); return outcome; } - const fallbackText = fallbackEntries.map((entry) => entry.text).join('\n\n'); - const nextPrompt = [fallbackText, followup ?? ''].filter(Boolean).join('\n\n'); - if (nextPrompt) { - state.steering = []; - state.followup = []; - if (outcome.kind !== 'completed') { - // The turn was aborted or errored: auto-opening a turn would defeat - // the interrupt (or hammer a failure). Keep the undelivered text as - // an editable draft instead, merged ahead of any current draft. - refillEditorFromQueues(nextPrompt); - } else { - // Install the next local activity before resolving the previous one. - // A Goal admission woken by the old activity therefore observes the - // user follow-up as busy instead of racing it for the session. - void runAgentTurn({ - kind: 'external', - prompt: nextPrompt, - sessionId: input.driver.getSessionId(), - }); - activity.finish(); - return outcome; - } - } busy = false; activity.finish(); diff --git a/packages/cli/src/pi-tui-turn.ts b/packages/cli/src/pi-tui-turn.ts index 055b43e2ea..957dc8cc6e 100644 --- a/packages/cli/src/pi-tui-turn.ts +++ b/packages/cli/src/pi-tui-turn.ts @@ -18,6 +18,7 @@ */ import type { SessionEvent } from '@maka/core/events'; +import { randomUUID } from 'node:crypto'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import { @@ -40,6 +41,8 @@ export type MakaPiTuiTurnRequest = | { kind: 'external'; prompt: string; + /** Stable operation/message identity shared by the transient row and Host admission. */ + turnId?: string; /** Model-facing text after explicit skill expansion, when different. */ sendText?: string; /** Session observed before preparation; null is valid for the first turn. */ @@ -54,34 +57,39 @@ export type MakaPiTuiTurnRequest = }; export interface RunMakaPiTuiTurnInput { - driver: Pick; + driver: Pick; turnActivity: MakaPiTuiTurnActivity; request: MakaPiTuiTurnRequest; shouldAbort: () => boolean; - onStart?: () => void; + onStart?: (turnId: string | undefined) => void; onPrepared?: (turn: MakaPreparedSessionTurn) => void | Promise; onSkillInvocation?: (result: SkillInvocationResult) => void | Promise; onEvent?: (event: SessionEvent) => void | Promise; onFailure?: (error: unknown) => void | Promise; } +export type MakaPiTuiTurnOutcome = GoalTurnOutcome | { kind: 'admitted' }; + /** * Owns one visible TUI turn from activity reservation through full stream drain. * Goal continuation and ScheduledTask admission remain Runtime Host responsibilities. */ -export async function runMakaPiTuiTurn(input: RunMakaPiTuiTurnInput): Promise { +export async function runMakaPiTuiTurn( + input: RunMakaPiTuiTurnInput, +): Promise { const { request } = input; let activity: SessionActivityLease | undefined; - let preparedTurnId = request.kind === 'attached' ? request.turn.turnId : undefined; + const externalTurnId = request.kind === 'external' ? (request.turnId ?? randomUUID()) : undefined; + let preparedTurnId = request.kind === 'attached' ? request.turn.turnId : externalTurnId; - const finishBeforeDrain = (outcome: GoalTurnOutcome): GoalTurnOutcome => { + const finishBeforeDrain = (outcome: T): T => { activity?.release(); activity = undefined; return outcome; }; try { - input.onStart?.(); + input.onStart?.(preparedTurnId); if (input.shouldAbort()) { return finishBeforeDrain(abortedOutcome(preparedTurnId)); } @@ -95,10 +103,24 @@ export async function runMakaPiTuiTurn(input: RunMakaPiTuiTurnInput): Promise { - return this.#enqueue(text, 'current_turn'); - } - - async queueMessage(text: string): Promise { - return this.#enqueue(text, 'next_turn'); + async submitMessage(text: string, options: MakaSubmitMessageOptions): Promise { + const sessionId = await this.#ensureSession(); + const sessionGeneration = this.#sessionGeneration; + const configuration = await this.#loadConfiguration(sessionId); + this.#assertCurrentSession(sessionId, sessionGeneration); + await this.#ensureChannel(sessionId); + this.#assertCurrentSession(sessionId, sessionGeneration); + this.#adoptLoadedConfiguration(configuration); + const modelText = options.modelText ?? text; + try { + await this.#request('turn.message.submit', { + originHostEpoch: this.#connection.hostEpoch, + sessionId, + messageId: options.messageId, + content: { + text: modelText, + ...(modelText === text ? {} : { displayText: text }), + }, + placement: options.placement, + }); + } catch (error) { + if ( + (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') || + (error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched') + ) { + return; + } + 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 queryMessageStatuses( + 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 { @@ -950,26 +979,6 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { await previous?.close().catch(() => undefined); } - async #enqueue( - text: string, - placement: 'current_turn' | 'next_turn', - ): Promise { - const sessionId = this.#sessionId; - if (!sessionId) return { kind: 'fallback' }; - const result = await this.#request('turn.message.submit', { - originHostEpoch: this.#connection.hostEpoch, - sessionId, - messageId: this.#newId(), - content: { text }, - placement, - }); - // A root Turn can settle between the local projection check and Host - // admission. The Host has already started the message in that case, so it - // must not be submitted again. Treat it as accepted; the subscription owns - // projection of the successor Turn. - return { kind: 'queued' }; - } - async #updateConfiguration( sessionId: string, patch: { diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index e9fe200a6e..351b5b568f 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,11 @@ import type { CreateSessionInput, TurnOrchestration } from '@maka/core/runtime-i import type { UserQuestionResponse } from '@maka/core/user-question'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; -import type { GoalControlAction, GoalProjection } from '@maka/runtime-host/protocol'; +import type { + GoalControlAction, + GoalProjection, + TurnMessageQueryResult, +} from '@maka/runtime-host/protocol'; export interface MakaSessionMoveResult { previousCwd: string; @@ -90,6 +94,17 @@ export interface MakaPreparePromptOptions { maxSteps?: number; } +export interface MakaSubmitMessageOptions { + messageId: string; + placement: 'current_turn' | 'next_turn'; + modelText?: string; +} + +export interface MakaRetractedMessages { + text: string; + messageIds: readonly string[]; +} + export class SkillInvocationBlockedError extends Error { constructor(readonly skillInvocation: SkillInvocationResult) { super('Explicit Skill invocation could not be resolved'); @@ -104,12 +119,11 @@ export interface MakaSessionDriver { prompt: string, options?: MakaPreparePromptOptions, ): Promise; + submitMessage?(text: string, options: MakaSubmitMessageOptions): Promise; + queryMessageStatuses?(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__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 4763c12670..df3c9ed762 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -72,6 +72,59 @@ test('idle submit starts exactly one root Turn and retry identity is connection- assert.equal(fixture.liveResidencies(), 0); }); +test('message query returns durable cancellation proof after the live queue disappears', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'cancelled-message', 'discard me', 'next_turn'); + await fixture.coordinator.cancelMessages(ROOT.sessionId, ['cancelled-message']); + + const result = await fixture.coordinator.handlers['turn.message.query']( + { + sessionId: ROOT.sessionId, + messageIds: ['cancelled-message', 'unknown-message'], + }, + operationContext(), + ); + + assert.deepEqual(result, { + ok: true, + result: { + messages: [ + { messageId: 'cancelled-message', status: 'cancelled' }, + { messageId: 'unknown-message', status: 'unknown' }, + ], + }, + }); +}); + +test('message query distinguishes a live admission from durable handoff proof', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'accepted-message', 'waiting', 'next_turn'); + fixture.receipts.set( + 'handed-off-message', + sourceReceipt('handed-off-message', 'delivered', 'current_turn', 'steering'), + ); + + const result = await fixture.coordinator.handlers['turn.message.query']( + { + sessionId: ROOT.sessionId, + messageIds: ['accepted-message', 'handed-off-message'], + }, + operationContext(), + ); + + assert.deepEqual(result, { + ok: true, + result: { + messages: [ + { messageId: 'accepted-message', status: 'accepted' }, + { messageId: 'handed-off-message', status: 'handed_off' }, + ], + }, + }); +}); + test('submit re-runs admission when the queue revision moves during preflight', async () => { let preflightCalls = 0; const fixture = createFixture(undefined, async () => { @@ -2267,6 +2320,16 @@ function memoryMessageAdmissionStore( return admission; }, readMessageAdmission: async (_sessionId, messageId) => admissions.get(messageId)?.admission, + readCancelledMessageAdmission: async (_sessionId, messageId) => { + const entry = admissions.get(messageId); + return entry?.state === 'cancelled' + ? { + messageId, + submittedContentDigest: entry.admission.submittedContentDigest, + submittedPlacement: entry.admission.submittedPlacement, + } + : undefined; + }, listMessageAdmissions: async (sessionId) => [...admissions.values()] .filter(({ admission, state }) => admission.sessionId === sessionId && state === 'accepted') diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c4f2fad83b..ad488a531f 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -242,6 +242,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 43); }); + test('publishes a new compatibility epoch for durable Message lifecycle queries', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); + }); + test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); @@ -969,6 +973,14 @@ describe('Runtime Host bootstrap protocol', () => { }); test('requires stable Message command identities, origin Host Epoch, and exact inputs', () => { + const query = { + requestId: 'query-request-1', + operation: 'turn.message.query' as const, + input: { + sessionId: 'session-1', + messageIds: ['message-1', 'message-2'], + }, + }; const submit = { requestId: 'submit-request-1', operation: 'turn.message.submit' as const, @@ -996,6 +1008,7 @@ describe('Runtime Host bootstrap protocol', () => { runId: 'run-1', }, }; + assert.deepEqual(decodeClientFrame(query), query); assert.deepEqual(decodeClientFrame(submit), submit); assert.deepEqual(decodeClientFrame(retract), retract); assert.deepEqual(decodeClientFrame(interrupt), interrupt); diff --git a/packages/runtime-host/src/__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..b8c48908d8 100644 --- a/packages/runtime-host/src/protocol/message.ts +++ b/packages/runtime-host/src/protocol/message.ts @@ -91,6 +91,20 @@ export type TurnMessageSubmitResult = | { readonly disposition: 'followup'; readonly queueRevision: number } | { readonly disposition: 'turn_started'; readonly turnId: string }; +export type MessageLifecycleStatus = 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; + +export interface TurnMessageQueryInput { + readonly sessionId: string; + readonly messageIds: readonly string[]; +} + +export interface TurnMessageQueryResult { + readonly messages: readonly { + readonly messageId: string; + readonly status: MessageLifecycleStatus; + }[]; +} + export interface QueueRetractInput { readonly originHostEpoch: string; readonly sessionId: string; @@ -163,6 +177,13 @@ const MESSAGE_OPERATION_ERRORS = [ ] as const; export const MESSAGE_OPERATION_SPECS = { + 'turn.message.query': defineOperation({ + mode: 'query', + availability: 'ready', + errors: MESSAGE_OPERATION_ERRORS, + decodeInput: decodeTurnMessageQueryInput, + decodeOutput: decodeTurnMessageQueryResult, + }), 'turn.message.submit': defineOperation({ mode: 'command', availability: 'ready', @@ -258,6 +279,51 @@ function decodeTurnMessageSubmitInput(value: unknown): TurnMessageSubmitInput { }; } +function decodeTurnMessageQueryInput(value: unknown): TurnMessageQueryInput { + const record = requireExactRecord(value, 'turn.message.query input', ['sessionId', 'messageIds']); + if (!Array.isArray(record.messageIds) || record.messageIds.length > MESSAGE_QUEUE_MAX_ENTRIES) { + throw invalidProtocolFrame('Invalid turn.message.query messageIds'); + } + const messageIds = record.messageIds.map((messageId) => requireEntityId(messageId, 'messageId')); + if (new Set(messageIds).size !== messageIds.length) { + throw invalidProtocolFrame('Duplicate turn.message.query messageId'); + } + return { + sessionId: requireEntityId(record.sessionId, 'sessionId'), + messageIds, + }; +} + +function decodeTurnMessageQueryResult(value: unknown): TurnMessageQueryResult { + const record = requireExactRecord(value, 'turn.message.query result', ['messages']); + if (!Array.isArray(record.messages) || record.messages.length > MESSAGE_QUEUE_MAX_ENTRIES) { + throw invalidProtocolFrame('Invalid turn.message.query messages'); + } + const messages = record.messages.map((candidate) => { + const message = requireExactRecord(candidate, 'turn.message.query message', [ + 'messageId', + 'status', + ]); + if ( + message.status !== 'accepted' && + message.status !== 'handed_off' && + message.status !== 'cancelled' && + message.status !== 'unknown' + ) { + throw invalidProtocolFrame('Invalid turn.message.query status'); + } + const status = message.status as MessageLifecycleStatus; + return { + messageId: requireEntityId(message.messageId, 'messageId'), + status, + }; + }); + if (new Set(messages.map(({ messageId }) => messageId)).size !== messages.length) { + throw invalidProtocolFrame('Duplicate turn.message.query result messageId'); + } + return { messages }; +} + function decodeTurnMessageSubmitResult(value: unknown): TurnMessageSubmitResult { const record = requireRecord(value, 'turn.message.submit result'); if (record.disposition === 'turn_started') { diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 9d95dec0bf..00bcf5257e 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -316,6 +316,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'subscription.open', 'task.ledger.query', 'turn.interrupt', + 'turn.message.query', 'turn.message.submit', 'turn.query', 'turn.regenerate', diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 066d3d5e0c..acd667d753 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -315,6 +315,7 @@ const HOST_EPOCH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; /** The sole in-memory message authority for one Runtime Host Epoch. */ export class HostMessageCoordinator implements RuntimeMessageAuthority { readonly handlers: MessageOperationHandlerMap = { + 'turn.message.query': (input) => this.queryMessages(input), 'turn.message.submit': (input, context) => this.submit(input, context), 'queue.retract': (input) => this.retract(input), 'queue.entry.retract': (input) => this.retractQueuedEntry(input), @@ -370,6 +371,49 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return state ? hasLiveMessageState(state) : false; } + async queryMessages(input: { sessionId: string; messageIds: readonly string[] }): Promise< + MessageOutcome<{ + messages: Array<{ + messageId: string; + status: 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; + }>; + }> + > { + const messages = [] as Array<{ + messageId: string; + status: 'accepted' | 'handed_off' | 'cancelled' | 'unknown'; + }>; + for (const messageId of input.messageIds) { + const cancelled = await this.#admissions.readCancelledMessageAdmission( + input.sessionId, + messageId, + ); + if (cancelled) { + messages.push({ messageId, status: 'cancelled' }); + continue; + } + const accepted = await this.#admissions.readMessageAdmission(input.sessionId, messageId); + if (accepted) { + messages.push({ messageId, status: 'accepted' }); + continue; + } + const root = await this.#durableProof.readRootTurnSourceMessageReceipt( + input.sessionId, + messageId, + ); + if (root) { + messages.push({ messageId, status: 'handed_off' }); + continue; + } + const steering = await this.#durableProof.readImmutableSteeringMessageProof( + input.sessionId, + messageId, + ); + messages.push({ messageId, status: steering ? 'handed_off' : 'unknown' }); + } + return success({ messages }); + } + retireSessions(sessionIds: readonly string[]): void { for (const sessionId of new Set(sessionIds)) { const state = this.#sessions.get(sessionId); diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 208befbb28..49876f7991 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -85,6 +85,7 @@ export type ConnectionEffectOperationKey = Extract< >; export type MessageOperationKey = Extract< OperationKey, + | 'turn.message.query' | 'turn.message.submit' | 'queue.retract' | 'queue.entry.retract' diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 9e36149c13..5997c38334 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1507,7 +1507,13 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { sessionId: request.sessionId, turnId: request.turnId, proposedRunId: randomUUID(), - proposedUserMessageId: randomUUID(), + // The interactive send's operation identity is also its canonical + // user-message identity. Clients can therefore render immediately + // and let the durable transcript replace that row in place. Other + // Turn kinds do not carry a user message and retain their own + // generated admission identity. + proposedUserMessageId: + request.execution.kind === 'external_message' ? request.turnId : randomUUID(), execution: request.execution, normalizedInput: canonicalContent.content, ...(request.turnOrchestration ? { turnOrchestration: request.turnOrchestration } : {}), diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index f939ab6099..93027587f8 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -445,6 +445,11 @@ describe('SqliteSessionMetadataStore', () => { await store.commitMessageAdmission(admission); await store.cancelMessageAdmissions('session-1', ['message-1']); assert.deepEqual(await store.listMessageAdmissions('session-1'), []); + assert.deepEqual(await store.readCancelledMessageAdmission('session-1', 'message-1'), { + messageId: 'message-1', + submittedContentDigest: messageContentDigest({ text: 'discard this draft' }), + submittedPlacement: 'next_turn', + }); await assert.rejects( store.commitMessageAdmission(admission), /identity is already cancelled/, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 32996da174..88201cf48a 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -415,6 +415,8 @@ async function createExecutionStoresForWrite sessionStore.commitMessageAdmission(admission)), readMessageAdmission: (sessionId, messageId) => run(() => sessionStore.readMessageAdmission(sessionId, messageId)), + readCancelledMessageAdmission: (sessionId, messageId) => + run(() => sessionStore.readCancelledMessageAdmission(sessionId, messageId)), listMessageAdmissions: (sessionId) => run(() => sessionStore.listMessageAdmissions(sessionId)), markMessagesHandedOff: (input) => run(() => sessionStore.markMessagesHandedOff(input)), diff --git a/packages/storage/src/message-admission-store.ts b/packages/storage/src/message-admission-store.ts index a73a3f43e2..e0b728e109 100644 --- a/packages/storage/src/message-admission-store.ts +++ b/packages/storage/src/message-admission-store.ts @@ -35,12 +35,22 @@ export interface PendingMessageAdmission { readonly admittedAt: number; } +export interface CancelledMessageAdmission { + readonly messageId: string; + readonly submittedContentDigest: `sha256:${string}`; + readonly submittedPlacement: 'current_turn' | 'next_turn'; +} + export interface MessageAdmissionStore { commitMessageAdmission(admission: PendingMessageAdmission): Promise; readMessageAdmission( sessionId: string, messageId: string, ): Promise; + readCancelledMessageAdmission( + sessionId: string, + messageId: string, + ): Promise; listMessageAdmissions(sessionId: string): Promise; markMessagesHandedOff(input: { sessionId: string; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 5361318930..3d69a52679 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -888,6 +888,11 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readMessageAdmission(sessionId, messageId); } + async readCancelledMessageAdmission(sessionId: string, messageId: string) { + await this.ensureReady(); + return this.metadata.readCancelledMessageAdmission(sessionId, messageId); + } + async listMessageAdmissions(sessionId: string): Promise { await this.ensureReady(); return this.metadata.listMessageAdmissions(sessionId); diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 8943dea71f..a2ed11dfc7 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -1656,6 +1656,48 @@ export class SqliteSessionMetadataStore { }); } + async readCancelledMessageAdmission( + sessionId: string, + messageId: string, + ): Promise< + | { + messageId: string; + submittedContentDigest: `sha256:${string}`; + submittedPlacement: 'current_turn' | 'next_turn'; + } + | undefined + > { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeSessionId(messageId); + return this.readTransaction(() => { + const row = this.db + .prepare( + ` + SELECT submitted_content_digest, submitted_placement + FROM cancelled_message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(sessionId, messageId) as + | { submitted_content_digest?: unknown; submitted_placement?: unknown } + | undefined; + if (!row) return undefined; + if ( + typeof row.submitted_content_digest !== 'string' || + !/^sha256:[a-f0-9]{64}$/u.test(row.submitted_content_digest) || + (row.submitted_placement !== 'current_turn' && row.submitted_placement !== 'next_turn') + ) { + throw new SessionMetadataConflictError('Invalid cancelled Message admission identity'); + } + return { + messageId, + submittedContentDigest: row.submitted_content_digest as `sha256:${string}`, + submittedPlacement: row.submitted_placement, + }; + }); + } + async listMessageAdmissions(sessionId: string): Promise { this.assertOpen(); assertSafeSessionId(sessionId); 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..48e270d482 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -57,6 +57,7 @@ import { type ProviderRetryEvent, type QuoteRef, } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; import { finalAssistantReplyText, type TurnTimelineItem, @@ -276,6 +277,33 @@ const UserMessageBody = memo(function UserMessageBody(props: { ); }); +export function TransientUserMessage(props: { + message: Extract; + onReadAttachmentBytes?: ReadAttachmentBytes; +}) { + const copy = getConversationCopy(useUiLocale()).messages; + const message = props.message; + return ( +
+ + + +
+ ); +} + function accessibleTextExcerpt(text: string): string { const normalized = text.replace(/\s+/g, ' ').trim(); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index c05d77fd26..9943f1befd 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -43,6 +43,7 @@ import { LocalizedChatMessage, TurnRunningStatus, TurnView, + TransientUserMessage, type ReadAttachmentBytes, type TurnFooterActionMeta, type TurnPresentationDeriver, @@ -59,8 +60,14 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } +export type TransientUserMessageProjection = Extract & { + /** Presentation-only placement until canonical transcript grouping arrives. */ + transientPlacement?: 'turn_source' | 'current_turn' | 'next_turn'; +}; + export function ChatView(props: { messages: StoredMessage[]; + transientMessages?: readonly TransientUserMessageProjection[]; messageLoading?: boolean; liveTurn?: LiveTurnProjection; /** Live display content already present when the host activated this conversation surface. */ @@ -272,6 +279,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 +462,22 @@ 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; + } + return message.turnId === tailTurnId || message.transientPlacement === 'turn_source'; + }) + : []; + const inlineTransientMessageIds = new Set( + inlineTransientMessages.map((message) => message.id), + ); const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, @@ -529,8 +553,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 +662,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/materialize.ts b/packages/ui/src/materialize.ts index 2f26a26174..1ea01c5d4d 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -429,6 +429,17 @@ export function overlayLiveTurn( ) { return turns; } + // A send arm is only a presentation claim that the next message may still + // arrive. It is not a Turn record and must not manufacture one while the + // canonical transcript is catching up. A real live step (or steering + // message) is sufficient evidence to project a missing external Turn. + if ( + targetIndex < 0 + && liveTurn.steps.length === 0 + && (liveTurn.pendingSteering?.length ?? 0) === 0 + ) { + return turns; + } const current = targetIndex >= 0 ? turns[targetIndex]!