diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 030561efc4..70ec0ce0f0 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -53,6 +53,10 @@ "tests": 1, "electron": "rail grouping has to be rebuilt from persisted project state after a renderer reload" }, + "side-chat-followups.spec.ts": { + "tests": 1, + "electron": "queue mutations and successive Side Chat Turns cross renderer/preload/main/Host; native dragging must pass the main-window drop guard and Host reconnect must restore the live fork without remounting it" + }, "skill-draft-lifecycle.spec.ts": { "tests": 2, "electron": "revision retry and cancel are Host-owned draft transitions across a parent and a child Session" diff --git a/apps/desktop/e2e/side-chat-followups.spec.ts b/apps/desktop/e2e/side-chat-followups.spec.ts new file mode 100644 index 0000000000..6f7c535b84 --- /dev/null +++ b/apps/desktop/e2e/side-chat-followups.spec.ts @@ -0,0 +1,216 @@ +/* + * 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 { FAKE_HOLD_OPEN_PROMPT, FAKE_WAIT_FOR_STEERING_PROMPT } from '@maka/runtime/test-only/fake-backend'; +import { connectExistingRuntimeHost } from '@maka/runtime-host/client'; +import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; +import type { ElectronApplication } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { parseDesktopSessionKey } from '../src/shared/runtime-host-identity'; +import { awaitSendReady, COMPOSER_INPUT, expect, test, withE2eWindow } from './fixtures'; + +type ReconnectFixture = { + closeConnection(): Promise; + release(): void; + waiting: boolean; + reseeded: boolean; +}; +type ReconnectGlobal = typeof globalThis & { __makaSideChatReconnect?: ReconnectFixture }; + +async function armConnectionGap(app: ElectronApplication): Promise { + // Fault only the transport in this disposable main process. Keep the Host, + // renderer, stored messages and observation recovery path running unchanged. + await app.evaluate(() => { + const require = process.getBuiltinModule('module').createRequire(`${process.cwd()}/`); + const { DesktopRuntimeHostClient } = require('./dist/main/runtime-host-client.js'); + const { RuntimeHostSessionObservationRegistry } = require('./dist/main/runtime-host-session-observation-registry.js'); + const queryMessageExecutions = DesktopRuntimeHostClient.prototype.queryMessageExecutions; + const attach = RuntimeHostSessionObservationRegistry.prototype.attach; + let client: { connection: { close(): Promise } } | undefined; + let release!: () => void; + const gap = new Promise((resolve) => { release = resolve; }); + const state: ReconnectFixture = { + async closeConnection() { + if (!client) throw new Error('Side Chat fixture did not capture the Desktop connection'); + await client.connection.close(); + }, + release, + waiting: false, + reseeded: false, + }; + DesktopRuntimeHostClient.prototype.queryMessageExecutions = function (...args: unknown[]) { + client = this; + DesktopRuntimeHostClient.prototype.queryMessageExecutions = queryMessageExecutions; + return queryMessageExecutions.apply(this, args); + }; + RuntimeHostSessionObservationRegistry.prototype.attach = async function (...args: unknown[]) { + state.waiting = true; + await gap; + RuntimeHostSessionObservationRegistry.prototype.attach = attach; + const result = await attach.apply(this, args); + state.reseeded = true; + return result; + }; + (globalThis as ReconnectGlobal).__makaSideChatReconnect = state; + }); +} + +// The real main-window capture listener previously swallowed queue drops, and +// the restored main/preload observation left completed entries in this panel. +// Component/Host tests omit those Electron owners; this one window verifies +// their wiring while the existing hook/projector suites cover state orderings. +test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', async ({}, testInfo) => { + await withE2eWindow({ + seed: true, + readinessSelector: COMPOSER_INPUT, + locale: 'zh-CN', + showWindow: true, + tracePath: testInfo.outputPath('trace.zip'), + }, async (page, { app, userDataDir }) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('side conversation acceptance source'); + await awaitSendReady(page); + await composer.press('Enter'); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); + const originalSessionIds = await page.evaluate(async () => + (await window.maka.sessions.list()).map((session) => session.id)); + await page.getByRole('button', { name: '展开任务工作栏' }).click(); + await page.getByRole('button', { name: /侧边对话.*在不打断主任务的情况下追问和只读探索/ }).click(); + const companion = page.locator('.maka-quote-companion'); + const sideComposer = companion.locator(COMPOSER_INPUT); + await sideComposer.fill(FAKE_HOLD_OPEN_PROMPT); + await sideComposer.press('Enter'); + await expect(companion).toContainText('Fake backend waiting'); + const forkId = await page.evaluate(async (existingIds) => { + const created = (await window.maka.sessions.list()).filter((session) => !existingIds.includes(session.id)); + if (created.length !== 1) throw new Error(`Expected one Side Chat fork, found ${created.length}`); + return created[0]!.id; + }, originalSessionIds); + const queued = companion.locator('.maka-composer-queue'); + for (const text of ['first follow-up', 'second follow-up', 'retract this follow-up']) { + await sideComposer.fill(text); + await sideComposer.press('Enter'); + await expect(queued).toContainText(text); + } + await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ + 'first follow-up', 'second follow-up', 'retract this follow-up', + ]); + await queued.getByRole('button', { name: '编辑', exact: true }).first().click(); + const edit = queued.getByRole('textbox', { name: '编辑', exact: true }); + await edit.fill('edited first follow-up'); + await edit.press('Enter'); + await expect(queued.locator('.maka-composer-queue-text').first()).toHaveText('edited first follow-up'); + const grips = queued.locator('[draggable="true"]'); + await grips.nth(1).dragTo(grips.nth(0)); + await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ + 'second follow-up', 'edited first follow-up', 'retract this follow-up', + ]); + await queued.getByRole('button', { name: '删除', exact: true }).nth(2).click(); + await expect(queued).not.toContainText('retract this follow-up'); + await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); + + await sideComposer.fill('steer the current response'); + await sideComposer.press('Shift+Enter'); + await expect(companion).toContainText('Acknowledged steering: steer the current response'); + await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ + 'second follow-up', 'edited first follow-up', + ]); + await queued.getByRole('button', { name: '调整方向', exact: true }).first().click(); + await expect(companion.locator('.maka-steering-message').last()).toContainText('second follow-up'); + await expect(queued.locator('.maka-composer-queue-text')).toHaveText(['edited first follow-up']); + await queued.getByRole('button', { name: '删除', exact: true }).click(); + await expect(queued).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('side-chat-steering.png'), fullPage: true }); + await companion.getByRole('button', { name: '停止', exact: true }).click(); + await expect(companion.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 }); + // The held-open fixture's pipe-separated acknowledgment is an unfinished + // Markdown table candidate until Stop flushes the final assistant message. + await expect(companion).toContainText('steer the current response | second follow-up'); + + // Hold a second Turn before its first token, queue two successors, then + // release it by steering. All three replies must survive the Host handoffs. + await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT); + await sideComposer.press('Enter'); + await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); + for (const text of ['successor one', 'successor two']) { + await sideComposer.fill(text); + await sideComposer.press('Enter'); + await expect(queued).toContainText(text); + } + await page.screenshot({ path: testInfo.outputPath('side-chat-queue.png'), fullPage: true }); + await sideComposer.fill('release the held response'); + await sideComposer.press('Shift+Enter'); + await expect(companion).toContainText('Acknowledged steering: release the held response'); + await expect(companion).toContainText('Fake backend received: successor one', { timeout: 20_000 }); + await expect(companion).toContainText('Fake backend received: successor two', { timeout: 20_000 }); + await expect(companion.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 }); + await expect(queued).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('side-chat-settled.png'), fullPage: true }); + + await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT); + await sideComposer.press('Enter'); + await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); + for (const text of ['reconnected successor one', 'reconnected successor two']) { + await sideComposer.fill(text); + await sideComposer.press('Enter'); + await expect(queued).toContainText(text); + } + await armConnectionGap(app); + // Capture the actual Desktop client before closing its transport. + await page.evaluate((id) => window.maka.sessions.queryMessageExecutions(id, ['e2e-connection-probe']), forkId); + const hostSessionId = parseDesktopSessionKey(forkId).sessionId; + const connection = await connectExistingRuntimeHost({ + rootPath: join(userDataDir, 'workspaces', 'default'), + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + }); + expect(connection.kind).toBe('connected'); + if (connection.kind !== 'connected') throw new Error('Acceptance client could not connect to Host'); + try { + await app.evaluate(() => (globalThis as ReconnectGlobal).__makaSideChatReconnect!.closeConnection()); + await expect.poll(() => app.evaluate(() => (globalThis as ReconnectGlobal).__makaSideChatReconnect!.waiting)).toBe(true); + await connection.connection.request('turn.message.submit', { + originHostEpoch: connection.connection.hostEpoch, + sessionId: hostSessionId, + messageId: randomUUID(), + content: { text: 'release while Desktop is disconnected' }, + placement: 'current_turn', + }); + await expect.poll(async () => { + const turns = await connection.connection.request('session.turns.query', { + sessionId: hostSessionId, position: 0, throughSequence: null, maxContributions: 128, + }); + return turns.contributions.filter((turn) => + turn.userPromptPreview?.startsWith('reconnected successor') && turn.latestState?.message.status === 'completed').length; + }, { timeout: 20_000 }).toBe(2); + await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ + 'reconnected successor one', 'reconnected successor two', + ]); + } finally { + await app.evaluate(() => (globalThis as ReconnectGlobal).__makaSideChatReconnect!.release()); + await connection.connection.close(); + } + await expect.poll(() => app.evaluate(() => (globalThis as ReconnectGlobal).__makaSideChatReconnect!.reseeded)).toBe(true); + await expect(companion).toContainText('Fake backend received: reconnected successor one', { timeout: 20_000 }); + await expect(companion).toContainText('Fake backend received: reconnected successor two'); + await expect(queued).toHaveCount(0); + await expect(companion.getByRole('button', { name: '停止', exact: true })).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('side-chat-reconnected.png'), fullPage: true }); + }); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f698584496..b8d5105b09 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -118,7 +118,6 @@ "src/renderer/session-error-presentation.ts", "src/renderer/session-event-health.ts", "src/renderer/session-health-notice.ts", - "src/renderer/session-message-settlement.ts", "src/renderer/session-read-state.ts", "src/renderer/session-status-presentation.ts", "src/renderer/session-workspace-actions.ts", @@ -213,7 +212,6 @@ "src/renderer/theme.ts", "src/renderer/titlebar-dim-color.ts", "src/renderer/titlebar-modal-sync.ts", - "src/renderer/transient-message-projection.ts", "src/renderer/turn-footer-actions.ts", "src/renderer/use-active-execution-boundary.ts", "src/renderer/use-app-shell-composer-quotes.ts", @@ -273,9 +271,7 @@ "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme", "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/work-board-panel" ], - "legacyPlatformImports": [ - "src/renderer/platform/desktop/create-workbar-services.ts -> src/renderer/session-message-settlement" - ], + "legacyPlatformImports": [], "controllerOwners": [ { "implementation": "src/renderer/features/app-update/controller/use-app-update-controller.ts", @@ -575,8 +571,8 @@ "dependencyPaths": { "./locales/conversation-copy.js": 1, "./locales/shell-copy.js": 1, + "./platform/desktop/session-message-settlement.js": 1, "./session-copy-attempt.js": 1, - "./session-message-settlement.js": 1, "./session-workspace-errors.js": 1, "@maka/core/session": 1 }, @@ -597,13 +593,14 @@ "createAppShellSessionEventHandlers" ], "dependencyPaths": { + "./application/contracts/message-queue-projection.js": 1, "./features/conversation/index.js": 1, "./locales/conversation-copy.js": 1, "./model-connection-errors.js": 1, "@maka/ui": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2752 + "nonTriviaTokens": 2725 }, "src/renderer/app-shell-session-start-actions.ts": { "importDeclarations": 2, @@ -2171,22 +2168,6 @@ "./locales/conversation-copy.js": 1 } }, - "src/renderer/session-message-settlement.ts": { - "bridgePaths": { - "window.maka.transcripts": 1 - }, - "environmentCapabilities": { - "window.clearTimeout": 2, - "window.setTimeout": 2 - }, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./platform/desktop/desktop-transcript-range-store.js": 1 - } - }, "src/renderer/session-read-state.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -2219,9 +2200,8 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./new-task-reload-intent.js": 1, - "./transient-message-projection.js": 1, - "@maka/runtime-host/protocol": 1 + "./application/contracts/transient-message-projection.js": 1, + "./new-task-reload-intent.js": 1 } }, "src/renderer/session-workspace-errors.ts": { @@ -4047,15 +4027,6 @@ "./theme": 1 } }, - "src/renderer/transient-message-projection.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/turn-footer-actions.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index d472fe61eb..7a433b9364 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -23,6 +23,7 @@ import type { StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; import { encodeDesktopTranscriptChange, + encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot, } from '../desktop-transcript-ipc.js'; import { @@ -39,7 +40,10 @@ import { } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { TranscriptReadSupersededError } from '../../renderer/features/conversation/index.js'; import { mergeSettledMessages } from '../../renderer/settled-message-merge.js'; -import { readSettledMessages } from '../../renderer/session-message-settlement.js'; +import { + readSettledMessages, + readSettledMessagesFrom, +} from '../../renderer/platform/desktop/session-message-settlement.js'; import { DesktopTranscriptReplica, type DesktopTranscriptReplicaChange } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; @@ -56,6 +60,18 @@ test('merges a settled tail without dropping earlier messages', () => { ]); }); +test('merges an anchored historical range before its overlapping tail', () => { + const answerA = { ...assistantMessage('answer A', 'assistant-a'), turnId: 'turn-a', ts: 9 }; + const answerB = { ...assistantMessage('answer B', 'assistant-b'), turnId: 'turn-b', ts: 20 }; + const partialC = { ...assistantMessage('partial C', 'assistant-c'), turnId: 'turn-c', ts: 10 }; + const answerC = { ...partialC, text: 'answer C' }; + + assert.deepEqual( + mergeSettledMessages([answerA, partialC], [answerB, answerC]), + [answerA, answerB, answerC], + ); +}); + test('cancels settlement while transcript open is pending', async () => { const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); let cancelled = false; @@ -92,6 +108,167 @@ test('cancels settlement while transcript open is pending', async () => { } }); +for (const paged of [false, true]) { + test(`reads one Host-owned Turn outside the bounded transcript tail${paged ? ' across multiple pages' : ''}`, async () => { + const sessionKey = JSON.stringify(['host-1', 'session-1']); + const turnB: StoredMessage[] = [ + userMessage('follow-up one', 'user-b'), + { ...assistantMessage('answer B', 'assistant-b'), turnId: 'turn-b', ts: 4 }, + { + type: 'turn_state', + id: 'complete-b', + turnId: 'turn-b', + ts: 5, + status: 'completed', + }, + ]; + const turnC: StoredMessage[] = [ + userMessage('follow-up two', 'user-c'), + { ...assistantMessage('answer C', 'assistant-c'), turnId: 'turn-c', ts: 7 }, + { + type: 'turn_state', + id: 'complete-c', + turnId: 'turn-c', + ts: 8, + status: 'completed', + }, + ]; + const navigations: number[] = []; + const extensions: number[] = []; + let deliverySequence = 0; + + const result = await readSettledMessagesFrom( + { + sessions: { + listTurns: async (sessionId) => { + assert.equal(sessionId, sessionKey); + return [{ turnId: 'turn-b', firstSequence: 3, status: 'completed' }]; + }, + }, + transcripts: { + open: async (_sessionId, handler) => { + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 8, + durable: turnC.map((message, index) => ({ sequence: index + 6, message })), + overlay: [], + hasOlder: true, + hasNewer: false, + })) handler({ ...batch, deliverySequence: ++deliverySequence }); + return { + sessionId: sessionKey, + generation: 'generation-1', + hostEpoch: 'host-1', + readThroughMessageId: 'complete-c', + async acknowledgeTail() { assert.fail('A recovery read must not mark the Session read'); }, + async loadBefore() {}, + async loadAfter(sequence, maxBytes, navigation) { + assert.equal(maxBytes, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.equal(sequence, 4); + extensions.push(sequence); + for (const batch of encodeDesktopTranscriptPage({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigation, + }, { + durableThrough: 8, + durable: [{ sequence: 5, message: turnB[2]! }], + hasNewer: true, + }, { direction: 'newer', anchor: sequence })) { + handler({ ...batch, deliverySequence: ++deliverySequence }); + } + }, + async loadAround(sequence, maxBytes, navigation) { + assert.equal(maxBytes, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + navigations.push(sequence); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 8, + durable: (paged ? turnB.slice(0, 2) : turnB).map((message, index) => ({ sequence: index + 3, message })), + overlay: [], + hasOlder: true, + hasNewer: true, + }, navigation)) handler({ ...batch, deliverySequence: ++deliverySequence }); + }, + async loadLatest() {}, + async close() {}, + }; + }, + }, + }, + sessionKey, + { requiredTurnId: 'turn-b' }, + ); + + assert.deepEqual(navigations, [3]); + assert.deepEqual(extensions, paged ? [4] : []); + assert.deepEqual(result, { messages: [...turnB, ...turnC], settled: true }); + }); +} + +for (const hasSequence of [false, true]) { + test(`does not settle when a targeted Host-owned Turn ${hasSequence ? 'cannot be recovered' : 'has no indexed sequence'}`, async () => { + const sessionKey = JSON.stringify(['host-1', 'session-1']); + const tail: StoredMessage[] = [ + { ...assistantMessage('answer C', 'assistant-c'), turnId: 'turn-c', ts: 7 }, + { + type: 'turn_state', + id: 'complete-c', + turnId: 'turn-c', + ts: 8, + status: 'completed', + }, + ]; + let targetedRead = false; + let deliverySequence = 0; + + const result = await readSettledMessagesFrom( + { + sessions: { + listTurns: async () => [{ + turnId: 'missing-turn', status: 'completed', ...(hasSequence ? { firstSequence: 3 } : {}), + }], + }, + transcripts: { + open: async (_sessionId, handler) => { + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 8, + durable: tail.map((message, index) => ({ sequence: index + 7, message })), + overlay: [], + hasOlder: true, + hasNewer: false, + })) handler({ ...batch, deliverySequence: ++deliverySequence }); + return { + sessionId: sessionKey, + generation: 'generation-1', + hostEpoch: 'host-1', + readThroughMessageId: 'complete-c', + async acknowledgeTail() {}, + async loadBefore() {}, + async loadAfter() {}, + async loadAround() { + targetedRead = true; + }, + async loadLatest() {}, + async close() {}, + }; + }, + }, + }, + sessionKey, + { requiredTurnId: 'missing-turn' }, + ); + + assert.equal(targetedRead, hasSequence); + assert.deepEqual(result, { messages: tail, settled: false }); + }); +} + test('moves a fragmented overlay record to durable storage without duplicating it', () => { const message = assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES * 2)); const identity = { diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 8433d14804..d81c7faa4b 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -23,12 +23,15 @@ import { afterEach, test } from 'node:test'; import { parseHTML } from 'linkedom'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ChatSurfaceLayout, ChatView, LocaleProvider } from '@maka/ui'; import type { SessionEvent } from '@maka/core/events'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { PermissionMode } from '@maka/core/permission'; import type { SessionChangedEvent, SessionSummary, + StoredMessage, TurnRecord, } from '@maka/core/session'; import type { ContextCompactResult } from '@maka/runtime-host/protocol'; @@ -64,8 +67,8 @@ function completeEvent(id: string, turnId: string, ts: number): SessionEvent { return { type: 'complete', id, turnId, ts, stopReason: 'end_turn' }; } -function textDeltaEvent(id: string, turnId: string, ts: number, text: string): SessionEvent { - return { type: 'text_delta', id, messageId: 'assistant-message', turnId, ts, text }; +function textDeltaEvent(id: string, turnId: string, ts: number, text: string, messageId = 'assistant-message'): SessionEvent { + return { type: 'text_delta', id, messageId, turnId, ts, text }; } function queueUpdateEvent( @@ -122,8 +125,11 @@ async function renderProbe( modelChoices?: readonly ChatModelChoice[]; ready?: (container: Element) => boolean; onSend?: (send: (text: string) => Promise) => void; + onProjection?: (companion: ReturnType) => void; + onQueue?: (queue: (text: string) => Promise) => void; onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; + onDeleteQueuedEntry?: (deleteEntry: (entryId: string) => Promise) => void; onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; confirmBypass?: () => Promise; onContextCompactionError?: (sessionId: string, error: unknown) => void; @@ -147,8 +153,11 @@ async function renderProbe( const children = options.ownership ? createElement(QuoteCompanionOwnershipProbe, { onSend: options.onSend ?? (() => undefined), + onProjection: options.onProjection, + onQueue: options.onQueue, onSteer: options.onSteer, onStop: options.onStop, + onDeleteQueuedEntry: options.onDeleteQueuedEntry, onSetPermissionMode: options.onSetPermissionMode, onContextCompactionError: options.onContextCompactionError, pendingQuotes: options.pendingQuotes, @@ -187,8 +196,11 @@ async function renderOwnershipProbe( } = {}, ) { let send!: (text: string) => Promise; + let projection!: ReturnType; + let queue!: (text: string) => Promise; let steer!: (text: string) => Promise; let stop!: () => Promise; + let deleteQueuedEntry!: (entryId: string) => Promise; let setPermissionMode!: (mode: PermissionMode) => Promise; let eventHandler: ((event: SessionEvent) => void) | undefined; let executionHandler: Parameters[4]; @@ -213,8 +225,11 @@ async function renderOwnershipProbe( { ownership: true, onSend: (value) => (send = value), + onProjection: (value) => (projection = value), + onQueue: (value) => (queue = value), onSteer: (value) => (steer = value), onStop: (value) => (stop = value), + onDeleteQueuedEntry: (value) => (deleteQueuedEntry = value), onSetPermissionMode: (value) => (setPermissionMode = value), ...options, }, @@ -222,9 +237,27 @@ async function renderOwnershipProbe( return { ...rendered, send: (text: string) => send(text), + queue: (text: string) => queue(text), steer: (text: string) => steer(text), stop: () => stop(), + deleteQueuedEntry: (entryId: string) => deleteQueuedEntry(entryId), setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode), + transcript() { + return parseHTML(`${renderToStaticMarkup( + createElement(LocaleProvider, { locale: 'en', children: createElement(ChatSurfaceLayout, { + composer: null, + children: createElement(ChatView, { + activeSession: projection.companionSession, + messages: projection.messages, + transientMessages: projection.transientMessages, + liveTurns: projection.liveTurns, + activeTurn: projection.activeTurn, + onNew: () => undefined, + scrollBehavior: 'auto', + }), + }) }), + )}`).document; + }, hostTurn(turnId: string | null, status: 'running' | 'completed' = 'running', available = true) { assert.ok(executionHandler); executionHandler({ type: 'host_execution', available, @@ -433,6 +466,58 @@ test('a first send shows the question bubble immediately but arms Stop only once await waitUntil(() => probe.getAttribute('data-streaming') === 'false'); }); +for (const proof of ['send reply', 'admission event'] as const) { + test(`keeps the initial Side Chat prompt before its reply when transcript reads fail (${proof})`, async () => { + const receipt = deferred<{ ok: true; turnId: string }>(); + let messageId: string | undefined; + const h = await renderOwnershipProbe({ + send: async (_sessionId, command) => { + messageId = command.turnId; + return receipt.promise; + }, + readSettledMessages: async () => { throw new Error('transcript temporarily unavailable'); }, + }); + let sent!: Promise; + await act(async () => { + sent = h.send('initial question'); + await Promise.resolve(); + }); + await waitUntil(() => messageId !== undefined); + await act(async () => { + h.hostTurn('first-turn'); + if (proof === 'admission event') { + h.emit(messageAdmittedEvent('admitted', 'first-turn', 1, messageId!)); + } else { + receipt.resolve({ ok: true, turnId: 'first-turn' }); + assert.equal(await sent, true); + } + h.emit({ type: 'text_complete', id: 'answer-event', messageId: 'answer', + turnId: 'first-turn', ts: 2, text: 'answer to initial question' }); + }); + const assertPromptBeforeReply = () => { + const transcript = h.transcript(); + const turn = transcript.querySelector('[data-transcript-turn-id="first-turn"]'); + assert.ok(turn); + assert.ok(turn.querySelector('.maka-user-message')?.textContent.startsWith('initial question')); + const text = transcript.body.textContent; + assert.ok(text.includes('answer to initial question')); + assert.ok(text.indexOf('initial question') < text.indexOf('answer to initial question')); + assert.equal(transcript.querySelectorAll('.maka-user-message').length, 1); + }; + assertPromptBeforeReply(); + await act(async () => { h.hostTurn('first-turn', 'completed'); }); + assertPromptBeforeReply(); + await act(async () => { h.hostTurn('successor-turn'); }); + assertPromptBeforeReply(); + if (proof === 'admission event') { + await act(async () => { + receipt.resolve({ ok: true, turnId: 'first-turn' }); + assert.equal(await sent, true); + }); + } + }); +} + test('a failed first send retires the optimistic bubble without ever arming Stop', async () => { // The fork never materializes: `branchFromTurn` throws. The optimistic bubble // must be unwound so nothing is stranded with no turn to reconcile it away, and @@ -480,7 +565,7 @@ test('dispatches /compact to the committed companion fork without sending model sendCalls += 1; return { ok: false as const, reason: 'seed only' }; }, - steer: async () => { + submitFollowUp: async () => { steerCalls += 1; return { kind: 'started' as const, turnId: 'unexpected-steer' }; }, @@ -504,6 +589,10 @@ test('dispatches the exact /compact Composer command before steering or ordinary calls.push('compact'); return true; }, + queue: async () => { + calls.push('queue'); + return true; + }, steer: async () => { calls.push('steer'); return true; @@ -518,6 +607,31 @@ test('dispatches the exact /compact Composer command before steering or ordinary assert.deepEqual(calls, ['compact']); }); +test('routes running Side Conversation submissions like the main conversation', async () => { + const calls: string[] = []; + const input = { + text: 'follow up', + streaming: true, + compact: async () => true, + queue: async (text: string) => { + calls.push(`queue:${text}`); + return true; + }, + steer: async (text: string) => { + calls.push(`steer:${text}`); + return true; + }, + send: async () => { + calls.push('send'); + return true; + }, + }; + + assert.equal(await dispatchQuoteCompanionInput(input), true); + assert.equal(await dispatchQuoteCompanionInput({ ...input, followUpMode: 'steer' }), true); + assert.deepEqual(calls, ['queue:follow up', 'steer:follow up']); +}); + test('keeps an async companion compaction exclusive until its terminal event', async () => { let compactCalls = 0; let sendCalls = 0; @@ -1274,8 +1388,8 @@ test('binds an unproven Side Conversation send through the durable transcript', messageId: string; }>(); // The Host opened a root Turn under its own identity and the answer was lost. - // No `message_admission` event exists for a root Message, so the transcript is - // the only thing that can tie the sent identity back to the Turn. + // Its admission event was also missed, so the transcript must still tie the + // sent identity back to the Turn. const { container, emit, send, hostTurn } = await renderOwnershipProbe({ send: async (_sessionId, command) => { admissionId = command.turnId; @@ -1575,12 +1689,12 @@ test('Side Chat stops presenting execution on observation loss while retaining t }); test('keeps the active Side Conversation streaming when Stop retracts a queued steer', async () => { - const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + const pendingSteer = deferred<{ kind: 'queued' }>(); let admissionId: string | undefined; let steerCalls = 0; const { container, emit, send, steer, stop, hostTurn } = await renderOwnershipProbe({ send: async () => ({ ok: true as const, turnId: 'old-turn' }), - steer: async (_sessionId, _text, requestedAdmissionId) => { + submitFollowUp: async (_sessionId, _placement, _text, requestedAdmissionId) => { steerCalls += 1; admissionId = requestedAdmissionId; return pendingSteer.promise; @@ -1622,19 +1736,19 @@ test('keeps the active Side Conversation streaming when Stop retracts a queued s assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); await act(async () => { - pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); + pendingSteer.resolve({ kind: 'queued' }); assert.equal(await steerResult, false); await Promise.resolve(); }); }); test('stops the active Side Conversation after retracting its queued steer', async () => { - const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + const pendingSteer = deferred<{ kind: 'queued' }>(); let admissionId: string | undefined; const stoppedTargets: SideChatStopTarget[] = []; const { send, steer, stop, hostTurn } = await renderOwnershipProbe({ send: async () => ({ ok: true as const, turnId: 'old-turn' }), - steer: async (_sessionId, _text, requestedAdmissionId) => { + submitFollowUp: async (_sessionId, _placement, _text, requestedAdmissionId) => { admissionId = requestedAdmissionId; return pendingSteer.promise; }, @@ -1671,21 +1785,21 @@ test('stops the active Side Conversation after retracting its queued steer', asy { kind: 'turn', turnId: 'old-turn' }, ]); await act(async () => { - pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); + pendingSteer.resolve({ kind: 'queued' }); assert.equal(await steerResult, false); await Promise.resolve(); }); }); test('does not let an older Stop failure release a newer active Turn Stop', async () => { - const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + const pendingSteer = deferred<{ kind: 'queued' }>(); const queuedStop = deferred(); const activeStop = deferred(); let admissionId: string | undefined; const stoppedTargets: SideChatStopTarget[] = []; const { emit, send, steer, stop, hostTurn } = await renderOwnershipProbe({ send: async () => ({ ok: true as const, turnId: 'old-turn' }), - steer: async (_sessionId, _text, requestedAdmissionId) => { + submitFollowUp: async (_sessionId, _placement, _text, requestedAdmissionId) => { admissionId = requestedAdmissionId; return pendingSteer.promise; }, @@ -1734,18 +1848,18 @@ test('does not let an older Stop failure release a newer active Turn Stop', asyn activeStop.resolve(undefined); await Promise.all([activeStopResult, duplicateStopResult]); await act(async () => { - pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); + pendingSteer.resolve({ kind: 'queued' }); assert.equal(await steerResult, false); await Promise.resolve(); }); }); test('continues projecting the active Turn while a steer awaits Host admission', async () => { - const pendingSteer = deferred<{ kind: 'queued'; messageId: string }>(); + const pendingSteer = deferred<{ kind: 'queued' }>(); let admissionId: string | undefined; const { container, emit, send, steer, hostTurn } = await renderOwnershipProbe({ send: async () => ({ ok: true as const, turnId: 'old-turn' }), - steer: async (_sessionId, _text, requestedAdmissionId) => { + submitFollowUp: async (_sessionId, _placement, _text, requestedAdmissionId) => { admissionId = requestedAdmissionId; return pendingSteer.promise; }, @@ -1762,6 +1876,11 @@ test('continues projecting the active Turn while a steer awaits Host admission', await Promise.resolve(); }); await waitUntil(() => admissionId !== undefined); + assert.equal(container.firstElementChild?.getAttribute('data-transient-count'), '2'); + assert.equal( + container.firstElementChild?.getAttribute('data-transient-texts'), + 'initial prompt|queue this steer', + ); await act(async () => { emit(textDeltaEvent('old-turn-text', 'old-turn', 1, 'still streaming')); await Promise.resolve(); @@ -1772,10 +1891,1073 @@ test('continues projecting the active Turn while a steer awaits Host admission', assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); await act(async () => { - pendingSteer.resolve({ kind: 'queued', messageId: admissionId as string }); + pendingSteer.resolve({ kind: 'queued' }); + assert.equal(await steerResult, true); + await Promise.resolve(); + }); +}); + +test('keeps an outcome-unknown Side Conversation steer addressable by message identity', async () => { + let admissionId: string | undefined; + const stoppedTargets: SideChatStopTarget[] = []; + const { send, steer, stop, hostTurn } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, placement, _text, requestedAdmissionId) => { + assert.equal(placement, 'current_turn'); + admissionId = requestedAdmissionId; + return { kind: 'outcome_unknown' as const }; + }, + stop: async (_sessionId, target) => { + stoppedTargets.push(target); + return undefined; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + await Promise.resolve(); + }); + await act(async () => { + assert.equal(await steer('uncertain steer'), true); + await stop(); + await Promise.resolve(); + }); + + assert.deepEqual(stoppedTargets, [{ kind: 'admission', messageId: admissionId }]); +}); + +test('recovers the Host-edited Side Conversation steer from the queue projection', async () => { + let admissionId: string | undefined; + const pendingSteer = deferred<{ kind: 'queued' }>(); + const { container, emit, send, steer, hostTurn } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, _placement, _text, requestedAdmissionId) => { + admissionId = requestedAdmissionId; + return pendingSteer.promise; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + await Promise.resolve(); + }); + let steerResult!: Promise; + await act(async () => { + steerResult = steer('queued follow-up'); + await Promise.resolve(); + }); + await waitUntil(() => admissionId !== undefined); + await act(async () => { + emit( + queueUpdateEvent('queued-steer', 'old-turn', 1, [ + { + entryId: 'queued-steer-entry', + messageId: admissionId as string, + content: { text: 'Host-edited follow-up' }, + placement: 'current_turn', + state: 'queued', + }, + ]), + ); + pendingSteer.resolve({ kind: 'queued' }); assert.equal(await steerResult, true); await Promise.resolve(); }); + + assert.equal( + container.firstElementChild?.getAttribute('data-transient-texts'), + 'initial prompt|Host-edited follow-up', + ); + assert.equal(container.firstElementChild?.getAttribute('data-queue-texts'), 'Host-edited follow-up'); + + await act(async () => { + emit({ + type: 'steering_message', + id: 'steering-consumed', + turnId: 'old-turn', + ts: 2, + messageId: admissionId as string, + content: { text: 'Host-edited follow-up' }, + }); + await Promise.resolve(); + }); + + assert.equal(container.firstElementChild?.getAttribute('data-transient-texts'), 'initial prompt'); + assert.equal(container.firstElementChild?.getAttribute('data-queue-texts'), ''); +}); + +test('retracts a queued Side Conversation message without stopping the active turn', async () => { + let messageId: string | undefined; + const retracted: string[] = []; + const { container, emit, send, queue, deleteQueuedEntry, hostTurn } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, _placement, _text, requestedMessageId) => { + messageId = requestedMessageId; + return { kind: 'queued' as const }; + }, + retractQueueEntry: async (_sessionId, entryId) => { + retracted.push(entryId); + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + await Promise.resolve(); + }); + await act(async () => { + assert.equal(await queue('remove me'), true); + emit( + queueUpdateEvent('queued-follow-up', 'old-turn', 1, [], [ + { + entryId: 'follow-up-entry', + messageId: messageId as string, + content: { text: 'remove me' }, + placement: 'next_turn', + state: 'queued', + }, + ]), + ); + await Promise.resolve(); + }); + + await act(async () => { + await deleteQueuedEntry('follow-up-entry'); + await Promise.resolve(); + }); + + assert.deepEqual(retracted, ['follow-up-entry']); + assert.equal(container.firstElementChild?.getAttribute('data-transient-texts'), 'initial prompt'); + assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'old-turn'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); +}); + +test('queues multiple Side Conversation follow-ups while the active turn keeps streaming', async () => { + const submissions: Array<{ placement: string; text: string; messageId: string }> = []; + const { container, send, queue, hostTurn } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, placement, text, messageId) => { + submissions.push({ placement, text, messageId }); + return { kind: 'queued' as const }; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + await Promise.resolve(); + }); + await act(async () => { + assert.equal(await queue('first follow-up'), true); + await Promise.resolve(); + }); + await act(async () => { + assert.equal(await queue('second follow-up'), true); + await Promise.resolve(); + }); + + assert.deepEqual( + submissions.map(({ placement, text }) => ({ placement, text })), + [ + { placement: 'next_turn', text: 'first follow-up' }, + { placement: 'next_turn', text: 'second follow-up' }, + ], + ); + assert.equal( + container.firstElementChild?.getAttribute('data-transient-texts'), + 'initial prompt|first follow-up|second follow-up', + ); + assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'old-turn'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); +}); + +for (const proof of ['started receipt', 'admission event'] as const) { + test(`adopts a queued Side Conversation follow-up that starts after the active turn settles (${proof})`, async () => { + let followUpMessageId: string | undefined; + const pendingFollowUp = deferred<{ kind: 'started'; turnId: string }>(); + const { container, emit, send, queue, hostTurn, transcript } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, placement, _text, messageId) => { + assert.equal(placement, 'next_turn'); + followUpMessageId = messageId; + return pendingFollowUp.promise; + }, + readSettledMessages: async () => ({ messages: [], settled: true }), + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + await Promise.resolve(); + }); + let followUpResult!: Promise; + await act(async () => { + followUpResult = queue('start after settlement'); + await Promise.resolve(); + }); + await waitUntil(() => followUpMessageId !== undefined); + await act(async () => { + hostTurn('old-turn', 'completed'); + emit(completeEvent('old-complete', 'old-turn', 1)); + hostTurn('new-turn'); + if (proof === 'started receipt') { + pendingFollowUp.resolve({ kind: 'started', turnId: 'new-turn' }); + assert.equal(await followUpResult, true); + } else { + emit(messageAdmittedEvent('follow-up-admitted', 'new-turn', 2, followUpMessageId!)); + } + await Promise.resolve(); + }); + + assert.equal(container.firstElementChild?.getAttribute('data-active-turn'), 'new-turn'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); + assert.equal( + container.firstElementChild?.getAttribute('data-transient-texts'), + 'initial prompt|start after settlement', + ); + await act(async () => { + emit(textDeltaEvent('new-turn-text', 'new-turn', 2, 'new answer', 'new-assistant')); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-live-text'), 'new answer'); + assert.match( + transcript().querySelector('[data-transcript-turn-id="new-turn"] .maka-user-message')?.textContent ?? '', + /start after settlement/, + ); + if (proof === 'admission event') { + await act(async () => { + pendingFollowUp.resolve({ kind: 'started', turnId: 'new-turn' }); + assert.equal(await followUpResult, true); + }); + } + }); +} + +for (const proof of ['admission event', 'ownership recovery'] as const) { + test(`places a raced steer before its successor reply after a lost receipt (${proof})`, async () => { + let messageId: string | undefined; + let markSeeded: (() => void) | undefined; + let recoverOwnership = false; + const h = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded) => { + markSeeded = onSeeded; + onSeeded?.(); + return () => undefined; + }, + send: async () => ({ ok: true as const, turnId: 'turn-a' }), + submitFollowUp: async (_sessionId, placement, _text, id) => { + assert.equal(placement, 'current_turn'); + messageId = id; + return { kind: 'outcome_unknown' as const }; + }, + readSettledMessages: async () => { throw new Error('transcript temporarily unavailable'); }, + queryMessageExecutions: async (_sessionId, messageIds) => ({ + resolutions: messageIds.map((id) => recoverOwnership && id === messageId + ? { messageId: id, state: 'owned' as const, turnId: 'turn-b', runId: 'run-b' } + : { messageId: id, state: 'pending' as const }), + }), + }); + await act(async () => { + assert.equal(await h.send('initial prompt'), true); + h.hostTurn('turn-a'); + }); + await act(async () => { assert.equal(await h.steer('raced successor prompt'), true); }); + await act(async () => { + h.hostTurn('turn-b'); + if (proof === 'admission event') { + h.emit(messageAdmittedEvent('admitted-b', 'turn-b', 2, messageId!)); + } else { + recoverOwnership = true; + markSeeded?.(); + } + h.emit({ type: 'text_complete', id: 'answer-event', messageId: 'answer-b', + turnId: 'turn-b', ts: 3, text: 'reply to raced successor' }); + }); + await waitUntil(() => h.container.firstElementChild?.getAttribute('data-processing') === 'false'); + const turn = h.transcript().querySelector('[data-transcript-turn-id="turn-b"]'); + assert.ok(turn); + assert.match(turn.querySelector('.maka-user-message')?.textContent ?? '', /raced successor prompt/); + assert.ok(turn.textContent.includes('reply to raced successor')); + assert.ok(turn.textContent.indexOf('raced successor prompt') < turn.textContent.indexOf('reply to raced successor')); + assert.equal(h.container.firstElementChild?.getAttribute('data-active-turn'), 'turn-b'); + }); +} + +test('reconciles a queued Side Conversation follow-up that settles before its started receipt', async () => { + let followUpMessageId: string | undefined; + let durableMessages: StoredMessage[] = []; + const pendingFollowUp = deferred<{ kind: 'started'; turnId: string }>(); + const { container, emit, send, queue, hostTurn } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, placement, _text, messageId) => { + assert.equal(placement, 'next_turn'); + followUpMessageId = messageId; + return pendingFollowUp.promise; + }, + readSettledMessages: async () => ({ messages: durableMessages, settled: true }), + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + await Promise.resolve(); + }); + let followUpResult!: Promise; + await act(async () => { + followUpResult = queue('late follow-up'); + await Promise.resolve(); + }); + await waitUntil(() => followUpMessageId !== undefined); + await act(async () => { + hostTurn('old-turn', 'completed'); + emit(completeEvent('old-complete', 'old-turn', 1)); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-streaming') === 'false'); + + durableMessages = [ + { + type: 'user', + id: followUpMessageId as string, + turnId: 'new-turn', + ts: 2, + text: 'late follow-up', + }, + { + type: 'assistant', + id: 'new-assistant', + turnId: 'new-turn', + ts: 3, + text: 'new answer', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'new-complete-state', + turnId: 'new-turn', + ts: 4, + status: 'completed', + }, + ]; + await act(async () => { + emit( + messageAdmittedEvent( + 'new-turn-admission', + 'new-turn', + 2, + followUpMessageId as string, + ), + ); + emit(textDeltaEvent('new-turn-text', 'new-turn', 2, 'new answer', 'new-assistant')); + hostTurn('new-turn', 'completed'); + emit(completeEvent('new-complete', 'new-turn', 3)); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'false'); + + await act(async () => { + pendingFollowUp.resolve({ kind: 'started', turnId: 'new-turn' }); + assert.equal(await followUpResult, true); + await Promise.resolve(); + }); + + assert.equal( + container.firstElementChild?.getAttribute('data-message-texts'), + 'late follow-up|new answer', + ); + assert.ok(!container.firstElementChild?.getAttribute('data-live-turn-ids')?.split('|').includes('new-turn')); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'false'); +}); + +for (const failReceiptRead of [false, true]) { + test(`does not re-arm a settled follow-up after ${failReceiptRead ? 'its transcript read fails' : 'it leaves the bounded transcript tail'}`, async () => { + let followUpMessageId: string | undefined; + let durableMessages: StoredMessage[] = []; + const turnBSettlement = deferred<{ + messages: StoredMessage[]; + settled: boolean; + }>(); + const pendingFollowUp = deferred<{ kind: 'started'; turnId: string }>(); + const { container, emit, send, queue, hostTurn } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'turn-a' }), + submitFollowUp: async (_sessionId, placement, _text, messageId) => { + assert.equal(placement, 'next_turn'); + followUpMessageId = messageId; + return pendingFollowUp.promise; + }, + readSettledMessages: async (_sessionId, options) => { + if (failReceiptRead && options?.requiredTurnId === 'turn-b' && options?.requiredAssistantMessageId === undefined) { + throw new Error('Transcript disconnected'); + } + if (options?.requiredAssistantMessageId !== undefined) { + return turnBSettlement.promise; + } + return { messages: durableMessages, settled: true }; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('turn-a'); + await Promise.resolve(); + }); + let followUpResult!: Promise; + await act(async () => { + followUpResult = queue('late follow-up'); + await Promise.resolve(); + }); + await waitUntil(() => followUpMessageId !== undefined); + await act(async () => { + hostTurn('turn-a', 'completed'); + emit(completeEvent('complete-a', 'turn-a', 1)); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-streaming') === 'false'); + + durableMessages = [ + { + type: 'user', + id: followUpMessageId as string, + turnId: 'turn-b', + ts: 2, + text: 'late follow-up', + }, + { + type: 'assistant', + id: 'assistant-b', + turnId: 'turn-b', + ts: 3, + text: 'answer B', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'complete-b-state', + turnId: 'turn-b', + ts: 4, + status: 'completed', + }, + ]; + await act(async () => { + emit(messageAdmittedEvent('admission-b', 'turn-b', 2, followUpMessageId as string)); + emit(textDeltaEvent('text-b', 'turn-b', 3, 'answer B', 'assistant-b')); + hostTurn('turn-b', 'completed'); + emit(completeEvent('complete-b', 'turn-b', 4)); + await Promise.resolve(); + }); + await act(async () => { + turnBSettlement.resolve({ messages: durableMessages, settled: true }); + await Promise.resolve(); + }); + await waitUntil( + () => container.firstElementChild?.getAttribute('data-message-texts') + === 'late follow-up|answer B', + ); + await waitUntil(() => container.firstElementChild?.getAttribute('data-streaming') === 'false'); + + // The next bounded snapshot contains only the later terminal Turn C. The + // panel has already observed and retained B's terminal state, so B's delayed + // started receipt must not make it live again merely because it left the tail. + durableMessages = [ + { + type: 'turn_state', + id: 'complete-c-state', + turnId: 'turn-c', + ts: 5, + status: 'completed', + }, + ]; + await act(async () => { + hostTurn('turn-c'); + emit(messageAdmittedEvent('admission-c', 'turn-c', 5, 'message-c')); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-active-turn') === 'turn-c'); + await act(async () => { + hostTurn('turn-c', 'completed'); + emit(completeEvent('complete-c', 'turn-c', 6)); + await Promise.resolve(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-active-turn') === ''); + + await act(async () => { + pendingFollowUp.resolve({ kind: 'started', turnId: 'turn-b' }); + assert.equal(await followUpResult, true); + await Promise.resolve(); + }); + + assert.ok(!container.firstElementChild?.getAttribute('data-live-turn-ids')?.split('|').includes('turn-b')); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'false'); + assert.equal( + container.firstElementChild?.getAttribute('data-message-texts'), + 'late follow-up|answer B', + ); + }); +} + +test('does not let a late Side Conversation started receipt replace a newer active turn', async () => { + const pendingFollowUp = deferred<{ kind: 'started'; turnId: string }>(); + const { container, emit, send, queue, hostTurn } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async () => pendingFollowUp.promise, + readSettledMessages: async () => ({ messages: [], settled: true }), + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + await Promise.resolve(); + }); + let followUpResult!: Promise; + await act(async () => { + followUpResult = queue('late follow-up'); + await Promise.resolve(); + }); + await act(async () => { + hostTurn('newer-turn'); + emit(messageAdmittedEvent('newer-admission', 'newer-turn', 2, 'newer-message')); + emit(textDeltaEvent('newer-text', 'newer-turn', 3, 'newer answer')); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'newer-turn'); + + await act(async () => { + pendingFollowUp.resolve({ kind: 'started', turnId: 'late-turn' }); + assert.equal(await followUpResult, true); + await Promise.resolve(); + }); + + assert.equal(container.firstElementChild?.getAttribute('data-live-turn-id'), 'newer-turn'); + assert.equal(container.firstElementChild?.getAttribute('data-live-text'), 'newer answer'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); +}); + +test('keeps the settled prior turn visible while a queued successor is running', async () => { + let firstMessageId: string | undefined; + let followUpMessageId: string | undefined; + const oldTurnSettlement = deferred<{ + messages: StoredMessage[]; + settled: boolean; + }>(); + const pendingFollowUp = deferred<{ kind: 'started'; turnId: string }>(); + let stoppedTarget: SideChatStopTarget; + const { container, emit, send, queue, stop, hostTurn } = await renderOwnershipProbe({ + send: async (_sessionId, command) => { + firstMessageId = command.turnId; + return { ok: true as const, turnId: 'old-turn' }; + }, + submitFollowUp: async (_sessionId, placement, _text, messageId) => { + assert.equal(placement, 'next_turn'); + followUpMessageId = messageId; + return pendingFollowUp.promise; + }, + readSettledMessages: async (_sessionId, options) => + options?.requiredAssistantMessageId === 'assistant-message' + ? oldTurnSettlement.promise + : { messages: [], settled: true }, + stop: async (_sessionId, target) => { + stoppedTarget = target; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + emit(textDeltaEvent('old-turn-text', 'old-turn', 1, 'old answer')); + await Promise.resolve(); + }); + let followUpResult!: Promise; + await act(async () => { + followUpResult = queue('start next'); + await Promise.resolve(); + }); + await waitUntil(() => followUpMessageId !== undefined); + await act(async () => { + hostTurn('old-turn', 'completed'); + emit(completeEvent('old-complete', 'old-turn', 2)); + hostTurn('new-turn'); + pendingFollowUp.resolve({ kind: 'started', turnId: 'new-turn' }); + assert.equal(await followUpResult, true); + await Promise.resolve(); + }); + assert.equal(container.firstElementChild?.getAttribute('data-active-turn'), 'new-turn'); + + await act(async () => { + oldTurnSettlement.resolve({ + messages: [ + { + type: 'user', + id: firstMessageId as string, + turnId: 'old-turn', + ts: 1, + text: 'initial prompt', + }, + { + type: 'assistant', + id: 'assistant-message', + turnId: 'old-turn', + ts: 2, + text: 'old answer', + modelId: 'test-model', + }, + ], + settled: true, + }); + await Promise.resolve(); + }); + + await waitUntil( + () => container.firstElementChild?.getAttribute('data-message-texts') === 'initial prompt|old answer', + ); + assert.equal(container.firstElementChild?.getAttribute('data-active-turn'), 'new-turn'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'true'); + await act(async () => { + await stop(); + await Promise.resolve(); + }); + assert.deepEqual(stoppedTarget, { kind: 'turn', turnId: 'new-turn' }); +}); + +test('retires a cancelled queued Side Conversation message after observation reseeds', async () => { + let queuedMessageId: string | undefined; + let markSeeded: (() => void) | undefined; + let seedCount = 0; + const queriedMessageIds: string[][] = []; + const { container, emit, send, queue, hostTurn } = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded) => { + markSeeded = onSeeded; + if (seedCount === 0) { + seedCount += 1; + onSeeded?.(); + } + return () => undefined; + }, + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, placement, _text, messageId) => { + assert.equal(placement, 'next_turn'); + queuedMessageId = messageId; + return { kind: 'queued' as const }; + }, + queryMessageExecutions: async (_sessionId, messageIds) => { + queriedMessageIds.push([...messageIds]); + return { + resolutions: messageIds.map((messageId) => + messageId === queuedMessageId + ? { messageId, state: 'cancelled' as const } + : { messageId, state: 'pending' as const }), + }; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + emit(textDeltaEvent('old-turn-text', 'old-turn', 1, 'still streaming')); + await Promise.resolve(); + }); + await act(async () => { + assert.equal(await queue('cancelled while disconnected'), true); + emit( + queueUpdateEvent('queued-follow-up', 'old-turn', 2, [], [ + { + entryId: 'follow-up-entry', + messageId: queuedMessageId as string, + content: { text: 'cancelled while disconnected' }, + placement: 'next_turn', + state: 'queued', + }, + ]), + ); + markSeeded?.(); + await Promise.resolve(); + }); + await waitUntil( + () => container.firstElementChild?.getAttribute('data-transient-texts') === 'initial prompt', + ); + + assert.equal(container.firstElementChild?.getAttribute('data-queue-texts'), ''); + assert.ok(queriedMessageIds.some((messageIds) => messageIds.includes(queuedMessageId as string))); +}); + +for (const resolutionState of ['cancelled', 'owned'] as const) { + test(`releases an outcome-unknown Side Conversation steer when reseeding proves it ${resolutionState}`, async () => { + let admissionId: string | undefined; + let markSeeded: (() => void) | undefined; + let reconnected = false; + const { container, emit, send, steer, queue, hostTurn } = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded) => { + markSeeded = onSeeded; + onSeeded?.(); + return () => undefined; + }, + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, placement, _text, messageId) => { + if (placement === 'current_turn') admissionId = messageId; + return { kind: 'outcome_unknown' as const }; + }, + readSettledMessages: async () => ({ + messages: reconnected && resolutionState === 'owned' ? [ + { type: 'user' as const, id: admissionId!, turnId: 'old-turn', ts: 2, text: 'uncertain steer' }, + { type: 'turn_state' as const, id: 'old-terminal', turnId: 'old-turn', ts: 3, status: 'completed' as const }, + ] : [], + settled: true, + }), + queryMessageExecutions: async (_sessionId, messageIds) => ({ + resolutions: messageIds.map((messageId) => messageId === admissionId + ? resolutionState === 'cancelled' + ? { messageId, state: 'cancelled' as const } + : { messageId, state: 'owned' as const, turnId: 'old-turn', runId: 'old-run' } + : { messageId, state: 'pending' as const }), + }), + }); + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + }); + await act(async () => { + assert.equal(await steer('uncertain steer'), true); + assert.equal(await queue('still unproven'), true); + }); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); + await act(async () => { + reconnected = true; + hostTurn('old-turn', 'completed'); + emit(completeEvent('old-completed', 'old-turn', 3)); + markSeeded?.(); + }); + await waitUntil(() => container.firstElementChild?.getAttribute('data-transient-texts') === 'initial prompt|still unproven'); + assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'false'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'false'); + await act(async () => { + assert.equal(await send('next prompt'), true); + }); + }); +} + +test('retires durable Side Conversation identities before observation recovery queries', async () => { + let rootMessageId: string | undefined; + let followUpMessageId: string | undefined; + let markSeeded: (() => void) | undefined; + let durableMessages: StoredMessage[] = []; + const queriedMessageIds: string[][] = []; + const { container, emit, send, queue, hostTurn } = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded) => { + markSeeded = onSeeded; + onSeeded?.(); + return () => undefined; + }, + send: async (_sessionId, command) => { + rootMessageId = command.turnId; + return { ok: true as const, turnId: 'turn-a' }; + }, + submitFollowUp: async (_sessionId, placement, _text, messageId) => { + assert.equal(placement, 'next_turn'); + followUpMessageId = messageId; + return { kind: 'queued' as const }; + }, + readSettledMessages: async () => ({ messages: durableMessages, settled: true }), + queryMessageExecutions: async (_sessionId, messageIds) => { + queriedMessageIds.push([...messageIds]); + return { + resolutions: messageIds.map((messageId) => ({ + messageId, + state: 'pending' as const, + })), + }; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('turn-a'); + await Promise.resolve(); + }); + await act(async () => { + assert.equal(await queue('follow-up'), true); + await Promise.resolve(); + }); + const durableRootMessageId = rootMessageId; + const durableFollowUpMessageId = followUpMessageId; + assert.ok(durableRootMessageId); + assert.ok(durableFollowUpMessageId); + durableMessages = [ + { + type: 'user', + id: durableRootMessageId, + turnId: 'turn-a', + ts: 1, + text: 'initial prompt', + }, + { + type: 'assistant', + id: 'assistant-a', + turnId: 'turn-a', + ts: 2, + text: 'answer A', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'complete-a', + turnId: 'turn-a', + ts: 3, + status: 'completed', + }, + { + type: 'user', + id: durableFollowUpMessageId, + turnId: 'turn-b', + ts: 4, + text: 'follow-up', + }, + { + type: 'assistant', + id: 'assistant-b', + turnId: 'turn-b', + ts: 5, + text: 'answer B', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'complete-b', + turnId: 'turn-b', + ts: 6, + status: 'completed', + }, + ]; + + await act(async () => { + hostTurn('turn-a', 'completed'); + emit(completeEvent('event-complete-a', 'turn-a', 3)); + emit(messageAdmittedEvent('admission-b', 'turn-b', 4, durableFollowUpMessageId)); + hostTurn('turn-b', 'completed'); + emit(completeEvent('event-complete-b', 'turn-b', 6)); + await Promise.resolve(); + }); + await waitUntil( + () => container.firstElementChild?.getAttribute('data-message-texts') + === 'initial prompt|answer A|follow-up|answer B', + ); + assert.equal(container.firstElementChild?.getAttribute('data-transient-count'), '0'); + + queriedMessageIds.length = 0; + await act(async () => { + markSeeded?.(); + await Promise.resolve(); + }); + assert.deepEqual(queriedMessageIds, []); +}); + +test('recovers every queued Side Conversation successor across one observation gap', async () => { + let rootMessageId: string | undefined; + let firstFollowUpId: string | undefined; + let secondFollowUpId: string | undefined; + let markSeeded: (() => void) | undefined; + let durableMessages: StoredMessage[] = []; + const pendingFirstFollowUp = deferred<{ kind: 'started'; turnId: string }>(); + const executionQueries: string[][] = []; + const targetedTurnReads: string[] = []; + const { container, emit, send, queue, hostTurn } = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded) => { + markSeeded = onSeeded; + onSeeded?.(); + return () => undefined; + }, + send: async (_sessionId, command) => { + rootMessageId = command.turnId; + return { ok: true as const, turnId: 'turn-a' }; + }, + submitFollowUp: async (_sessionId, placement, text, messageId) => { + assert.equal(placement, 'next_turn'); + if (text === 'follow-up one') { + firstFollowUpId = messageId; + return pendingFirstFollowUp.promise; + } + secondFollowUpId = messageId; + return { kind: 'queued' as const }; + }, + readSettledMessages: async (_sessionId, options) => { + if (options?.requiredTurnId === 'turn-b') { + targetedTurnReads.push(options.requiredTurnId); + return { + messages: [ + { + type: 'user', + id: firstFollowUpId as string, + turnId: 'turn-b', + ts: 3, + text: 'follow-up one', + }, + { + type: 'assistant', + id: 'assistant-b', + turnId: 'turn-b', + ts: 4, + text: 'answer B', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'complete-b', + turnId: 'turn-b', + ts: 5, + status: 'completed', + }, + { + type: 'user', + id: secondFollowUpId as string, + turnId: 'turn-c', + ts: 6, + text: 'follow-up two', + }, + { + type: 'assistant', + id: 'assistant-c', + turnId: 'turn-c', + ts: 7, + text: 'answer C', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'complete-c', + turnId: 'turn-c', + ts: 8, + status: 'completed', + }, + ], + settled: true, + }; + } + return { messages: durableMessages, settled: true }; + }, + queryMessageExecutions: async (_sessionId, messageIds) => { + executionQueries.push([...messageIds]); + return { + resolutions: messageIds.map((messageId) => ({ + messageId, + state: 'owned' as const, + turnId: messageId === firstFollowUpId ? 'turn-b' : 'turn-c', + runId: messageId === firstFollowUpId ? 'run-b' : 'run-c', + })), + }; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('turn-a'); + await Promise.resolve(); + }); + let firstFollowUpResult!: Promise; + await act(async () => { + firstFollowUpResult = queue('follow-up one'); + await Promise.resolve(); + }); + await waitUntil(() => firstFollowUpId !== undefined); + await act(async () => { + assert.equal(await queue('follow-up two'), true); + await Promise.resolve(); + }); + const durableFirstFollowUpId = firstFollowUpId; + const durableSecondFollowUpId = secondFollowUpId; + const durableRootMessageId = rootMessageId; + assert.ok(durableRootMessageId); + assert.ok(durableFirstFollowUpId); + assert.ok(durableSecondFollowUpId); + durableMessages = [ + { + type: 'user', + id: durableRootMessageId, + turnId: 'turn-a', + ts: 0, + text: 'initial prompt', + }, + { + type: 'assistant', + id: 'assistant-a', + turnId: 'turn-a', + ts: 1, + text: 'answer A', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'complete-a', + turnId: 'turn-a', + ts: 2, + status: 'completed', + }, + ]; + + await act(async () => { + hostTurn('turn-a', 'completed'); + emit(completeEvent('event-complete-a', 'turn-a', 2)); + await Promise.resolve(); + }); + await waitUntil( + () => container.firstElementChild?.getAttribute('data-message-texts') + === 'initial prompt|answer A', + ); + + // Both queued successors finish while observation is unavailable, but the + // bounded recovery tail contains only the later terminal Turn C. + durableMessages = [ + { + type: 'user', + id: durableSecondFollowUpId, + turnId: 'turn-c', + ts: 6, + text: 'follow-up two', + }, + { + type: 'assistant', + id: 'assistant-c', + turnId: 'turn-c', + ts: 7, + text: 'answer C', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'complete-c', + turnId: 'turn-c', + ts: 8, + status: 'completed', + }, + ]; + + await act(async () => { + // Replacement currently replays only the latest terminal root admission. + emit(messageAdmittedEvent('admission-c', 'turn-c', 6, durableSecondFollowUpId)); + hostTurn('turn-c', 'completed'); + emit(completeEvent('event-complete-c', 'turn-c', 8)); + markSeeded?.(); + await Promise.resolve(); + }); + + await waitUntil(() => targetedTurnReads.length > 0); + assert.ok( + executionQueries.some((messageIds) => + messageIds.includes(durableFirstFollowUpId)), + ); + assert.deepEqual(targetedTurnReads, ['turn-b']); + assert.equal( + container.firstElementChild?.getAttribute('data-message-texts'), + 'initial prompt|answer A|follow-up one|answer B|follow-up two|answer C', + ); + assert.equal(container.firstElementChild?.getAttribute('data-transient-count'), '0'); + assert.equal(container.firstElementChild?.getAttribute('data-streaming'), 'false'); + + await act(async () => { + pendingFirstFollowUp.resolve({ kind: 'started', turnId: 'turn-b' }); + assert.equal(await firstFollowUpResult, true); + await Promise.resolve(); + }); + assert.ok(!container.firstElementChild?.getAttribute('data-live-turn-ids')?.split('|').includes('turn-b')); }); test('fails a send when observation seed rejects and resubscribes for retry', async () => { @@ -2068,8 +3250,11 @@ function QuoteCompanionProbe(props: { function QuoteCompanionOwnershipProbe(props: { onSend: (send: (text: string) => Promise) => void; + onProjection?: (companion: ReturnType) => void; + onQueue?: (queue: (text: string) => Promise) => void; onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; + onDeleteQueuedEntry?: (deleteEntry: (entryId: string) => Promise) => void; onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; onContextCompactionError?: (sessionId: string, error: unknown) => void; pendingQuotes?: readonly StagedCompanionQuote[]; @@ -2089,13 +3274,17 @@ function QuoteCompanionOwnershipProbe(props: { onContextCompactionError: props.onContextCompactionError, }); props.onSend(companion.send); + props.onProjection?.(companion); + props.onQueue?.(companion.queue); props.onSteer?.(companion.steer); props.onStop?.(companion.stop); + props.onDeleteQueuedEntry?.(companion.deleteQueuedEntry); props.onSetPermissionMode?.(companion.setPermissionMode); return createElement('div', { 'data-companion-id': companion.companionSession?.id ?? '', 'data-error': companion.error ?? '', 'data-live-turn-id': companion.liveTurns?.at(-1)?.turnId ?? '', + 'data-live-turn-ids': companion.liveTurns?.map((turn) => turn.turnId).join('|') ?? '', 'data-live-text': companion.liveTurns?.at(-1)?.steps.find((step) => step.text)?.text?.text ?? '', 'data-streaming': String(companion.streaming), 'data-active-turn': companion.activeTurn?.turnId ?? '', @@ -2104,6 +3293,11 @@ function QuoteCompanionOwnershipProbe(props: { 'data-permission-mode': companion.permissionMode ?? '', 'data-transient-count': String(companion.transientMessages.length), 'data-transient-text': companion.transientMessages[0]?.text ?? '', + 'data-transient-texts': companion.transientMessages.map((message) => message.text).join('|'), + 'data-message-texts': companion.messages + .flatMap((message) => 'text' in message && typeof message.text === 'string' ? [message.text] : []) + .join('|'), + 'data-queue-texts': companion.queuedMessages?.map((entry) => entry.content.text).join('|') ?? '', }); } 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 3d7bebd50f..fc8683793a 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 @@ -29,6 +29,7 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { type AttachmentRef } from '@maka/core/events'; import { + MESSAGE_QUEUE_MAX_ENTRIES, SESSION_CONTINUITY_SCHEMA_VERSION, type SessionCatalogProjection, } from "@maka/runtime-host/protocol"; @@ -1040,14 +1041,18 @@ test("submits an ordinary composer message once under its stable message identit test('returns Host-owned cancellation proof to the renderer', async () => { const ipc = ipcHarness(); + const queriedMessageIds: string[][] = []; registerExecutionIpc( { client: executionClient({ - queryMessages: async (input) => ({ - cancelledMessageIds: input.messageIds.filter( + queryMessages: async (input) => { + queriedMessageIds.push([...input.messageIds]); + return { + cancelledMessageIds: input.messageIds.filter( (messageId) => messageId === 'message-cancelled', - ), - }), + ), + }; + }, }), }, ipc, @@ -1060,6 +1065,109 @@ test('returns Host-owned cancellation proof to the renderer', async () => { ]), { cancelledMessageIds: ['message-cancelled'] }, ); + assert.deepEqual(queriedMessageIds, [['message-accepted', 'message-cancelled']]); +}); + +test('batches cancellation proof queries at the Runtime Host protocol boundary', async () => { + const ipc = ipcHarness(); + const queriedMessageIds: string[][] = []; + registerExecutionIpc( + { + client: executionClient({ + queryMessages: async (input) => { + queriedMessageIds.push([...input.messageIds]); + return { cancelledMessageIds: input.messageIds.slice(-1) }; + }, + }), + }, + ipc, + ); + const messageIds = Array.from({ length: 65 }, (_, index) => `message-${index}`); + + assert.deepEqual( + await ipc.invoke('sessions:queryCancelledMessages', 'session-1', messageIds), + { cancelledMessageIds: ['message-63', 'message-64'] }, + ); + assert.deepEqual(queriedMessageIds.map((ids) => ids.length), [64, 1]); +}); + +test('rejects duplicate cancellation proof identities across protocol batches', async () => { + const ipc = ipcHarness(); + let queryCount = 0; + registerExecutionIpc( + { + client: executionClient({ + queryMessages: async () => { + queryCount += 1; + return { cancelledMessageIds: [] }; + }, + }), + }, + ipc, + ); + const messageIds = Array.from({ length: 65 }, (_, index) => `message-${index}`); + messageIds[64] = messageIds[0] as string; + + await assert.rejects( + ipc.invoke('sessions:queryCancelledMessages', 'session-1', messageIds), + /Duplicate Message identities/u, + ); + assert.equal(queryCount, 0); +}); + +test('rejects invalid or unbounded Desktop cancellation proof queries before dispatch', async () => { + const ipc = ipcHarness(); + let queryCount = 0; + registerExecutionIpc( + { + client: executionClient({ + queryMessages: async () => { + queryCount += 1; + return { cancelledMessageIds: [] }; + }, + }), + }, + ipc, + ); + + await assert.rejects( + ipc.invoke('sessions:queryCancelledMessages', 'session-1', ['valid-message', 42]), + /Invalid Message identity/u, + ); + await assert.rejects( + ipc.invoke('sessions:queryCancelledMessages', 'session-1', ['not a protocol identity']), + /Invalid Message identity/u, + ); + await assert.rejects( + ipc.invoke( + 'sessions:queryCancelledMessages', + 'session-1', + Array.from({ length: 4_097 }, (_, index) => `message-${index}`), + ), + /Invalid Message identities/u, + ); + assert.equal(queryCount, 0); +}); + +test('rejects duplicate cancellation proofs returned across protocol batches', async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + queryMessages: async () => ({ cancelledMessageIds: ['message-0'] }), + }), + }, + ipc, + ); + + await assert.rejects( + ipc.invoke( + 'sessions:queryCancelledMessages', + 'session-1', + Array.from({ length: 65 }, (_, index) => `message-${index}`), + ), + /Duplicate cancelled Message identities/u, + ); }); test('returns Host-owned Message execution resolutions to the renderer', async () => { @@ -1101,6 +1209,117 @@ test('returns Host-owned Message execution resolutions to the renderer', async ( ); }); +test('batches Message execution queries at the Runtime Host protocol boundary', async () => { + const ipc = ipcHarness(); + const queriedMessageIds: string[][] = []; + registerExecutionIpc( + { + client: executionClient({ + queryMessageExecutions: async (input) => { + queriedMessageIds.push([...input.messageIds]); + assert.ok(input.messageIds.length <= MESSAGE_QUEUE_MAX_ENTRIES); + return { + resolutions: input.messageIds.map((messageId) => { + if (messageId === 'message-0') { + return { + messageId, + state: 'owned' as const, + turnId: 'turn-0', + runId: 'run-0', + }; + } + if (messageId === 'message-64') { + return { messageId, state: 'cancelled' as const }; + } + return { messageId, state: 'pending' as const }; + }), + }; + }, + }), + }, + ipc, + ); + const messageIds = Array.from({ length: 65 }, (_, index) => `message-${index}`); + + const result = await ipc.invoke( + 'sessions:queryMessageExecutions', + 'session-1', + messageIds, + ) as { resolutions: unknown[] }; + + assert.deepEqual(queriedMessageIds.map((ids) => ids.length), [64, 1]); + assert.deepEqual(result.resolutions, messageIds.map((messageId) => { + if (messageId === 'message-0') { + return { + messageId, + state: 'owned', + turnId: 'turn-0', + runId: 'run-0', + }; + } + if (messageId === 'message-64') return { messageId, state: 'cancelled' }; + return { messageId, state: 'pending' }; + })); +}); + +test('rejects duplicate Message execution identities before protocol batching', async () => { + const ipc = ipcHarness(); + let queryCount = 0; + registerExecutionIpc( + { + client: executionClient({ + queryMessageExecutions: async () => { + queryCount += 1; + return { resolutions: [] }; + }, + }), + }, + ipc, + ); + const messageIds = Array.from({ length: 65 }, (_, index) => `message-${index}`); + messageIds[64] = messageIds[0] as string; + + await assert.rejects( + ipc.invoke('sessions:queryMessageExecutions', 'session-1', messageIds), + /Duplicate Message identities/u, + ); + assert.equal(queryCount, 0); +}); + +test('rejects invalid or unbounded Desktop Message execution queries before dispatch', async () => { + const ipc = ipcHarness(); + let queryCount = 0; + registerExecutionIpc( + { + client: executionClient({ + queryMessageExecutions: async () => { + queryCount += 1; + return { resolutions: [] }; + }, + }), + }, + ipc, + ); + + await assert.rejects( + ipc.invoke('sessions:queryMessageExecutions', 'session-1', ['valid-message', 42]), + /Invalid Message identity/u, + ); + await assert.rejects( + ipc.invoke('sessions:queryMessageExecutions', 'session-1', ['not a protocol identity']), + /Invalid Message identity/u, + ); + await assert.rejects( + ipc.invoke( + 'sessions:queryMessageExecutions', + 'session-1', + Array.from({ length: 4_097 }, (_, index) => `message-${index}`), + ), + /Invalid Message identities/u, + ); + assert.equal(queryCount, 0); +}); + test('submits a slash Skill message and reports the Host Skill outcome', async () => { const submits: unknown[] = []; const ipc = ipcHarness(); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 37c2bc7b4f..3c657aafb9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -862,7 +862,6 @@ test('broadcasts durable admission and transcript changes from the same message' turnId: 'turn-1', ts: 2, text: 'Continue here', - steeringEventId: 'steering-event-1', }; const observer = new RuntimeHostSessionObserver({ client: { @@ -957,6 +956,69 @@ test('broadcasts durable admission and transcript changes from the same message' await observer.close(); }); +for (const resolution of ['owned', 'cancelled', 'pending', 'unavailable'] as const) { + test(`proves a removed follow-up is ${resolution} before successor content`, async (t) => { + const events = new AsyncFrameQueue(); + const queries: string[][] = []; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => runtimeHostSessionFixture({ + snapshot: continuitySnapshot({ + queue: { + hostEpoch: 'host-1', queueRevision: 1, steering: [], + followup: [{ + entryId: 'entry-1', messageId: 'followup-1', content: { text: 'Next question' }, + placement: 'next_turn', state: 'queued', + }], + }, + }), + transcript: Promise.resolve([]), events, async close() { events.end(); }, + }), + queryMessageExecutions: async ({ messageIds }) => { + queries.push([...messageIds]); + if (resolution === 'unavailable') throw new Error('Host proof unavailable'); + return { resolutions: messageIds.map((messageId) => resolution === 'owned' + ? { messageId, state: 'owned' as const, turnId: 'turn-2', runId: 'run-2' } + : { messageId, state: resolution }) }; + }, + }, + emitSessionsChanged() {}, + }); + t.after(() => observer.close()); + const target = eventTarget(25); + await observer.observe('session-1', 'observer-followup', target, true); + target.events.splice(0); + events.push({ + kind: 'subscription.session_projection', hostEpoch: 'host-1', + subscriptionId: 'subscription-1', sequence: 1, + snapshot: continuitySnapshot({ + projectionRevision: 2, + rootTurn: { sessionId: 'session-1', turnId: 'turn-2', runId: 'run-2', status: 'running' }, + queue: { hostEpoch: 'host-1', queueRevision: 2, steering: [], followup: [] }, + }), + }); + events.push({ + kind: 'subscription.session_delta', hostEpoch: 'host-1', + subscriptionId: 'subscription-1', sessionId: 'session-1', sequence: 2, + delta: { kind: 'text', turnId: 'turn-2', runId: 'run-2', messageId: 'answer-2', startOffset: 0, text: 'Next answer' }, + }); + await waitFor(() => target.events.some((event) => event.type === 'text_delta')); + + assert.deepEqual(queries, [['followup-1']]); + const admissions = target.events.filter((event) => event.type === 'message_admission'); + assert.deepEqual(admissions.map((event) => ({ + messageId: event.messageId, turnId: event.turnId, outcome: event.outcome, + })), resolution === 'owned' || resolution === 'cancelled' ? [{ + messageId: 'followup-1', turnId: 'turn-2', + outcome: resolution === 'owned' ? 'admitted' : 'retracted', + }] : []); + if (admissions.length > 0) { + assert.ok(target.events.indexOf(admissions[0]!) + < target.events.findIndex((event) => event.type === 'text_delta')); + } + }); +} + test('moves the read marker only as far as the Renderer window reports reaching', async () => { const events = new AsyncFrameQueue(); const markers: string[] = []; @@ -2722,6 +2784,109 @@ test("reconciles terminal, Goal, interaction, and sidecar state after subscripti await observer.close(); }); +test('replays durable admission before a terminal successor on subscription recovery', async () => { + const firstEvents = new AsyncFrameQueue(); + const secondEvents = new AsyncFrameQueue(); + const recoveredSessions: string[] = []; + const terminalTranscript: StoredMessage[] = [ + { + type: 'user', + id: 'follow-up-message', + turnId: 'turn-2', + ts: 20, + text: 'Continue', + }, + { + type: 'assistant', + id: 'assistant-2', + turnId: 'turn-2', + ts: 30, + text: 'Done', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'terminal-2', + turnId: 'turn-2', + ts: 40, + status: 'completed', + }, + ]; + let openCount = 0; + const observer = new RuntimeHostSessionObserver({ + client: { + listSessionTurns: async () => [{ + turnId: 'turn-1', + status: 'completed' as const, + statusSource: 'recorded' as const, + }], + openSession: async () => { + openCount += 1; + if (openCount === 1) { + return runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: firstEvents, + async close() { + firstEvents.end(); + }, + }); + } + return runtimeHostSessionFixture({ + snapshot: continuitySnapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-2', + runId: 'run-2', + status: 'completed', + terminalEventId: 'terminal-2', + }, + }), + transcript: Promise.resolve(terminalTranscript), + loadTranscriptOverlay: async () => terminalTranscript, + events: secondEvents, + async close() { + secondEvents.end(); + }, + }); + }, + }, + emitSessionsChanged() {}, + emitSubscriptionRecovered: (sessionId) => { + recoveredSessions.push(sessionId); + }, + now: () => 50, + }); + const target = eventTarget(23); + await observer.observe('session-1', 'observer-1', target, true); + + firstEvents.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + reason: 'slow_consumer', + }); + await waitFor(() => recoveredSessions.length === 1); + + assert.deepEqual( + target.events + .filter((event) => event.turnId === 'turn-2') + .map((event) => event.type), + ['message_admission', 'queue_update', 'text_complete', 'complete'], + ); + const admission = target.events.find( + (event): event is Extract => + event.type === 'message_admission', + ); + assert.deepEqual( + admission && { messageId: admission.messageId, turnId: admission.turnId }, + { messageId: 'follow-up-message', turnId: 'turn-2' }, + ); + await observer.close(); +}); + test("shares one Host subscription and one delivery per renderer target", async () => { const events = new AsyncFrameQueue(); let openCount = 0; diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index 13cfa17ee3..961938d14d 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -21,10 +21,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; +import { deriveMessageQueueProjection } from '../../renderer/application/contracts/message-queue-projection.js'; import { mergeTransientMessageProjection, + projectQueuedTransientMessages, reconcileTransientMessages, -} from '../../renderer/transient-message-projection.js'; +} from '../../renderer/application/contracts/transient-message-projection.js'; /** The durable Message that replaces the transient row above. */ function canonicalSend(): StoredMessage { @@ -116,6 +118,84 @@ test('keeps a transient message out of a sparse historical range', () => { 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('derives one queue projection for main and Side Conversation consumers', () => { + const projection = deriveMessageQueueProjection({ + type: 'queue_update', + id: 'queue-1', + turnId: 'turn-1', + ts: 7, + steering: ['in flight', 'steer'], + followup: ['next'], + steeringEntries: [ + { + entryId: 'in-flight', + messageId: 'message-in-flight', + content: { text: 'in flight' }, + placement: 'current_turn', + state: 'in_flight', + }, + { + entryId: 'steer', + messageId: 'message-steer', + content: { text: 'raw', displayText: 'steer', quotes: [{ text: 'context' }] }, + placement: 'current_turn', + state: 'queued', + }, + ], + followupEntries: [ + { + entryId: 'next', + messageId: 'message-next', + content: { text: 'next' }, + placement: 'next_turn', + state: 'queued', + }, + ], + }); + + assert.deepEqual(projection.entries.map((entry) => entry.entryId), ['steer', 'next']); + assert.deepEqual(projection.transientMessages, [ + { + id: 'message-steer', + pendingSteering: true, + transientPlacement: 'current_turn', + hostTurnId: 'turn-1', + ts: 7, + text: 'steer', + quotes: [{ text: 'context' }], + }, + { + id: 'message-next', + transientPlacement: 'next_turn', + ts: 7, + text: 'next', + }, + ]); +}); + test('keeps a Host-bound current Turn when a later IPC result has no Turn identity', () => { const hostBound = { ...transient, id: 'message-current', hostTurnId: 'host-turn' }; const lateIpcUpdate = { ...transient, id: 'message-current', text: 'uploaded content' }; diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 6e92fabf11..9156fdb9a5 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -79,6 +79,30 @@ function createBridgeRecorder(): { } describe('createDesktopWorkbarServices', () => { + it('waits for Host admission before accepting a Side Conversation follow-up', async () => { + const { bridge, calls } = createBridgeRecorder(); + const services = createDesktopWorkbarServices(bridge, { + readSettledMessages: async () => ({ messages: [], settled: true }), + }); + + await services.sideChat.submitFollowUp( + 'fork', + 'next_turn', + 'later', + 'message-next', + ); + + assert.deepEqual( + calls.find((call) => call.name === 'sessions.submitMessage')?.args, + [ + 'fork', + 'next_turn', + { messageId: 'message-next', text: 'later' }, + { waitForHostAdmission: true }, + ], + ); + }); + it('preserves the Side Conversation Stop identity kind', async () => { const { bridge, calls } = createBridgeRecorder(); const services = createDesktopWorkbarServices(bridge, { @@ -97,6 +121,30 @@ describe('createDesktopWorkbarServices', () => { ); }); + it('reports Side Conversation readiness again after an observation reseed', () => { + const { bridge, calls } = createBridgeRecorder(); + const services = createDesktopWorkbarServices(bridge, { + readSettledMessages: async () => ({ messages: [], settled: true }), + }); + let readyCount = 0; + + services.sideChat.subscribeEvents('fork', () => undefined, () => { + readyCount += 1; + })(); + + const subscribe = calls.find((call) => call.name === 'sessions.subscribeEvents'); + assert.ok(subscribe); + const observationSeed = subscribe.args[2] as + | ((phase: 'pending' | 'ready') => void) + | undefined; + observationSeed?.('pending'); + observationSeed?.('ready'); + observationSeed?.('pending'); + observationSeed?.('ready'); + + assert.equal(readyCount, 2); + }); + it('maps every Workbar capability to the existing Desktop bridge', async () => { const { bridge, calls } = createBridgeRecorder(); const settledReads: unknown[][] = []; @@ -151,6 +199,7 @@ describe('createDesktopWorkbarServices', () => { await services.sideChat.listTurns('s'); await services.sideChat.readSettledMessages('s', { requiredAssistantMessageId: 'message', + requiredTurnId: 'turn', }); await services.sideChat.branchFromTurn('s', { sourceTurnId: 'turn', @@ -166,7 +215,23 @@ describe('createDesktopWorkbarServices', () => { text: 'hello', }); await services.sideChat.stop('fork'); - await services.sideChat.steer('fork', 'more'); + const nextFollowUp = await services.sideChat.submitFollowUp( + 'fork', + 'next_turn', + 'later', + 'message-next', + ); + const currentFollowUp = await services.sideChat.submitFollowUp( + 'fork', + 'current_turn', + 'more', + 'message-current', + ); + await services.sideChat.queryMessageExecutions('fork', ['message-next']); + await services.sideChat.retractQueueEntry('fork', 'entry-1'); + await services.sideChat.promoteQueueEntry('fork', 'entry-2'); + await services.sideChat.updateQueueEntry('fork', 'entry-3', 4, 'updated'); + await services.sideChat.reorderQueueEntries('fork', ['entry-3', 'entry-2']); await services.sideChat.setPermissionMode('fork', 'ask'); await services.sideChat.regenerateTurn('fork', { sourceTurnId: 'turn-2', @@ -223,6 +288,12 @@ describe('createDesktopWorkbarServices', () => { 'sessions.send', 'sessions.stop', 'sessions.submitMessage', + 'sessions.submitMessage', + 'sessions.queryMessageExecutions', + 'sessions.retractQueueEntry', + 'sessions.promoteQueueEntry', + 'sessions.updateQueueEntry', + 'sessions.reorderQueueEntries', 'sessions.setPermissionMode', 'sessions.regenerateTurn', 'sessions.respondToSandboxBoundary', @@ -244,10 +315,29 @@ describe('createDesktopWorkbarServices', () => { 's', 'cursor-1', ]); - assert.equal(settledReads[0]?.[0], bridge.transcripts); + assert.equal(settledReads[0]?.[0], bridge); assert.deepEqual(settledReads[0]?.slice(1), [ 's', - { requiredAssistantMessageId: 'message' }, + { requiredAssistantMessageId: 'message', requiredTurnId: 'turn' }, + ]); + const followUpCalls = calls.filter((call) => call.name === 'sessions.submitMessage'); + assert.deepEqual(followUpCalls[0]?.args, [ + 'fork', + 'next_turn', + { messageId: 'message-next', text: 'later' }, + { waitForHostAdmission: true }, ]); + assert.deepEqual(followUpCalls[1]?.args, [ + 'fork', + 'current_turn', + { messageId: 'message-current', text: 'more' }, + { waitForHostAdmission: true }, + ]); + assert.deepEqual(nextFollowUp, { kind: 'queued' }); + assert.deepEqual(currentFollowUp, { kind: 'queued' }); + assert.deepEqual( + calls.find((call) => call.name === 'sessions.queryMessageExecutions')?.args, + ['fork', ['message-next']], + ); }); }); diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index e0f5dedfdb..6381c6311b 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -482,6 +482,8 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main const block = (e) => { const target = e.target instanceof Element ? e.target : e.target?.parentElement; if (target?.closest('[data-maka-file-drop-target="true"]')) return; + if (target?.closest('[data-maka-queue-drop-target="true"]') + && e.dataTransfer?.types.includes('application/x-maka-queue-entry')) return; e.preventDefault(); e.stopPropagation(); }; 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 1bf4d099f5..f11cdc238e 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -25,6 +25,10 @@ import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, } from '@maka/runtime-host/client'; +import { + MESSAGE_QUEUE_MAX_ENTRIES, + type TurnMessageExecutionResolution, +} from '@maka/runtime-host/protocol'; import { type SessionChangedEvent, type SessionChangedReason, @@ -122,6 +126,7 @@ type RuntimeHostSessionExecutionClient = Pick< /** No Skill was named, so the Host resolved none. */ const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] } as const; +const DESKTOP_MESSAGE_QUERY_MAX_ENTRIES = 4_096; async function submitMessageWithReconnect( client: Pick, @@ -297,16 +302,47 @@ export function registerRuntimeHostSessionExecutionIpc( ipcMain.handle( 'sessions:queryCancelledMessages', async (_event, sessionId: string, messageIds: unknown) => { - if (!Array.isArray(messageIds)) throw new Error('Invalid Message identities'); - return deps.client.queryMessages({ sessionId, messageIds }); + const normalizedSessionId = requiredId(sessionId, 'Session'); + const normalizedMessageIds = requiredMessageIds(messageIds); + // Keep the transport limit at the Runtime Host seam so renderer callers + // can query their complete optimistic projection as one operation. + const cancelledMessageIds: string[] = []; + for ( + let from = 0; + from < normalizedMessageIds.length; + from += MESSAGE_QUEUE_MAX_ENTRIES + ) { + const result = await deps.client.queryMessages({ + sessionId: normalizedSessionId, + messageIds: normalizedMessageIds.slice(from, from + MESSAGE_QUEUE_MAX_ENTRIES), + }); + cancelledMessageIds.push(...result.cancelledMessageIds); + } + if (new Set(cancelledMessageIds).size !== cancelledMessageIds.length) { + throw new Error('Duplicate cancelled Message identities'); + } + return { cancelledMessageIds }; }, ); ipcMain.handle( 'sessions:queryMessageExecutions', async (_event, sessionId: string, messageIds: unknown) => { - if (!Array.isArray(messageIds)) throw new Error('Invalid Message identities'); - return deps.client.queryMessageExecutions({ sessionId, messageIds }); + const normalizedSessionId = requiredId(sessionId, 'Session'); + const normalizedMessageIds = requiredMessageIds(messageIds); + const resolutions: TurnMessageExecutionResolution[] = []; + for ( + let from = 0; + from < normalizedMessageIds.length; + from += MESSAGE_QUEUE_MAX_ENTRIES + ) { + const result = await deps.client.queryMessageExecutions({ + sessionId: normalizedSessionId, + messageIds: normalizedMessageIds.slice(from, from + MESSAGE_QUEUE_MAX_ENTRIES), + }); + resolutions.push(...result.resolutions); + } + return { resolutions }; }, ); @@ -1010,6 +1046,24 @@ function requiredId(value: unknown, label: string): string { return value; } +function requiredMessageIds(value: unknown): string[] { + if (!Array.isArray(value) || value.length > DESKTOP_MESSAGE_QUERY_MAX_ENTRIES) { + throw new Error('Invalid Message identities'); + } + const messageIds = value.map(requiredMessageId); + if (new Set(messageIds).size !== messageIds.length) { + throw new Error('Duplicate Message identities'); + } + return messageIds; +} + +function requiredMessageId(value: unknown): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value)) { + throw new Error('Invalid Message identity'); + } + return value; +} + function requiredText(value: unknown, label: string): string { if ( typeof value !== "string" || diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 1662bc9334..1ca5390129 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -66,7 +66,7 @@ import { } from './desktop-transcript-ipc.js'; type SessionObserverClient = Pick & - Partial>; + Partial>; const TRANSCRIPT_DELIVERY_TIMEOUT_MS = 30_000; const TRANSCRIPT_DELIVERY_WINDOW = 4; @@ -894,6 +894,7 @@ export class RuntimeHostSessionObserver { state.snapshot = state.projector.snapshot; if (update.previousSnapshot) { for (const group of state.targets.values()) this.#sendExecution(state, group); + await this.#reconcileRemovedQueueMessages(state, update.previousSnapshot, state.snapshot); } for (const event of update.events) { this.#broadcast(state.sessionId, event); @@ -933,6 +934,49 @@ export class RuntimeHostSessionObserver { } } + async #reconcileRemovedQueueMessages( + state: ObservedSessionState, + previous: SessionContinuitySnapshot, + next: SessionContinuitySnapshot, + ): Promise { + if (!state.messageAdmissions || !this.#client.queryMessageExecutions + || previous.queue.hostEpoch !== next.queue.hostEpoch) return; + const retained = new Set( + [...next.queue.steering, ...next.queue.followup].map((entry) => entry.messageId), + ); + // A queue removal can be delivery, promotion or cancellation. Only Host + // proof can retire the transient or name the successor; the snapshot's + // current root alone cannot. A queue contains at most 64 message identities. + const messageIds = [...previous.queue.steering, ...previous.queue.followup] + .filter((entry) => !retained.has(entry.messageId)) + .map((entry) => entry.messageId); + if (messageIds.length === 0) return; + const projector = state.projector; + try { + const { resolutions } = await this.#client.queryMessageExecutions({ + sessionId: state.sessionId, messageIds, + }); + if (this.#closed || state.closing || state.projector !== projector) return; + for (const resolution of resolutions) { + if (resolution.state === 'pending') continue; + const turnId = resolution.state === 'owned' + ? resolution.turnId : (next.rootTurn ?? previous.rootTurn)?.turnId; + if (!turnId) continue; + this.#broadcast(state.sessionId, { + type: 'message_admission', + id: `host-message-resolution:${next.queue.hostEpoch}:${next.queue.queueRevision}:${resolution.messageId}`, + turnId, + ts: this.#now(), + messageId: resolution.messageId, + outcome: resolution.state === 'owned' ? 'admitted' : 'retracted', + }); + } + } catch { + // Keep unproven messages visible. Durable transcript admission or the + // next observation recovery can resolve them without guessing a result. + } + } + #broadcast(sessionId: string, event: SessionEvent | SessionObservationMessage): void { const state = this.#states.get(sessionId); if (!state) return; @@ -1720,6 +1764,7 @@ function replacementProjection( const previousRoot = previous.rootTurn; const root = next.rootTurn; const terminalEvents: SessionEvent[] = []; + const seedEvents = projector.seedActive(true); if (previousRoot && !isTerminalTurn(previousRoot)) { if (!root || root.runId !== previousRoot.runId) { const stored = projector.seedStoredTerminal( @@ -1738,7 +1783,7 @@ function replacementProjection( } terminalEvents.push(...stored); } else if (isTerminalTurn(root)) { - terminalEvents.push(...projector.seedTerminal(root)); + terminalEvents.push(...seedEvents.splice(0), ...projector.seedTerminal(root)); } } if ( @@ -1746,12 +1791,11 @@ function replacementProjection( isTerminalTurn(root) && (!previousRoot || previousRoot.runId !== root.runId) ) { - terminalEvents.push(...projector.seedTerminal(root)); + terminalEvents.push(...seedEvents.splice(0), ...projector.seedTerminal(root)); } return { terminalEvents, - activeEvents: - root && !isTerminalTurn(root) ? projector.seedActive(true) : [], + activeEvents: seedEvents, terminalTurnIds: new Set( terminalEvents.filter(isTerminalSessionEvent).map((event) => event.turnId), ), diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 67ed33c8b1..27071163f6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -286,6 +286,11 @@ export type DesktopSessionStopResult = | { kind: 'interrupted'; retractedMessageIds: string[] } | undefined; +/** Cancellation proof aggregated across every Runtime Host query batch. */ +export interface DesktopMessageCancellationQueryResult { + readonly cancelledMessageIds: readonly string[]; +} + export type DesktopReviseBeforeTurnInput = ReviseBeforeTurnInput & { /** Stable target identity for retrying one Desktop copy action. */ copyId: string; @@ -1169,7 +1174,7 @@ export interface MakaBridge { queryCancelledMessages( sessionId: string, messageIds: readonly string[], - ): Promise; + ): Promise; queryMessageExecutions( sessionId: string, messageIds: readonly string[], diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index bee65df2f6..1e00fc530d 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -58,7 +58,7 @@ import { noRealConnectionReasonFromError, noRealConnectionSetupDescription, } from './model-connection-errors.js'; -import type { RefreshMessagesOptions } from './session-message-settlement.js'; +import type { RefreshMessagesOptions } from './platform/desktop/session-message-settlement.js'; export type { RefreshMessagesOptions }; diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index fce8902c1d..a59fb09da7 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -36,7 +36,7 @@ import { type SessionCopyAttemptPhase, type SessionCopyAttemptKey, } from './session-copy-attempt.js'; -import { readSettledMessages } from './session-message-settlement.js'; +import { readSettledMessages } from './platform/desktop/session-message-settlement.js'; import type { MessageListUpdater } from './session-workspace-actions.js'; type RefBox = { current: T }; diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index d5a4e14cc4..6f82936890 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 { } from '@maka/ui'; import type { LiveTurnBuffer, LiveTurnProjection, InteractionQueues } from '@maka/ui'; import type { RefreshMessagesOptions } from './app-shell-chat-actions.js'; +import { deriveMessageQueueProjection } from './application/contracts/message-queue-projection.js'; import type { MessageQueueUiState } from './app-shell-session-ui-state.js'; import * as modelConnectionErrors from './model-connection-errors.js'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; @@ -299,7 +300,8 @@ export function createAppShellSessionEventHandlers(options: { ); switch (event.type) { - case 'queue_update': + case 'queue_update': { + const queue = deriveMessageQueueProjection(event); for (const entry of [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])]) { removeTransientMessage?.(sessionId, entry.messageId); } @@ -314,14 +316,12 @@ export function createAppShellSessionEventHandlers(options: { ...current, [sessionId]: { queueRevision: event.queueRevision, - entries: [ - ...(event.steeringEntries ?? []).filter((entry) => entry.state === 'queued'), - ...(event.followupEntries ?? []), - ].map((entry) => structuredClone(entry)), + entries: queue.entries, }, }; }); break; + } case 'message_admission': if (event.outcome === 'retracted') removeTransientMessage?.(sessionId, event.messageId); break; diff --git a/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts b/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts new file mode 100644 index 0000000000..b3f484380d --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts @@ -0,0 +1,62 @@ +/* + * 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 { + MessageQueueEntryProjection, + QueueUpdateEvent, +} from '@maka/core/events'; +import type { TransientUserMessageProjection } from '@maka/ui'; + +export interface MessageQueueProjection { + readonly entries: readonly MessageQueueEntryProjection[]; + readonly transientMessages: readonly TransientUserMessageProjection[]; +} + +/** One presentation contract for Host queue snapshots in every chat surface. */ +export function deriveMessageQueueProjection( + event: QueueUpdateEvent, +): MessageQueueProjection { + const entries = [ + ...(event.steeringEntries ?? []), + ...(event.followupEntries ?? []), + ] + .filter((entry) => entry.state === 'queued') + .map((entry) => structuredClone(entry)); + return { + entries, + transientMessages: entries.map((entry) => ({ + id: entry.messageId, + transientPlacement: entry.placement, + ...(entry.placement === 'current_turn' && { + hostTurnId: event.turnId, + pendingSteering: true, + }), + ts: event.ts, + text: entry.content.displayText ?? entry.content.text, + ...(entry.content.attachments && { attachments: [...entry.content.attachments] }), + ...(entry.content.directoryReferences && { + directoryReferences: entry.content.directoryReferences, + }), + ...(entry.content.quotes && { quotes: [...entry.content.quotes] }), + ...(entry.content.inlineReferences && { + inlineReferences: [...entry.content.inlineReferences], + }), + })), + }; +} diff --git a/apps/desktop/src/renderer/transient-message-projection.ts b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts similarity index 79% rename from apps/desktop/src/renderer/transient-message-projection.ts rename to apps/desktop/src/renderer/application/contracts/transient-message-projection.ts index 42ccb71f5a..803a6024b6 100644 --- a/apps/desktop/src/renderer/transient-message-projection.ts +++ b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts @@ -22,6 +22,23 @@ import type { TransientUserMessageProjection } from '@maka/ui'; type TransientUserMessage = TransientUserMessageProjection; +/** + * Replace the queue-backed subset in the exact order supplied by the Host. + * Other local intents keep their relative position because queue absence is + * not cancellation or delivery proof. + */ +export function projectQueuedTransientMessages( + transient: Map, + queued: readonly TransientUserMessage[], +): void { + if (queued.length === 0) return; + const queuedIds = new Set(queued.map((message) => message.id)); + const retained = [...transient.entries()].filter(([id]) => !queuedIds.has(id)); + transient.clear(); + for (const [id, message] of retained) transient.set(id, message); + for (const message of queued) transient.set(message.id, message); +} + /** * A Host-named Turn outranks a later local update that still has none: the * IPC reply can land after the Host event that already bound this Message. diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 1241150101..f501d72cbf 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -18,6 +18,7 @@ */ import type { + MessageQueuePlacement, QuoteRef, SessionEvent, ShellRunUpdate, @@ -47,6 +48,7 @@ import type { Result } from '@maka/core/result'; import type { ContextCompactResult, ContextDiagnosticsResult, + TurnMessageExecutionQueryResult, } from '@maka/runtime-host/protocol'; import type { MergedUsageSummary } from '@maka/core/usage-ledger-merge'; import type { @@ -192,9 +194,9 @@ export type SideChatSendResult = | { ok: false; reason: 'outcome_unknown'; messageId: string } | { ok: false; reason?: string; messageId?: never }; -export type SideChatSteerResult = - | { kind: 'queued'; messageId: string } - | { kind: 'outcome_unknown'; messageId: string } +export type SideChatFollowUpResult = + | { kind: 'queued' } + | { kind: 'outcome_unknown' } | { kind: 'started'; turnId: string }; export type SideChatStopTarget = @@ -206,7 +208,7 @@ export interface SideChatSessionPort { listTurns(sessionId: string): Promise; readSettledMessages( sessionId: string, - options?: { requiredAssistantMessageId?: string }, + options?: { requiredAssistantMessageId?: string; requiredTurnId?: string }, ): Promise<{ messages: StoredMessage[]; settled: boolean }>; branchFromTurn( sessionId: string, @@ -237,7 +239,25 @@ export interface SideChatSessionPort { sessionId: string, target?: SideChatStopTarget, ): Promise<{ kind: 'retracted'; messageId: string } | undefined>; - steer(sessionId: string, text: string, admissionId?: string): Promise; + submitFollowUp( + sessionId: string, + placement: MessageQueuePlacement, + text: string, + admissionId: string, + ): Promise; + queryMessageExecutions( + sessionId: string, + messageIds: readonly string[], + ): Promise; + retractQueueEntry(sessionId: string, entryId: string): Promise; + promoteQueueEntry(sessionId: string, entryId: string): Promise; + updateQueueEntry( + sessionId: string, + entryId: string, + expectedQueueRevision: number, + text: string, + ): Promise; + reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise; setPermissionMode( sessionId: string, mode: PermissionMode, @@ -262,7 +282,8 @@ export interface SideChatSessionPort { subscribeEvents( sessionId: string, handler: (event: SessionEvent) => void, - onSeeded?: () => void, + /** Called after the initial observation seed and each reconnect seed. */ + onReady?: () => void, onSeedError?: (error: unknown) => void, onExecution?: (projection: import('../../../shared/session-execution-projection.js').SessionExecutionProjection | undefined) => void, ): WorkbarUnsubscribe; diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 11c1e45a56..5767a3e22b 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -137,9 +137,16 @@ export function createFakeWorkbarServices( }, send: async () => ({ ok: false, reason: 'not configured' }), stop: async () => undefined, - steer: async () => { - throw new Error('Fake sideChat.steer is not configured'); + submitFollowUp: async () => { + throw new Error('Fake sideChat.submitFollowUp is not configured'); }, + queryMessageExecutions: async (_sessionId, messageIds) => ({ + resolutions: messageIds.map((messageId) => ({ messageId, state: 'pending' as const })), + }), + retractQueueEntry: async () => undefined, + promoteQueueEntry: async () => undefined, + updateQueueEntry: async () => undefined, + reorderQueueEntries: async () => undefined, setPermissionMode: async () => { throw new Error('Fake sideChat.setPermissionMode is not configured'); }, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-context-compaction.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-context-compaction.ts index 527e258200..4822d11a0a 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-context-compaction.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-context-compaction.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { ContextCompactionOutcome } from '@maka/core/events'; +import type { ContextCompactionOutcome, FollowUpMode } from '@maka/core/events'; import type { UiLocale } from '@maka/core/ui-locale'; import type { ContextCompactResult } from '@maka/runtime-host/protocol'; @@ -47,12 +47,18 @@ export function isExactCompactCommand(input: string): boolean { export function dispatchQuoteCompanionInput(input: { text: string; streaming: boolean; + followUpMode?: FollowUpMode; compact(): Promise; + queue(text: string): Promise; steer(text: string): Promise; send(): Promise; }): Promise { if (isExactCompactCommand(input.text)) return input.compact(); - if (input.streaming) return input.steer(input.text); + if (input.streaming) { + return input.followUpMode === 'steer' + ? input.steer(input.text) + : input.queue(input.text); + } return input.send(); } diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 3fb6741276..31aa986337 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -299,11 +299,13 @@ export function QuoteCompanionPanel(props: { )} + onSend={(text, metadata) => dispatchQuoteCompanionInput({ text, streaming: companion.streaming, + followUpMode: metadata?.followUpMode, compact: companion.compact, + queue: companion.queue, steer: companion.steer, send: async () => { try { @@ -333,6 +335,13 @@ export function QuoteCompanionPanel(props: { hidden={Boolean(activeInteraction)} streaming={companion.streaming} processing={companion.processing} + queuedMessages={companion.queuedMessages} + pendingMessages={companion.transientMessages} + queuedMessageRevision={companion.queuedMessageRevision} + onPromoteQueuedEntry={companion.promoteQueuedEntry} + onUpdateQueuedEntry={companion.updateQueuedEntry} + onDeleteQueuedEntry={companion.deleteQueuedEntry} + onReorderQueuedEntries={companion.reorderQueuedEntries} draftKey={draftKey} disabled={!companion.modelReady} onPickAttachments={pickAttachments} diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index bc13822a34..b044414751 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -38,6 +38,8 @@ import type { ClientCapabilityRequestEvent, ContextCompactionOutcome, FormRequestEvent, + MessageQueueEntryProjection, + MessageQueuePlacement, QuoteRef, SessionEvent, UserQuestionRequestEvent, @@ -66,7 +68,13 @@ import { type EnsureCompanionForkResult, } from './quote-companion-core.js'; import { isExactCompactCommand } from './quote-companion-context-compaction.js'; +import { deriveMessageQueueProjection } from '../../../../application/contracts/message-queue-projection.js'; import { mergeSettledMessages } from '../../../../settled-message-merge.js'; +import { + mergeTransientMessageProjection, + projectQueuedTransientMessages, + reconcileTransientMessages, +} from '../../../../application/contracts/transient-message-projection.js'; import { getDesktopConversationCopy } from '../../../../locales/conversation-copy.js'; import { snapshotCompanionQuotes, @@ -168,6 +176,9 @@ export interface UseQuoteCompanionResult { * before the durable transcript echoes them back. Reconciled away once the * durable message with the same id lands. Pass straight to `ChatView`. */ transientMessages: readonly TransientUserMessageProjection[]; + /** Host-authoritative pending steering and follow-up messages. */ + queuedMessages: readonly MessageQueueEntryProjection[]; + queuedMessageRevision: number | undefined; liveTurns: LiveTurnBuffer | undefined; activeTurn: ReturnType; streaming: boolean; @@ -192,6 +203,16 @@ export interface UseQuoteCompanionResult { send: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; /** Insert text into the active companion turn at the next model step. */ steer: (text: string) => Promise; + /** Queue text for the next companion turn while the current turn continues. */ + queue: (text: string) => Promise; + promoteQueuedEntry: (entryId: string) => Promise; + updateQueuedEntry: ( + entryId: string, + expectedQueueRevision: number, + text: string, + ) => Promise; + deleteQueuedEntry: (entryId: string) => Promise; + reorderQueuedEntries: (entryIds: readonly string[]) => Promise; setPermissionMode: (mode: PermissionMode) => Promise; regenerate: (turnId: string) => Promise; stop: () => Promise; @@ -206,6 +227,19 @@ function requiredAssistantMessageId(projection: LiveTurnProjection | undefined): return [...(projection?.steps ?? [])].reverse().find((step) => step.text)?.stepId; } +function transcriptRecordsTerminalTurn( + messages: readonly StoredMessage[], + turnId: string, +): boolean { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.type === 'turn_state' && message.turnId === turnId) { + return message.status !== 'running'; + } + } + return false; +} + /** * Companion for the quote side panel. On the first question it FORKS the main * session (`branchFromTurn` from the latest SETTLED turn) into a child that @@ -278,6 +312,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const compactionTurnIdRef = useRef(null); const pendingCompactionTerminalRef = useRef(null); const [allMessages, setAllMessages] = useState([]); + const allMessagesRef = useRef(allMessages); + allMessagesRef.current = allMessages; // Renderer-only user bubble shown the instant a send dispatches. The durable // transcript only echoes the just-sent question back mid-turn on a single // best-effort refresh (and otherwise not until the turn settles), so without @@ -287,6 +323,13 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const [pendingUserMessages, setPendingUserMessages] = useState< TransientUserMessageProjection[] >([]); + const pendingUserMessagesRef = useRef>( + new Map(), + ); + const [messageQueue, setMessageQueue] = useState<{ + readonly entries: readonly MessageQueueEntryProjection[]; + readonly queueRevision?: number; + }>({ entries: [] }); const [execution, setExecution] = useState(); const [liveTurns, setLiveTurns] = useState(); const liveTurnsRef = useRef(liveTurns); @@ -364,16 +407,84 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan submitLockRef.current = locked; }, []); + const syncPendingUserMessages = useCallback(() => { + setPendingUserMessages([...pendingUserMessagesRef.current.values()]); + }, []); + + const addPendingUserMessage = useCallback((message: TransientUserMessageProjection) => { + const current = pendingUserMessagesRef.current.get(message.id); + pendingUserMessagesRef.current.set( + message.id, + current ? mergeTransientMessageProjection(current, message) : message, + ); + syncPendingUserMessages(); + }, [syncPendingUserMessages]); + + const reconcilePendingUserMessages = useCallback(() => { + reconcileTransientMessages(pendingUserMessagesRef.current, allMessagesRef.current.filter( + (message) => message.turnId !== undefined && ownTurnIdsRef.current.has(message.turnId), + )); + syncPendingUserMessages(); + }, [syncPendingUserMessages]); + + const mergeDurableMessages = useCallback((messages: readonly StoredMessage[]) => { + const next = mergeSettledMessages(allMessagesRef.current, messages); + allMessagesRef.current = next; + setAllMessages(next); + setLiveTurns((current) => current ? reconcileLiveTurnBuffer(current, next) : current); + reconcilePendingUserMessages(); + }, [reconcilePendingUserMessages]); + // Retire the optimistic bubble for a message id. Called when a send is - // retracted/abandoned; the success path retires it implicitly by reconciling - // against the durable transcript (see the `transientMessages` derivation). + // retracted/abandoned; the success path retires it through the shared + // durable-transient reconciliation rule. const dropOptimisticUserMessage = useCallback((messageId: string) => { - setPendingUserMessages((current) => { - const next = current.filter((message) => message.id !== messageId); - return next.length === current.length ? current : next; + if (!pendingUserMessagesRef.current.delete(messageId)) return; + syncPendingUserMessages(); + }, [syncPendingUserMessages]); + + const dropQueuedMessage = useCallback((messageId: string) => { + setMessageQueue((current) => { + const entries = current.entries.filter((entry) => entry.messageId !== messageId); + return entries.length === current.entries.length ? current : { ...current, entries }; }); }, []); + // Bind presentation to canonical ownership before the caller reconciles and + // publishes the pending Map. Recovery can bind a whole batch in one update. + const bindPendingMessageTurn = useCallback((messageId: string, turnId: string, startsTurn = false) => { + const message = pendingUserMessagesRef.current.get(messageId); + if (!message) return; + const movedToSuccessor = message.pendingSteering + && message.hostTurnId !== undefined && message.hostTurnId !== turnId; + pendingUserMessagesRef.current.set(messageId, { + ...message, + hostTurnId: turnId, + ...((startsTurn || movedToSuccessor || message.transientPlacement === 'next_turn') && { + transientPlacement: 'current_turn', pendingSteering: false, + }), + }); + }, []); + + const recordOwnedTurn = useCallback((turnId: string, messageId?: string, startsTurn = false) => { + if (messageId) bindPendingMessageTurn(messageId, turnId, startsTurn); + hasContentRef.current = true; + setHasContent(true); + ownTurnIdsRef.current.add(turnId); + setOwnTurnTick((tick) => tick + 1); + reconcilePendingUserMessages(); + }, [bindPendingMessageTurn, reconcilePendingUserMessages]); + + const projectMessageQueue = useCallback( + (event: Extract) => { + const queue = deriveMessageQueueProjection(event); + setMessageQueue({ entries: queue.entries, queueRevision: event.queueRevision }); + projectQueuedTransientMessages(pendingUserMessagesRef.current, queue.transientMessages); + syncPendingUserMessages(); + }, + [syncPendingUserMessages], + ); + const applyOwnedEvent = useCallback( (forkId: string, event: SessionEvent) => { const terminal: PendingCompactionTerminal | undefined = @@ -419,6 +530,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // off from the live projection, then reconcile (shared with the main chat) // so the finished exchange never flickers away. void sideChat.readSettledMessages(forkId, { + requiredTurnId: settledTurnId, ...(requiredAssistantMessageId(liveTurnsRef.current?.find((turn) => turn.turnId === settledTurnId)) ? { requiredAssistantMessageId: requiredAssistantMessageId(liveTurnsRef.current?.find((turn) => turn.turnId === settledTurnId)), @@ -427,8 +539,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }) .then(({ messages: next }) => { if (!mountedRef.current || companionIdRef.current !== forkId) return; - setAllMessages((current) => mergeSettledMessages(current, next)); - setLiveTurns((prev) => (prev ? reconcileLiveTurnBuffer(prev, next) : prev)); + mergeDurableMessages(next); if (activeTurnIdRef.current === settledTurnId) { activeTurnIdRef.current = null; } @@ -443,7 +554,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }); } }, - [mountedRef, sideChat], + [mergeDurableMessages, mountedRef, sideChat], ); const bindAdmittedTurn = useCallback( @@ -457,22 +568,20 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // Host admission is the durable-content boundary. Even if a concurrent // Stop interrupts the Run before send() settles, this fork now owns a // persisted user message and must never be replaced as an empty copy. - hasContentRef.current = true; - setHasContent(true); - activeTurnIdRef.current = turnId; if (stopRequestRef.current && stopRequestRef.current.promise === admission.stopPromise) { stopRequestRef.current.turnId = turnId; } - ownTurnIdsRef.current.add(turnId); + recordOwnedTurn(turnId, admission.messageId); admission.consumeOnAdmission?.(); setError(null); - setOwnTurnTick((tick) => tick + 1); - setLiveTurns((previous) => retainLiveTurn(previous, armLiveTurn(turnId))); + setLiveTurns((previous) => reconcileLiveTurnBuffer( + retainLiveTurn(previous, armLiveTurn(turnId)), allMessagesRef.current, + )); for (const event of admission.events) { if (event.turnId === turnId) applyOwnedEvent(forkId, event); } }, - [applyOwnedEvent, setPendingAdmission], + [applyOwnedEvent, recordOwnedTurn, setPendingAdmission], ); // A Message whose admission answer was lost is still reconcilable: the Host @@ -532,6 +641,90 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan [bindAdmittedTurn, releaseAdmission], ); + const reconcilePendingMessageExecutions = useCallback(async (forkId: string) => { + if (!mountedRef.current || companionIdRef.current !== forkId) return; + // Reseeding may retire the transient before the lost admission receipt is + // recovered. Its durable identity also releases the Composer admission slot. + const admission = pendingAdmissionRef.current; + const admittedMessage = admission && allMessagesRef.current.find( + (message) => message.type === 'user' && message.id === admission.messageId, + ); + if (admittedMessage?.turnId) bindAdmittedTurn(forkId, admittedMessage.turnId); + const messageIds = new Set(pendingUserMessagesRef.current.keys()); + if (pendingAdmissionRef.current) messageIds.add(pendingAdmissionRef.current.messageId); + if (messageIds.size === 0) return; + try { + const { resolutions } = await sideChat.queryMessageExecutions(forkId, [...messageIds]); + if (!mountedRef.current || companionIdRef.current !== forkId) return; + const cancelled = new Set(); + const unprovenOwnedTurnIds = new Set(); + let ownershipChanged = false; + for (const resolution of resolutions) { + const pending = pendingAdmissionRef.current; + if (resolution.state === 'cancelled') { + cancelled.add(resolution.messageId); + if (pending?.messageId === resolution.messageId) releaseAdmission(pending); + } else if (resolution.state === 'owned') { + bindPendingMessageTurn(resolution.messageId, resolution.turnId); + if (pending?.messageId === resolution.messageId) { + bindAdmittedTurn(forkId, resolution.turnId); + } + const previousSize = ownTurnIdsRef.current.size; + ownTurnIdsRef.current.add(resolution.turnId); + ownershipChanged ||= ownTurnIdsRef.current.size !== previousSize; + if (!transcriptRecordsTerminalTurn(allMessagesRef.current, resolution.turnId)) { + unprovenOwnedTurnIds.add(resolution.turnId); + } + } + } + if (ownershipChanged) { + hasContentRef.current = true; + setHasContent(true); + setOwnTurnTick((tick) => tick + 1); + } + for (const messageId of cancelled) pendingUserMessagesRef.current.delete(messageId); + if (unprovenOwnedTurnIds.size > 0) { + const recovered = await Promise.allSettled( + [...unprovenOwnedTurnIds].map((turnId) => + sideChat.readSettledMessages(forkId, { requiredTurnId: turnId })), + ); + if (!mountedRef.current || companionIdRef.current !== forkId) return; + for (const result of recovered) { + if (result.status === 'fulfilled' && result.value.settled) { + mergeDurableMessages(result.value.messages); + } + } + } + reconcilePendingUserMessages(); + if (cancelled.size > 0) { + setMessageQueue((current) => ({ + ...current, + entries: current.entries.filter((entry) => !cancelled.has(entry.messageId)), + })); + } + } catch { + // A failed proof query leaves presentation intact until canonical proof arrives. + } + }, [bindAdmittedTurn, bindPendingMessageTurn, mergeDurableMessages, mountedRef, reconcilePendingUserMessages, releaseAdmission, sideChat]); + + const reconcileStartedFollowUpTurn = useCallback(async ( + forkId: string, + turnId: string, + messageId: string, + ): Promise => { + // A receipt proves ownership, not current execution. Register it before the + // read so concurrent content events can be retained, then recover this Turn + // even if it completed outside the current transcript window. Only Host + // execution snapshots decide whether it is still running. + recordOwnedTurn(turnId, messageId, true); + const { messages } = await sideChat.readSettledMessages(forkId, { + requiredTurnId: turnId, + }).catch(() => ({ messages: [] as StoredMessage[] })); + if (!mountedRef.current || companionIdRef.current !== forkId) return false; + mergeDurableMessages(messages); + return true; + }, [mergeDurableMessages, mountedRef, recordOwnedTurn, sideChat]); + // Subscribe to the fork's event stream + load its transcript. Called // synchronously the moment the fork is committed, BEFORE the run starts, so // no boundary request / complete can be missed (the stream has no replay). @@ -554,30 +747,56 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // A subscription can fail before the first send. Keep that failure // observable to a later send without creating an unhandled rejection now. void ready.catch(() => undefined); - void sideChat.readSettledMessages(forkId) - .then(({ messages }) => { - if (mountedRef.current) { - setAllMessages((current) => mergeSettledMessages(current, messages)); - } - }) - .catch(() => { - if (mountedRef.current) setError(copyRef.current.errors.settlementFailed); - }); setExecution((previous) => previous?.rootTurn?.sessionId === forkId ? { ...previous, available: false } : undefined); let disposed = false; + const observationSeeded = () => { + if (disposed || !mountedRef.current) return; + resolveReady(); + void sideChat.readSettledMessages(forkId) + .then(({ messages }) => { + if (!mountedRef.current || companionIdRef.current !== forkId) return; + mergeDurableMessages(messages); + void reconcilePendingMessageExecutions(forkId); + }) + .catch(() => { + void reconcilePendingMessageExecutions(forkId); + }); + }; const unsubscribe = sideChat.subscribeEvents( forkId, (event: SessionEvent) => { - if (!mountedRef.current) return; + if (!mountedRef.current || disposed || companionIdRef.current !== forkId) return; + if (event.type === 'queue_update') { + projectMessageQueue(event); + return; + } const admission = pendingAdmissionRef.current; - if (admission) { - if ( - event.type === 'message_admission' && - event.messageId === admission.messageId - ) { + if (event.type === 'steering_message') { + dropOptimisticUserMessage(event.messageId); + dropQueuedMessage(event.messageId); + recordOwnedTurn(event.turnId); + applyOwnedEvent(forkId, event); + return; + } else if (event.type === 'message_admission' && event.outcome === 'retracted') { + dropOptimisticUserMessage(event.messageId); + dropQueuedMessage(event.messageId); + if (admission?.messageId === event.messageId) { + admission.events.push(event); + resolveAdmission(forkId, admission, admission.messageId); + } + return; + } else if (event.type === 'message_admission' && event.outcome === 'admitted') { + dropQueuedMessage(event.messageId); + if (admission?.messageId === event.messageId) { admission.events.push(event); resolveAdmission(forkId, admission, admission.messageId); - } else if (event.turnId === activeTurnIdRef.current) { + } else { + recordOwnedTurn(event.turnId, event.messageId); + } + return; + } + if (admission) { + if (ownTurnIdsRef.current.has(event.turnId)) { applyOwnedEvent(forkId, event); } else { admission.events.push(event); @@ -589,7 +808,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } applyOwnedEvent(forkId, event); }, - resolveReady, + observationSeeded, (error) => { if (disposed || !mountedRef.current) return; rejectReady(error); @@ -599,7 +818,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setExecution((previous) => previous ? { ...previous, available: false } : undefined); setError(copyRef.current.errors.sendFailed); }, - (projection) => { if (mountedRef.current && !disposed) setExecution(projection); }, + (projection) => { + if (!mountedRef.current || disposed) return; + activeTurnIdRef.current = activeHostTurn(projection)?.turnId ?? null; + setExecution(projection); + }, ); unsubscribeRef.current = () => { if (disposed) return; @@ -610,7 +833,19 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan resolveReady(); }; return ready; - }, [applyOwnedEvent, mountedRef, resolveAdmission, sideChat]); + }, [ + applyOwnedEvent, + recordOwnedTurn, + dropOptimisticUserMessage, + dropQueuedMessage, + mountedRef, + mergeDurableMessages, + projectMessageQueue, + reconcilePendingMessageExecutions, + reconcileUnknownAdmission, + resolveAdmission, + sideChat, + ]); const commitFork = useCallback( (session: SessionSummary) => { @@ -664,7 +899,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan companionRef.current = undefined; clearPermissionModeIntent(existing.id); setCompanion(undefined); + allMessagesRef.current = []; setAllMessages([]); + pendingUserMessagesRef.current.clear(); + setPendingUserMessages([]); + setMessageQueue({ entries: [] }); onForkVisibilityChangeRef.current?.({ type: 'cleanup-succeeded', sessionId: existing.id, @@ -886,16 +1125,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan events: [], consumeOnAdmission: () => onQuotesConsumed(quoteSnapshot), }; - setPendingUserMessages((current) => [ - ...current.filter((message) => message.id !== turnId), - { - id: turnId, - text: trimmed, - ts: Date.now(), - transientPlacement: 'current_turn', - ...(quoteSnapshot.quotes.length > 0 ? { quotes: quoteSnapshot.quotes } : {}), - }, - ]); + const optimisticMessage: TransientUserMessageProjection = { + id: turnId, + text: trimmed, + ts: Date.now(), + transientPlacement: 'current_turn', + ...(quoteSnapshot.quotes.length > 0 ? { quotes: quoteSnapshot.quotes } : {}), + }; + addPendingUserMessage(optimisticMessage); // Setup can still fail before the send is in flight (fork unavailable, // fail-closed permission write, or a lost subscription). Retire the // optimistic bubble and release the lock so a failed first send never @@ -1015,7 +1252,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }) .then(({ messages: next }) => { if (mountedRef.current) { - setAllMessages((current) => mergeSettledMessages(current, next)); + mergeDurableMessages(next); } }) .catch(() => {}); @@ -1058,7 +1295,9 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan sideChat, bindAdmittedTurn, compact, + addPendingUserMessage, dropOptimisticUserMessage, + mergeDurableMessages, releaseAdmission, resolveAdmission, setPendingAdmission, @@ -1117,7 +1356,10 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } }, [execution, releaseAdmission, resolveAdmission, sideChat]); - const steer = useCallback(async (text: string): Promise => { + const submitFollowUp = useCallback(async ( + text: string, + placement: MessageQueuePlacement, + ): Promise => { const id = companionIdRef.current; const trimmed = text.trim(); if ( @@ -1125,7 +1367,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan !id || !trimmed || !turnInFlight || - pendingAdmissionRef.current + (placement === 'current_turn' && pendingAdmissionRef.current !== null) ) { return false; } @@ -1134,35 +1376,73 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan messageId: admissionId, events: [], }; - setPendingAdmission(admission); + const optimisticMessage: TransientUserMessageProjection = { + id: admissionId, + text: trimmed, + ts: Date.now(), + transientPlacement: placement, + ...(placement === 'current_turn' && { pendingSteering: true }), + ...(placement === 'current_turn' && activeTurnIdRef.current + ? { hostTurnId: activeTurnIdRef.current } + : {}), + }; + addPendingUserMessage(optimisticMessage); + if (placement === 'current_turn') setPendingAdmission(admission); try { - const outcome = await sideChat.steer(id, trimmed, admissionId); + const outcome = await sideChat.submitFollowUp(id, placement, trimmed, admissionId); if (!mountedRef.current) return false; - if ((await admission.stopPromise) === 'confirmed') return false; + if (placement === 'current_turn' && (await admission.stopPromise) === 'confirmed') { + return false; + } if (admissionOutcomeForMessage(admission.events, admission.messageId)?.kind === 'retracted') { return false; } if (outcome.kind === 'started') { - bindAdmittedTurn(id, outcome.turnId); - } else if (resolveAdmission(id, admission, outcome.messageId)?.kind === 'retracted') { + if (placement === 'current_turn') { + recordOwnedTurn(outcome.turnId, admissionId, true); + bindAdmittedTurn(id, outcome.turnId); + } else { + // The active Turn can settle between the local streaming check and + // Host admission. In that race a nominal next-turn follow-up starts + // immediately. Reconcile first because a reconnect can replay this + // receipt after the Host-named Turn has already settled. + if (!(await reconcileStartedFollowUpTurn(id, outcome.turnId, admissionId))) { + return false; + } + } + } else if (resolveAdmission(id, admission, admissionId)?.kind === 'retracted') { return false; + } else if ( + outcome.kind === 'queued' && + placement === 'current_turn' && + pendingAdmissionRef.current === admission + ) { + // A queued follow-up no longer owns the Composer's single in-flight + // admission slot. Its optimistic row and the Host queue projection + // remain until delivery/retraction, while later follow-ups may queue too. + setPendingAdmission(null); } setError(null); return true; } catch { if (mountedRef.current) { - if (pendingAdmissionRef.current === admission) { + if (placement === 'current_turn' && pendingAdmissionRef.current === admission) { releaseAdmission(admission, copyRef.current.errors.sendFailed); } else if ( admissionOutcomeForMessage(admission.events, admission.messageId)?.kind !== 'retracted' ) { + dropOptimisticUserMessage(admission.messageId); setError(copyRef.current.errors.sendFailed); } } return false; } }, [ + addPendingUserMessage, bindAdmittedTurn, + reconcileStartedFollowUpTurn, + recordOwnedTurn, + dropOptimisticUserMessage, mountedRef, releaseAdmission, resolveAdmission, @@ -1171,6 +1451,54 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan turnInFlight, ]); + const steer = useCallback( + (text: string) => submitFollowUp(text, 'current_turn'), + [submitFollowUp], + ); + const queue = useCallback( + (text: string) => submitFollowUp(text, 'next_turn'), + [submitFollowUp], + ); + + const runQueueEntryAction = useCallback( + async (action: (sessionId: string) => Promise): Promise => { + const id = companionIdRef.current; + if (!id) return; + try { + await action(id); + } catch (error) { + if (mountedRef.current) setError(copyRef.current.errors.respondFailed); + throw error; + } + }, + [mountedRef], + ); + + const promoteQueuedEntry = useCallback( + (entryId: string) => runQueueEntryAction((id) => sideChat.promoteQueueEntry(id, entryId)), + [runQueueEntryAction, sideChat], + ); + const updateQueuedEntry = useCallback( + (entryId: string, expectedQueueRevision: number, text: string) => + runQueueEntryAction((id) => + sideChat.updateQueueEntry(id, entryId, expectedQueueRevision, text), + ), + [runQueueEntryAction, sideChat], + ); + const deleteQueuedEntry = useCallback( + async (entryId: string): Promise => { + const messageId = messageQueue.entries.find((entry) => entry.entryId === entryId)?.messageId; + await runQueueEntryAction((id) => sideChat.retractQueueEntry(id, entryId)); + if (messageId) dropOptimisticUserMessage(messageId); + }, + [dropOptimisticUserMessage, messageQueue.entries, runQueueEntryAction, sideChat], + ); + const reorderQueuedEntries = useCallback( + (entryIds: readonly string[]) => + runQueueEntryAction((id) => sideChat.reorderQueueEntries(id, entryIds)), + [runQueueEntryAction, sideChat], + ); + const setPermissionMode = useCallback( (mode: PermissionMode): Promise => { const id = companionIdRef.current; @@ -1287,15 +1615,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const messages = allMessages.filter( (message) => message.turnId !== undefined && ownTurnIdsRef.current.has(message.turnId), ); - // Drop the optimistic bubble only once its durable twin will actually RENDER, - // i.e. it is in `messages` (own-turn filtered) — not merely settled into - // `allMessages`. Building this from `allMessages` could retire the transient on - // an `outcome_unknown` settle while the durable message is still filtered out of - // the render, blinking the question away until `reconcileUnknownAdmission` binds. - const durableMessageIds = new Set(messages.map((message) => message.id)); - const transientMessages = pendingUserMessages.filter( - (message) => !durableMessageIds.has(message.id), - ); + const transientMessages = pendingUserMessages; // Inherited model (read-only): the fork's once created, else the source's. const activeModel = companion ? { llmConnectionSlug: companion.llmConnectionSlug, model: companion.model } @@ -1327,6 +1647,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan hasContent, messages, transientMessages, + queuedMessages: messageQueue.entries, + queuedMessageRevision: messageQueue.queueRevision, liveTurns, activeTurn: chatTurnActivity(execution), streaming, @@ -1343,6 +1665,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan compact, send, steer, + queue, + promoteQueuedEntry, + updateQueuedEntry, + deleteQueuedEntry, + reorderQueuedEntries, setPermissionMode, regenerate, stop, diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 5db281dc56..6708f6690c 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -19,7 +19,7 @@ import type { MakaBridge } from '../../../preload/bridge-contract.js'; import type { WorkbarServices } from '../../features/workbar'; -import { readSettledMessagesFrom } from '../../session-message-settlement.js'; +import { readSettledMessagesFrom } from './session-message-settlement.js'; export type DesktopWorkbarBridge = Pick< MakaBridge, @@ -47,6 +47,32 @@ export function createDesktopWorkbarServices( bridge: DesktopWorkbarBridge = window.maka, dependencies: DesktopWorkbarServiceDependencies = DEFAULT_DEPENDENCIES, ): WorkbarServices { + const submitSideChatFollowUp: WorkbarServices['sideChat']['submitFollowUp'] = async ( + sessionId, + placement, + text, + admissionId, + ) => { + const result = await bridge.sessions.submitMessage( + sessionId, + placement, + { + messageId: admissionId, + text, + }, + { waitForHostAdmission: true }, + ); + if (!result.ok) { + if (result.reason === 'outcome_unknown') { + return { kind: 'outcome_unknown' }; + } + throw new Error('Runtime Host refused the follow-up Message'); + } + return result.disposition === 'turn_started' && result.turnId + ? { kind: 'started', turnId: result.turnId } + : { kind: 'queued' }; + }; + return { review: { read: (input) => bridge.gitReview.read(input), @@ -94,7 +120,7 @@ export function createDesktopWorkbarServices( listSessions: () => bridge.sessions.list(), listTurns: (sessionId) => bridge.sessions.listTurns(sessionId), readSettledMessages: (sessionId, options) => - dependencies.readSettledMessages(bridge.transcripts, sessionId, options), + dependencies.readSettledMessages(bridge, sessionId, options), branchFromTurn: (sessionId, input) => bridge.sessions.branchFromTurn(sessionId, input), cleanupSessionCopy: (sessionId) => @@ -114,27 +140,17 @@ export function createDesktopWorkbarServices( ); return result?.kind === 'retracted' ? result : undefined; }, - // Steering is a Message placed at the current Turn's boundary, so it - // rides the one admission channel. Runtime Host names the outcome; this - // adapter only renames it for the Side Conversation port. - steer: async (sessionId, text, admissionId) => { - const messageId = admissionId ?? crypto.randomUUID(); - const result = await bridge.sessions.submitMessage(sessionId, 'current_turn', { - messageId, - text, - }); - if (!result.ok) { - if (result.reason === 'outcome_unknown') { - return { kind: 'outcome_unknown', messageId }; - } - // No Turn opened and nothing was queued; the caller surfaces it as a - // failed send rather than waiting for an admission that never lands. - throw new Error('Runtime Host refused the steering Message'); - } - return result.disposition === 'turn_started' && result.turnId - ? { kind: 'started', turnId: result.turnId } - : { kind: 'queued', messageId }; - }, + submitFollowUp: submitSideChatFollowUp, + queryMessageExecutions: (sessionId, messageIds) => + bridge.sessions.queryMessageExecutions(sessionId, messageIds), + retractQueueEntry: (sessionId, entryId) => + bridge.sessions.retractQueueEntry(sessionId, entryId), + promoteQueueEntry: (sessionId, entryId) => + bridge.sessions.promoteQueueEntry(sessionId, entryId), + updateQueueEntry: (sessionId, entryId, expectedQueueRevision, text) => + bridge.sessions.updateQueueEntry(sessionId, entryId, expectedQueueRevision, text), + reorderQueueEntries: (sessionId, entryIds) => + bridge.sessions.reorderQueueEntries(sessionId, entryIds), setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), regenerateTurn: (sessionId, input) => diff --git a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts new file mode 100644 index 0000000000..91c2d62b8d --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts @@ -0,0 +1,177 @@ +/* + * 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 { MakaBridge } from '../../../preload/bridge-contract.js'; +import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../../preload/transcript-contract.js'; +import { DesktopTranscriptRangeStore } from './desktop-transcript-range-store.js'; + +const COMMITTED_ASSISTANT_SETTLE_TIMEOUT_MS = 480; + +export interface RefreshMessagesOptions { + requiredAssistantMessageId?: string; + requiredTurnId?: string; + signal?: AbortSignal; +} + +export type TranscriptSettlementSource = { + transcripts: Pick; + sessions: Pick; +}; + +export async function readSettledMessages( + sessionId: string, + options: RefreshMessagesOptions = {}, +): Promise<{ messages: StoredMessage[]; settled: boolean }> { + return readSettledMessagesFrom(window.maka, sessionId, options); +} + +export async function readSettledMessagesFrom( + source: TranscriptSettlementSource, + sessionId: string, + options: RefreshMessagesOptions = {}, +): Promise<{ messages: StoredMessage[]; settled: boolean }> { + const deadline = Date.now() + COMMITTED_ASSISTANT_SETTLE_TIMEOUT_MS; + const store = new DesktopTranscriptRangeStore(sessionId); + let notify: () => void = () => {}; + const changed = () => new Promise((resolve) => { + notify = resolve; + }); + let nextChange = changed(); + let cancelOpen = () => {}; + let rejectCancellation!: (error: Error) => void; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + void cancellation.catch(() => undefined); + let cancelled = false; + const cancel = (error: Error) => { + if (cancelled) return; + cancelled = true; + cancelOpen(); + rejectCancellation(error); + }; + const abort = () => cancel(new Error('Desktop transcript settlement was cancelled')); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + const openTimeout = globalThis.setTimeout( + () => cancel(new Error('Desktop transcript settlement timed out while opening')), + Math.max(0, deadline - Date.now()), + ); + const opening = source.transcripts.open( + sessionId, + (batch) => { + if (!store.accept(batch)) return; + notify(); + nextChange = changed(); + }, + (close) => { + cancelOpen = close; + if (cancelled) close(); + }, + ); + void opening.catch(() => undefined); + let handle: Awaited | undefined; + try { + handle = await Promise.race([opening, cancellation]); + globalThis.clearTimeout(openTimeout); + const requiredTurnId = options.requiredTurnId; + let retainedDurable: ReturnType | undefined; + if ( + requiredTurnId !== undefined && + !transcriptRecordsTerminalTurn(store.snapshot().messages, requiredTurnId) + ) { + retainedDurable = store.durableEntries(); + const readHandle = handle; + const recoverTurn = async () => { + // Main now reads sequence-anchored ranges, not Turn identities. Resolve + // the Host's existing Turn index, then extend only this bounded window + // until the requested terminal record arrives or settlement times out. + const turns = await source.sessions.listTurns(sessionId); + const firstSequence = turns.find((turn) => turn.turnId === requiredTurnId)?.firstSequence; + if (firstSequence === undefined || cancelled || Date.now() >= deadline) return; + await readHandle.loadAround(firstSequence, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, store.navigate()); + let previousSequence: number | null = null; + while (!cancelled && Date.now() < deadline) { + if (transcriptRecordsTerminalTurn(store.snapshot().messages, requiredTurnId)) return; + const range = store.range(); + if (!range.hasNewer || range.newestSequence === previousSequence) return; + previousSequence = range.newestSequence; + await readHandle.loadAfter(range.newestSequence, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, store.navigation()); + } + }; + void recoverTurn().catch(() => undefined); + } + while (true) { + const snapshot = store.snapshot(); + const requiredMessageId = options.requiredAssistantMessageId; + const settled = + snapshot.ready && + (requiredMessageId === undefined || store.hasDurableMessage(requiredMessageId)) && + (requiredTurnId === undefined || + transcriptRecordsTerminalTurn(snapshot.messages, requiredTurnId)); + if (settled || Date.now() >= deadline) { + return { + messages: retainedDurable + ? mergeTranscriptRanges(retainedDurable, store.durableEntries(), snapshot.messages) + : [...snapshot.messages], + settled, + }; + } + await Promise.race([ + nextChange, + cancellation, + new Promise((resolve) => + globalThis.setTimeout(resolve, Math.max(0, deadline - Date.now())), + ), + ]); + } + } finally { + cancelled = true; + globalThis.clearTimeout(openTimeout); + options.signal?.removeEventListener('abort', abort); + await handle?.close().catch(() => undefined); + } +} + +function mergeTranscriptRanges( + retained: ReturnType, + current: ReturnType, + currentMessages: readonly StoredMessage[], +): StoredMessage[] { + const durableBySequence = new Map(retained.map(({ sequence, message }) => [sequence, message])); + for (const { sequence, message } of current) durableBySequence.set(sequence, message); + const durable = [...durableBySequence] + .sort(([left], [right]) => left - right) + .map(([, message]) => message); + const durableIds = new Set(durable.map((message) => message.id)); + return durable.concat(currentMessages.filter((message) => !durableIds.has(message.id))); +} + +function transcriptRecordsTerminalTurn( + messages: readonly StoredMessage[], + turnId: string, +): boolean { + return messages.some( + (message) => + message.type === 'turn_state' && + message.turnId === turnId && + message.status !== 'running', + ); +} diff --git a/apps/desktop/src/renderer/session-message-settlement.ts b/apps/desktop/src/renderer/session-message-settlement.ts deleted file mode 100644 index f609eb2a86..0000000000 --- a/apps/desktop/src/renderer/session-message-settlement.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { StoredMessage } from '@maka/core/session'; -import type { MakaBridge } from '../preload/bridge-contract.js'; -import { DesktopTranscriptRangeStore } from './platform/desktop/desktop-transcript-range-store.js'; - -const COMMITTED_ASSISTANT_SETTLE_TIMEOUT_MS = 480; - -export interface RefreshMessagesOptions { - requiredAssistantMessageId?: string; - signal?: AbortSignal; -} - -export type TranscriptSettlementSource = Pick; - -export async function readSettledMessagesFrom( - transcripts: TranscriptSettlementSource, - sessionId: string, - options: RefreshMessagesOptions = {}, -): Promise<{ messages: StoredMessage[]; settled: boolean }> { - return readSettledMessagesUsing(transcripts, sessionId, options); -} - -export async function readSettledMessages( - sessionId: string, - options: RefreshMessagesOptions = {}, -): Promise<{ messages: StoredMessage[]; settled: boolean }> { - return readSettledMessagesUsing(window.maka.transcripts, sessionId, options); -} - -async function readSettledMessagesUsing( - transcripts: TranscriptSettlementSource, - sessionId: string, - options: RefreshMessagesOptions, -): Promise<{ messages: StoredMessage[]; settled: boolean }> { - const deadline = Date.now() + COMMITTED_ASSISTANT_SETTLE_TIMEOUT_MS; - const store = new DesktopTranscriptRangeStore(sessionId); - let notify: () => void = () => {}; - const changed = () => new Promise((resolve) => { - notify = resolve; - }); - let nextChange = changed(); - let cancelOpen = () => {}; - let rejectCancellation!: (error: Error) => void; - const cancellation = new Promise((_resolve, reject) => { - rejectCancellation = reject; - }); - void cancellation.catch(() => undefined); - let cancelled = false; - const cancel = (error: Error) => { - if (cancelled) return; - cancelled = true; - cancelOpen(); - rejectCancellation(error); - }; - const abort = () => cancel(new Error('Desktop transcript settlement was cancelled')); - options.signal?.addEventListener('abort', abort, { once: true }); - if (options.signal?.aborted) abort(); - const openTimeout = globalThis.setTimeout( - () => cancel(new Error('Desktop transcript settlement timed out while opening')), - Math.max(0, deadline - Date.now()), - ); - const opening = transcripts.open( - sessionId, - (batch) => { - if (!store.accept(batch)) return; - notify(); - nextChange = changed(); - }, - (close) => { - cancelOpen = close; - if (cancelled) close(); - }, - ); - void opening.catch(() => undefined); - let handle: Awaited | undefined; - try { - handle = await Promise.race([opening, cancellation]); - globalThis.clearTimeout(openTimeout); - while (true) { - const snapshot = store.snapshot(); - const requiredMessageId = options.requiredAssistantMessageId; - const settled = - snapshot.ready && - (requiredMessageId === undefined || store.hasDurableMessage(requiredMessageId)); - if (settled || Date.now() >= deadline) { - return { messages: [...snapshot.messages], settled }; - } - await Promise.race([ - nextChange, - cancellation, - new Promise((resolve) => - globalThis.setTimeout(resolve, Math.max(0, deadline - Date.now())), - ), - ]); - } - } finally { - globalThis.clearTimeout(openTimeout); - options.signal?.removeEventListener('abort', abort); - await handle?.close().catch(() => undefined); - } -} diff --git a/apps/desktop/src/renderer/session-workspace-actions.ts b/apps/desktop/src/renderer/session-workspace-actions.ts index b7ae802ba6..656b4206a0 100644 --- a/apps/desktop/src/renderer/session-workspace-actions.ts +++ b/apps/desktop/src/renderer/session-workspace-actions.ts @@ -35,13 +35,12 @@ import type { StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; -import { MESSAGE_QUEUE_MAX_ENTRIES } from '@maka/runtime-host/protocol'; import { clearNewTaskReloadIntent, markNewTaskReloadIntent } from './new-task-reload-intent.js'; import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js'; import { mergeTransientMessageProjection, reconcileTransientMessages, -} from './transient-message-projection.js'; +} from './application/contracts/transient-message-projection.js'; type RefBox = { current: T }; @@ -146,21 +145,14 @@ export function createSessionWorkspaceActions(deps: { const pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending || pending.size === 0) return; try { - // A legal Host queue already fills the protocol's per-query cap, and an - // unreconciled root Message sits beside it, so asking about every row at - // once fails the whole proof and retires nothing. const messageIds = [...pending.keys()]; - const cancelled: string[] = []; - for (let from = 0; from < messageIds.length; from += MESSAGE_QUEUE_MAX_ENTRIES) { - const result = await window.maka.sessions.queryCancelledMessages( - sessionId, - messageIds.slice(from, from + MESSAGE_QUEUE_MAX_ENTRIES), - ); - cancelled.push(...result.cancelledMessageIds); - } + const { cancelledMessageIds } = await window.maka.sessions.queryCancelledMessages( + sessionId, + messageIds, + ); const current = transientMessagesBySessionRef.current.get(sessionId); if (!current) return; - for (const messageId of cancelled) current.delete(messageId); + for (const messageId of cancelledMessageIds) current.delete(messageId); if (current.size === 0) transientMessagesBySessionRef.current.delete(sessionId); reprojectActiveTransients(sessionId); } catch { diff --git a/apps/desktop/src/renderer/settled-message-merge.ts b/apps/desktop/src/renderer/settled-message-merge.ts index 35a41f6dad..9bee69f6e6 100644 --- a/apps/desktop/src/renderer/settled-message-merge.ts +++ b/apps/desktop/src/renderer/settled-message-merge.ts @@ -25,7 +25,24 @@ export function mergeSettledMessages( ): StoredMessage[] { const incomingById = new Map(incoming.map((message) => [message.id, message])); const knownIds = new Set(current.map((message) => message.id)); - return current - .map((message) => incomingById.get(message.id) ?? message) - .concat(incoming.filter((message) => !knownIds.has(message.id))); + const next = current.map((message) => incomingById.get(message.id) ?? message); + let anchor: number | undefined; + let pending: StoredMessage[] = []; + for (const message of incoming) { + const currentIndex = next.findIndex((candidate) => candidate.id === message.id); + if (currentIndex >= 0) { + if (pending.length > 0) { + next.splice(currentIndex, 0, ...pending); + pending = []; + } + anchor = next.findIndex((candidate) => candidate.id === message.id) + 1; + continue; + } + if (knownIds.has(message.id)) continue; + pending.push(message); + } + if (pending.length > 0) { + next.splice(anchor ?? next.length, 0, ...pending); + } + return next; } diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 645e408027..85aeb72550 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -891,7 +891,14 @@ function bridge(options: { }), send: async () => ({ ok: true, turnId: 'story-side-chat-turn' }), stop: async () => undefined, - steer: async () => ({ kind: 'started', turnId: 'story-side-chat-turn' }), + submitFollowUp: async () => ({ kind: 'started', turnId: 'story-side-chat-turn' }), + queryMessageExecutions: async (_sessionId, messageIds) => ({ + resolutions: messageIds.map((messageId) => ({ messageId, state: 'pending' as const })), + }), + retractQueueEntry: async () => undefined, + promoteQueueEntry: async () => undefined, + updateQueueEntry: async () => undefined, + reorderQueueEntries: async () => undefined, setPermissionMode: async (_sessionId, mode) => ({ ...SIDE_CHAT_SESSION, permissionMode: mode, diff --git a/docs/archive/side-chat-ablation-2026-09-11.md b/docs/archive/side-chat-ablation-2026-09-11.md new file mode 100644 index 0000000000..e05a094769 --- /dev/null +++ b/docs/archive/side-chat-ablation-2026-09-11.md @@ -0,0 +1,180 @@ + + +# Side Conversation 简化消融实验(2026-09-11) + +这是 PR #4901 的一次本地实验快照,不是新的架构契约。 + +## 结论 + +原实现不是这次实验找到的最简单写法。保留必要的恢复机制,合并五处重复逻辑后,生产代码减少 **40 行非空行**,原有 209 项相关测试全部通过;加强两类边界输入后,实际实现的 210 项相关测试全部通过。 + +减少行数不是唯一选择标准:一个更短、也通过原测试的逐条恢复方案被拒绝,因为它会对每条恢复身份重复扫描历史并调度状态更新。本次没有证明全局最小实现,也没有测量实际交互性能。 + +## 方法与复现 + +- 对照提交:`a450cc7e7675a4fbe2f7e141d2b5ddcc4668c7e3`。 +- 环境:Node `v24.14.0`,仓库现有 npm / esbuild,不添加依赖。 +- 每次只对同一个对照提交做一项生产源码变换;通过 Node 加载钩子在内存中转译,不覆盖源码或构建产物。最终再测试五项简化组合。 +- 基础消融固定对照提交中的 hook 测试,其余测试使用已构建的同版本产物。覆盖 hook、临时消息投影、服务适配、transcript range、执行 IPC、observer、队列状态和共享 Composer,共 209 项。 +- 另用两类输入探针检查原测试遗漏:Host 编辑排队文本;迟到的 started 回执读取历史失败。探针与基础结果分别记录,不把删除代码后仍然通过测试当作等价证明。 +- 实验脚本和一次性 npm 命令归档在本地分支 `experiment/side-chat-ablation-20260911`,未推送。下述验证记录对应 9 月 11 日的实验快照;生产简化及回归测试随本报告单独提交,不包含一次性实验脚本。 + +在实验分支、依赖和 workspace 构建产物准备好后运行: + +```sh +npm run prototype:side-chat-ablation +npm run prototype:side-chat-ablation -- baseline combined-simplification +npm run prototype:side-chat-ablation -- --interactive +``` + +脚本路径:`apps/desktop/src/renderer/features/workbar/tools/side-chat/side-chat-ablation.prototype.mjs`。每项打印 JSON:变体、问题、非空行变化、退出码、通过数、失败数和失败测试。变体失败属于实验数据,须查看逐项退出码,不能把外层脚本正常退出理解为所有变体通过。 + +实验分支故意将 Node-only 一次性脚本放在被研究模块旁边;不要将这个脚本或 npm 命令合入生产,renderer 架构检查会拒绝它的 Node 环境依赖。 + +## 单项消融结果 + +下表均使用同一组 209 项原有测试;每行独立变更,不是逐步累积删除。 + +| 变体 ID | 变更 | 通过 / 失败 | 决策与证据 | +| --- | --- | --- | --- | +| `baseline` | 原实现 | 209 / 0 | 对照 | +| `no-execution-recovery` | 删除执行身份恢复 | 207 / 2 | 保留:取消消息不能清理,观察中断期间的中间后续 Turn 不能恢复 | +| `no-targeted-recovery` | 删除窗口外 Turn 定向读取 | 208 / 1 | 保留:A→B→C 中断后,只有最新 C 的窗口不足以恢复 B | +| `arm-started-receipt` | started 回执直接激活 Turn | 205 / 4 | 保留原判断:迟到回执可能重新激活已完成 Turn,或影响更新的活动 Turn | +| `no-retained-terminal-proof` | 仅信当前窗口,不查本地保留的终态 | 208 / 1 | 保留:B 离开窗口不代表它没有完成 | +| `no-durable-retirement` | 不从 pending Map 清理已有持久化消息 | 205 / 4 | 保留:持久化身份必须退出本地待处理投影 | +| `unbatched-executions` | 所有身份一次发给 Host | 208 / 1 | 保留:65 个身份必须拆成 64+1;Desktop 上限仍为 4096 | +| `no-terminal-admission-replay` | 终态恢复不重放 admission | 208 / 1 | 保留:重连需要先恢复消息与 Turn 的归属 | +| `no-queue-shadow` | 删除队列到临时消息的投影 | 209 / 0 | **拒绝**:新增 Host 编辑探针失败,原测试覆盖不足 | +| `no-eager-transcript-read` | 由 observation-ready 统一触发首次读取 | 209 / 0 | 采用:减少 9 行,移除重复初始读取 | +| `no-dead-admission-branch` | 删除早返回后的不可达 admission 分支 | 209 / 0 | 采用:减少 6 行 | +| `one-pending-reconciliation` | 集中 own-Turn 过滤和 pending 清理 | 209 / 0 | 采用:减少 10 行,调用者无需重复过滤 | +| `single-receipt-read-exit` | 回执读取成功/失败共享后续判断 | 209 / 0 | 采用:减少 10 行,且修复读失败绕过保留终态的问题 | +| `shared-query-validation` | 两个 IPC 查询复用身份参数校验 | 209 / 0 | 采用:减少 5 行,保留独立查询和传输分批 | +| `shared-recovery-updates` | 恢复循环逐条复用归属/删除 helper | 209 / 0 | **拒绝**:虽减少 25 行,但重复扫描历史、逐条调度状态更新;未做性能基准测试 | +| `combined-simplification` | 合并上述五项采用的变更 | 209 / 0 | 采用:合计减少 40 行,保留批量恢复更新 | + +## 覆盖缺口探针 + +| 探针 | 原实现 | 对应变体 | 发现 | +| --- | --- | --- | --- | +| Host 将排队消息从 `queued follow-up` 改成 `Host-edited follow-up` | 209 / 0 | 删除队列投影:208 / 1 | 本地临时消息仍显示旧文本;不能直接删除队列投影 | +| 已保留 B 终态、B 离开窗口,迟到 started(B) 的定向读取拒绝 | 208 / 1 | 共享读取出口:209 / 0 | 原 catch 直接重新激活 B;简化后读失败只表示没有新增证据,仍检查保留的终态 | + +这两类输入已进入正式 hook 回归测试:加强现有队列测试的 Host 编辑输入,将已有迟到回执测试参数化为读取成功与失败两种情况。实际测试总数由 209 增为 210,没有为实验脚本新增测试框架。 + +## 生产候选 + +- `use-quote-companion.ts`:非空行 1677 → 1642;统一 pending 清理、读取出口,删除重复读取和不可达分支。保留所有权恢复、定向历史读取、终态证据、队列投影及批量更新。 +- `runtime-host-session-execution-ipc-main.ts`:非空行 1038 → 1033;共享 `requiredMessageIds` 校验,不改变身份规则、重复检查、4096 上限或 64 条传输分批。 +- `quote-companion-retry.test.ts`:覆盖上述 Host 编辑和读取失败场景。 + +## 验证与限制 + +- 实际候选重新构建后:210 / 210 项相关测试通过。 +- Desktop main 构建、renderer 构建、Desktop 完整 typecheck、根 lint / format 检查、Desktop knip、`git diff --check` 通过。 +- Renderer 构建仍提示部分 chunk 超过 500 kB;入口和第三方声明校验通过。该提示不是这次消融的性能测量。 +- Renderer 架构 checker 的 103 项 fixture 测试通过;移出一次性脚本后,当前树与对照提交的架构检查通过。 +- 没有执行全仓测试、真实 Electron 断连/多后续消息手工验收或穷举异步时序;不能据此声称所有竞态已消除或实现已达到理论最简。 + +## 9 月 12 日主线合并复核 + +- 同步主线 `12d3fb9332`(含 Renderer transcript window 重构),保留上述五项简化。旧的 Turn-ID history 导航已被主线删除,因此定向恢复改为通过现有 Host `listTurns` 取得 `firstSequence`,再使用新的 `loadAround` / `loadAfter` 读取到目标终态;仍受 settlement 时限约束,不将缺少位置或回复视为完成。 +- 将现有 settlement 实现迁入 `platform/desktop/session-message-settlement.ts`,更新调用方并删除旧实现与冗余内部转发。没有新增 Host 协议,也没有恢复主线删除的导航接口;架构清单同步移除了旧的 platform-to-legacy 依赖。 +- 补充跨页终态、缺少索引位置用例。清理旧 `dist` 并重新构建后,Desktop 全量 2527 / 2527、共享 UI 419 / 419 测试通过;全仓 build / typecheck、lint / format、Desktop 与 UI knip、103 项架构 fixture 和相对主线的架构检查通过。此次相关回归为 209 项,计数变化包含主线替换旧 transcript 测试。 +- 逐项核对 PR #4901 的 3 条讨论评论、18 次 review 提交和 6 个行内线程。6 个线程均已关闭,其中正常 handoff 的 P1 已由 reviewer 撤回;迟到回执、终态/多后继恢复、pending 真正退休、64-ID 分批、等待 Host 准入均有实现和回归覆盖。review 正文提出的窗口外回复恢复也已按主线新接口重新验证。 +- 仍不声称完成真实 Desktop 的 Enter / Shift+Enter、多条追问、编辑/重排/撤回及断连重连手工验收;独立人工批准也尚未获得。线程关闭不等同于界面验收或批准合并。 + +## 9 月 12 日最新主线与真实 Desktop 验收 + +本节更新前一节的验证状态,前述实验数字仍对应各自的历史快照。最终合入主线 +`83aa12a29c57e82bfe807e0913a35a90881cf6d0`,保留主线的 +`SessionExecutionProjection`、`activeHostTurn` 和共享 `LiveTurnBuffer`。 +迟到 admission 只证明归属,不能覆盖 Host 的当前执行快照;没有增加执行调度器或新协议。 + +### 实际发现与修复 + +- 回执丢失后,取消证明需要释放对应 admission;已持久化或 owned 的消息也需要释放 admission 槽位。 + 否则消息恢复了,Composer 仍不能正常继续发送。新增 cancelled / owned 两条回归。 +- 最新主线的普通后继用户消息不再依赖 `steeringEventId`。Projector 现在从普通 durable user + 恢复归属;Desktop observer 在队列条目消失时查询现有 Host `queryMessageExecutions`, + 区分消费、取消和未决,并在后继内容事件前发布归属。队列消失本身不再被当作撤回。 + 这次真实验收发现的缺陷独立于 reviewer 在旧提交上已经撤回的正常 handoff P1。 +- 断连期间 B/C 均完成后,重连虽恢复回复,旧队列仍可能显示 B。终态 seed 现在也发布权威空队列, + 并先恢复 admission / queue,再发布终态内容;普通用户消息、空队列及事件顺序均有回归。 +- Electron 主窗口的捕获阶段 drop 防护会吞掉 Composer 内部拖放。队列使用专用 MIME 和目标标记, + 主窗口允许该目标上的队列拖放;验收使用真实 Playwright `dragTo`。 +- 截图发现初始问题落到回复后方。Admission、started 和 ownership recovery 现在将临时问题绑定到 + Host Turn;立即启动的 next-turn 追问,以及跨到后继 Turn 的 steering,会从待发送显示归位。 + ChatView 按消息实际归属的 Turn 放置问题,完成、断连或后继开始不再把问题挪到回复之后。 + 真实 hook → ChatView 回归先复现失败,再验证发送回执 / admission 先后、历史读失败、终态、 + 后继切换,以及 raced steering 的 admission / ownership recovery 两条路径。 + +### 所有 reviewer 评论核对 + +再次读取 PR #4901:3 条讨论评论、18 次 review 提交、6 个行内线程;6 个线程均已 resolved。 +以下同时核对线程内容与 review 正文,未将 resolved 状态当作实现正确性的证据。 + +| 意见 | 本轮核验 | +| --- | --- | +| 迟到 started(B) 不能重新激活已经完成的 B,包括 B 在窗口外或读取失败 | 执行状态只来自 Host projection;定向恢复与保留终态回归通过 | +| 正常 queued handoff P1 | 保留 reviewer 的历史撤回结论;最新主线实际 handoff 缺陷按上节单独修复并验收 | +| 终态 successor 重连 admission,以及 A→B→C 中间 B 归属恢复 | 终态 seed + 未决 ID 的 owned / cancelled / pending 查询;两条已完成回复均恢复 | +| Durable twin 必须真正退出 pending Map,unknown ownership 仍可见 | 复用 `reconcileTransientMessages`,仅传入侧聊实际可显示的 own-Turn durable 消息 | +| 执行查询遵守每批 64 个 ID,Desktop 输入上限 4096 | 现有 IPC 边界分批;65 个混合结果、重复/非法 ID、4097 个 ID 回归通过 | +| Follow-up 必须等待 Host admission | 两种 placement 均传 `{ waitForHostAdmission: true }`;服务适配测试覆盖 | +| 仅在 review 正文提出的窗口外已完成 B 回复恢复 | 现有 Turn index 的 `firstSequence` + transcript range 分页定向读取;缺少位置/终态不算完成 | +| 共享 transient 生命周期,保留侧聊自己的 fork/quote/可见历史边界 | 共用 projection / reconciliation 实现,旧文件已迁移并删除;未引入第二套 Host 执行权威 | +| 实际 Desktop Enter / Shift+Enter、多条追问、编辑/重排/撤回、断连重连 | 新增一个真实 Electron 窗口验收,以下场景全部通过 | + +### 验收与验证结果 + +`apps/desktop/e2e/side-chat-followups.spec.ts` 在隔离用户目录中运行实际 Electron main/preload、 +Host 和 renderer,使用确定性 fake model:长回复中 Enter 排入三条消息,编辑、原生拖动重排、 +撤回、Shift+Enter、提升队列条目;随后完成两条普通后继。最后关闭真实 Desktop transport, +保留运行中的 Host,让外部 Host client 在观察间隙完成两条后继,再恢复观察,断言两条回复可见、 +队列为空且无 Stop。它保护主窗口 drop 监听器与 main/preload 重连线路;状态组合仍在低层回归中。 +E2E 总预算随最新 main 从 34 增为 35,只新增一个窗口测试,没有增加重试或延长时限。 + +- 最新 Desktop 全量 **2565/2565**、共享 UI **426/426** 通过;其中侧聊 hook **63/63**。 +- Projector / observer **75/75** 通过;全仓 build / typecheck、lint / format、Desktop / UI knip、 + 103 项架构 fixture、相对最新 main 的架构检查及 E2E budget 检查通过。 +- 最终真实 Electron 验收 **1/1** 通过(约 11 秒),四张截图已查看;有序问题、普通后继回复、 + 重连后两条回复与空队列均符合预期。这是自动化应用验收,不代表独立人工批准。 +- 本地证据位于 `apps/desktop/e2e/test-results/side-chat-followups-Side-C-aa834-Host-handoffs-and-reconnect/`: + `trace.zip`、`side-chat-steering.png`、`side-chat-queue.png`、`side-chat-settled.png`、 + `side-chat-reconnected.png`。复现命令(先在根目录构建): + `cd apps/desktop && npx playwright test --config e2e/playwright.config.ts side-chat-followups.spec.ts`。 + +Host 全量仍有一项基线测试竞态,不能称为全仓测试全绿:1924 项中 1911 通过、1 失败、12 跳过。 +`production Host publishes and retires an implementation child patch` 的 fake provider 在约 0.1 秒内 +用完 5 次 PTY Read,抛出 `PTY child did not publish its input response` 并断开 HTTP; +PTY 随后约 0.5 秒正常输出 `READY` 和 `CHILD_PTY_OK:ping`,但模型重试仍读到原工具结果,最终触发 +5 秒 terminal deadline。独立进程复现相同现象;加载追踪所涉及的 **693 个 tracked 源文件** +逐字节与上述 main 相同,另外两个为构建生成的 model metadata / pricing 文件;没有加载本 PR +修改的 projector。首次默认并发全量还出现过 Bash sandbox 测试超时,该项单独运行通过。 +本轮未修改无关 Host 夹具、放宽时限或用重试掩盖失败;详细本地日志为 +`/tmp/maka-pr-4901-host-tests-bounded.log`、`/tmp/maka-pr-4901-host-pty-probe.log` 和 +`/tmp/maka-pr-4901-host-base-comparison.json`。 + +规范审查未发现硬性违反;需求审查及实际验收中发现的上述问题均已修复并覆盖回归。 +GitHub 的独立人工 approval 仍待取得,最终合并由人工决定。 + +Generated-by: Codex diff --git a/docs/side-conversation.md b/docs/side-conversation.md index 4fa8f25810..71e0f426e4 100644 --- a/docs/side-conversation.md +++ b/docs/side-conversation.md @@ -50,8 +50,9 @@ The generic side-conversation entry extends that foundation: - only instructions submitted in the side chat are active; explicit side-chat actions may use the inherited permission profile, and the permission can be changed from the side Composer; -- a running side turn accepts a Steer message at the next model step while Stop - remains available; +- while a side turn is running, Enter queues a follow-up for the next turn and + Shift+Enter steers the active turn at its next model step; both appear + immediately, while Stop remains available; - the side Composer shares `/` Skill discovery, `@` file references, files, quotes, and draft ownership with the main Composer; - settled side answers expose Copy, Info, and Regenerate without navigating the @@ -305,8 +306,8 @@ Maka now has the first usable slice of the same architecture: - multiple numbered side-chat tabs with independent drafts, forks, streams, and quote queues; - the same Composer shell as the main conversation, including a functional - attachment menu, Skill and file mentions, inherited permission menu, and - mid-turn Steer submission; + attachment menu, Skill and file mentions, inherited permission menu, + Enter-to-queue / Shift+Enter-to-Steer routing, and Host-backed queue controls; - the same answer metadata surface for Copy, Info, and Regenerate, with Branch withheld because it would navigate outside the temporary side-tab lifecycle; - no content-area close action: Side Chat lifetime belongs exclusively to tab diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 35c833ffd5..4bffffaf3a 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 31 | -| platform-contract | 32 | +| platform-contract | 33 | -Total Windows-excluded declarations: **90** +Total Windows-excluded declarations: **91** ## Inventory diff --git a/packages/runtime-host/protocol-compatible-changes/base64-length-allocation.json b/packages/runtime-host/protocol-compatible-changes/base64-length-allocation.json index ce85ab48ac..c5c591178e 100644 --- a/packages/runtime-host/protocol-compatible-changes/base64-length-allocation.json +++ b/packages/runtime-host/protocol-compatible-changes/base64-length-allocation.json @@ -1,5 +1,5 @@ { - "epoch": 143, + "epoch": 147, "files": [ "packages/runtime-host/src/protocol/artifact.ts", "packages/runtime-host/src/protocol/session-transcript.ts" diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 555e5c15ee..9e50f6fc25 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -322,6 +322,112 @@ test('admits an in-flight message only after its durable Turn ownership is recor assert.deepEqual(projector.noteDurableTranscriptMessages([durableMessage]), []); }); +test('admits and reseeds an ordinary follow-up from its durable root message', () => { + const current = snapshot(); + const message: StoredMessage = { + type: 'user', + id: 'followup-1', + turnId: 'turn-1', + ts: 1, + text: 'Next question', + }; + const projector = new RuntimeHostSessionProjector( + current, + createRuntimeHostSessionProjectionSeed([], current), + () => 10, + [], + true, + ); + const admissions = (events: readonly SessionEvent[]) => + events + .filter((event) => event.type === 'message_admission') + .map((event) => ({ + messageId: event.messageId, + turnId: event.turnId, + outcome: event.outcome, + })); + const expected = [{ messageId: 'followup-1', turnId: 'turn-1', outcome: 'admitted' }]; + + assert.deepEqual(admissions(projector.noteDurableTranscriptMessages([message])), expected); + assert.deepEqual(projector.noteDurableTranscriptMessages([message]), []); + const recovered = new RuntimeHostSessionProjector( + current, + createRuntimeHostSessionProjectionSeed([message], current), + () => 20, + [], + true, + ); + assert.deepEqual(admissions(recovered.seedActive(false)), expected); +}); + +test('queue disappearance does not prove a follow-up was retracted', () => { + const previous = snapshot({ + queue: { + hostEpoch: 'host-1', + queueRevision: 1, + steering: [], + followup: [ + { + entryId: 'entry-1', + messageId: 'followup-1', + content: { text: 'Next question' }, + placement: 'next_turn', + state: 'queued', + }, + ], + }, + }); + const projector = new RuntimeHostSessionProjector( + previous, + createRuntimeHostSessionProjectionSeed([], previous), + () => 10, + [], + true, + ); + const next = snapshot({ + projectionRevision: 2, + rootTurn: { sessionId: 'session-1', turnId: 'turn-2', runId: 'run-2', status: 'running' }, + queue: { hostEpoch: 'host-1', queueRevision: 2, steering: [], followup: [] }, + }); + + const update = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: next, + }); + assert.deepEqual( + update.events.filter((event) => event.type === 'message_admission'), + [], + ); +}); + +test('reseeds an empty queue after queued successors completed while disconnected', () => { + const current = snapshot({ + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-3', + runId: 'run-3', + status: 'completed', + terminalEventId: 'complete-3', + }, + queue: { hostEpoch: 'host-1', queueRevision: 7, steering: [], followup: [] }, + }); + const projector = new RuntimeHostSessionProjector( + current, + createRuntimeHostSessionProjectionSeed([], current), + () => 10, + [], + true, + ); + const queue = projector.seedActive(false).find((event) => event.type === 'queue_update'); + assert.ok(queue, 'a replacement must clear the previously rendered queue'); + assert.equal(queue.queueRevision, 7); + assert.deepEqual(queue.steeringEntries, []); + assert.deepEqual(queue.followupEntries, []); +}); + test('reseeds the latest provider retry when the active Turn still carries one', () => { const retry = { phase: 'scheduled' as const, diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 4701e2602d..548b920d3b 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -47,7 +47,7 @@ interface AssistantAccumulator { } export interface RuntimeHostSessionProjectionSeed { - readonly durableSteeringMessages: readonly { + readonly durableUserMessages: readonly { readonly messageId: string; readonly turnId: string; }[]; @@ -59,10 +59,9 @@ export function createRuntimeHostSessionProjectionSeed( snapshot: SessionContinuitySnapshot, ): RuntimeHostSessionProjectionSeed { return { - durableSteeringMessages: transcript + durableUserMessages: transcript .filter( - (message): message is Extract => - message.type === 'user' && message.steeringEventId !== undefined, + (message): message is Extract => message.type === 'user', ) .map((message) => ({ messageId: message.id, turnId: message.turnId })), activeAssistantMessages: @@ -91,7 +90,7 @@ export interface RuntimeHostProjectionUpdate { export class RuntimeHostSessionProjector { #snapshot: SessionContinuitySnapshot; readonly #now: () => number; - readonly #durableSteeringTurnByMessage: Map; + readonly #durableTurnByMessage: Map; // Only live/synthesized messages for the current root belong here. Durable // transcript identity stays in the admission map above, so this render // ledger cannot grow with the lifetime of the session. @@ -108,8 +107,8 @@ export class RuntimeHostSessionProjector { ) { this.#snapshot = structuredClone(snapshot); this.#now = now; - this.#durableSteeringTurnByMessage = new Map( - seed.durableSteeringMessages.map(({ messageId, turnId }) => [messageId, turnId]), + this.#durableTurnByMessage = new Map( + seed.durableUserMessages.map(({ messageId, turnId }) => [messageId, turnId]), ); this.#projectMessageAdmissions = projectMessageAdmissions; const root = snapshot.rootTurn; @@ -164,18 +163,22 @@ export class RuntimeHostSessionProjector { const root = this.#snapshot.rootTurn; if (!root) return []; const events: SessionEvent[] = []; + const queueEvents = + this.#projectMessageAdmissions || queueHasEntries(this.#snapshot.queue) + ? [projectQueueUpdate(this.#snapshot.queue, root.turnId, this.#now())] + : []; if (this.#projectMessageAdmissions) { events.push( ...projectMessageAdmissionEvents( root, - [...this.#durableSteeringTurnByMessage] + [...this.#durableTurnByMessage] .filter(([, turnId]) => turnId === root.turnId) .map(([messageId]) => messageId), this.#now(), ), ); } - if (isRuntimeHostTerminalTurn(root)) return events; + if (isRuntimeHostTerminalTurn(root)) return [...events, ...queueEvents]; // Re-derive the running compaction row on reconnect / restart: the Host keeps // the compaction Turn alive, so a reconnecting client learns of it here. if (root.rootExecutionKind === 'context_compact') { @@ -205,7 +208,7 @@ export class RuntimeHostSessionProjector { } for (const entry of rootQueueInFlight(this.#snapshot.queue)) { if ( - this.#durableSteeringTurnByMessage.has(entry.messageId) || + this.#durableTurnByMessage.has(entry.messageId) || this.#renderedSteeringMessageIds.has(entry.messageId) ) continue; @@ -219,22 +222,19 @@ export class RuntimeHostSessionProjector { content: structuredClone(entry.content), }); } - if (queueHasEntries(this.#snapshot.queue)) { - events.push(projectQueueUpdate(this.#snapshot.queue, root.turnId, this.#now())); - } - return events; + return [...events, ...queueEvents]; } noteDurableTranscriptMessages(messages: readonly StoredMessage[]): SessionEvent[] { const events: SessionEvent[] = []; for (const message of messages) { - if (message.type !== 'user' || message.steeringEventId === undefined) continue; - const previousTurnId = this.#durableSteeringTurnByMessage.get(message.id); - this.#durableSteeringTurnByMessage.set(message.id, message.turnId); + if (message.type !== 'user') continue; + const previousTurnId = this.#durableTurnByMessage.get(message.id); + this.#durableTurnByMessage.set(message.id, message.turnId); if (!this.#projectMessageAdmissions || previousTurnId === message.turnId) continue; events.push({ type: 'message_admission', - id: `host-admission:${message.steeringEventId}`, + id: `host-admission:${message.turnId}:${message.id}`, turnId: message.turnId, ts: this.#now(), messageId: message.id, @@ -395,7 +395,7 @@ export class RuntimeHostSessionProjector { const event = projectSessionEvent(frame); if (event.type === 'steering_message') { if ( - this.#durableSteeringTurnByMessage.has(event.messageId) || + this.#durableTurnByMessage.has(event.messageId) || this.#renderedSteeringMessageIds.has(event.messageId) ) { return emptyUpdate(events); @@ -423,13 +423,10 @@ export class RuntimeHostSessionProjector { root && queueChanged(previousSnapshot.queue, next.queue) ? newlyInFlight(previousSnapshot.queue, next.queue) : []; - if (this.#projectMessageAdmissions) { - events.push(...projectMessageRetractionEvents(previousSnapshot, next, this.#now())); - } if (root && queueChanged(previousSnapshot.queue, next.queue)) { for (const entry of enteredActiveTurn) { if ( - this.#durableSteeringTurnByMessage.has(entry.messageId) || + this.#durableTurnByMessage.has(entry.messageId) || this.#renderedSteeringMessageIds.has(entry.messageId) ) continue; @@ -546,28 +543,6 @@ function projectMessageAdmissionEvents( })); } -function projectMessageRetractionEvents( - previous: SessionContinuitySnapshot, - next: SessionContinuitySnapshot, - ts: number, -): SessionEvent[] { - const root = next.rootTurn ?? previous.rootTurn; - if (!root || previous.queue.hostEpoch !== next.queue.hostEpoch) return []; - const retained = new Set( - [...next.queue.steering, ...next.queue.followup].map((entry) => entry.messageId), - ); - return [...previous.queue.steering, ...previous.queue.followup] - .filter((entry) => entry.state === 'queued' && !retained.has(entry.messageId)) - .map((entry) => ({ - type: 'message_admission' as const, - id: `host-retraction:${next.queue.hostEpoch}:${next.queue.queueRevision}:${entry.messageId}`, - turnId: root.turnId, - ts, - messageId: entry.messageId, - outcome: 'retracted' as const, - })); -} - /** * Presentation-only event that drives the renderer's live "compacting" row. * Emitted on both the live transition (`accept`) and reconnect (`seedActive`) diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 7b7e685fcb..113eb388e4 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -531,27 +531,24 @@ export function ChatView(props: { const railAlignment = resolveRailAlignedTarget(railClaimRef.current, props.scrollTargetTurn); railClaimRef.current = railAlignment.claim; const scrollTargetTurn = railAlignment.target; - const inlineTransientMessages = tailTurnId - ? transientMessages.filter((message) => { - const turn = turns.find((candidate) => candidate.turnId === tailTurnId); - if ( - turn === undefined - || turn.user !== undefined - || turn.timeline.some((item) => item.kind === 'user' && item.messageId === message.id) - ) { - return false; - } - // An unbound row belongs to the Turn the user is looking at; a bound - // one only renders inline in the Turn the Host named. - return ( - message.transientPlacement === 'current_turn' - && message.hostTurnId === tailTurnId - ); - }) - : []; - const inlineTransientMessageIds = new Set( - inlineTransientMessages.map((message) => message.id), - ); + // Ownership also groups retained prompts after their Turn stops running or + // a successor starts. Execution recency must not move a prompt below its reply. + const turnsById = new Map(turns.map((turn) => [turn.turnId, turn])); + const inlineTransientMessagesByTurn = new Map(); + const inlineTransientMessageIds = new Set(); + for (const message of transientMessages) { + const turn = message.hostTurnId ? turnsById.get(message.hostTurnId) : undefined; + if ( + message.transientPlacement !== 'current_turn' + || turn === undefined + || turn.user !== undefined + || turn.timeline.some((item) => item.kind === 'user' && item.messageId === message.id) + ) continue; + const messages = inlineTransientMessagesByTurn.get(turn.turnId) ?? []; + messages.push(message); + inlineTransientMessagesByTurn.set(turn.turnId, messages); + inlineTransientMessageIds.add(message.id); + } const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, @@ -767,7 +764,7 @@ export function ChatView(props: { { if (reorderable && dragEntryId.current) event.preventDefault(); }} @@ -212,6 +213,7 @@ export const ComposerMessageQueue = memo(function ComposerMessageQueue( dragEntryId.current = entry.entryId; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', entry.entryId); + event.dataTransfer.setData('application/x-maka-queue-entry', entry.entryId); }} onDragEnd={() => { dragEntryId.current = null;