From 610e9eda1cc7e1dfc01723e4f611085effd20760 Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Thu, 3 Sep 2026 02:54:45 +0800 Subject: [PATCH 1/3] fix(desktop): open side conversations without a settled turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the Desktop side conversation while the main session's first turn was still running failed with "无法创建侧边对话,请稍后重试。": the panel forked eagerly at mount and only from the latest completed turn, of which there is none during the first turn. The composer is now usable immediately (no eager fork, no preparing block), and the fork is created lazily on the first send: through the latest settled turn if one exists, else with an empty context. An empty copy inherits the source model/cwd/permission and records parentSessionId, but copies no messages, no runtime runs/events, no in-progress Todo, and fabricates no branchOfTurnId lineage. The conversation-copy boundary keeps its `sourceTurnId`, now optional: absent means an empty copy, accepted only for the side_conversation intent (enforced at the protocol decoder and the persistence lineage invariant). Threaded through core, storage, the Runtime Host protocol/coordinator, the desktop IPC/renderer, and the CLI (which now also opens an empty side conversation instead of erroring). RUNTIME_HOST_COMPATIBILITY_EPOCH is bumped for the optional-field wire shape; session.revision.create still requires it, so its shape and fingerprint are unchanged. The now-dead "preparing"/busy-tab pipeline (loading frame, aria-busy, drag/close disabling) is removed since the composer is live at once. Fixes #4507 Generated-by: Claude Code --- apps/desktop/e2e/session-workbar.spec.ts | 22 +- .../permission-response-ipc-boundary.test.ts | 36 +- .../quote-companion-disposal.test.ts | 5 +- .../__tests__/quote-companion-retry.test.ts | 582 ++++++++++-------- ...me-host-session-execution-ipc-main.test.ts | 20 +- .../main/__tests__/workbar-controller.test.ts | 3 - .../src/main/permission-response-guard.ts | 22 +- .../main/runtime-host-desktop-candidate.ts | 9 +- ...runtime-host-session-execution-ipc-main.ts | 8 +- .../controller/use-workbar-controller.ts | 22 +- .../src/renderer/features/workbar/ports.ts | 2 +- .../tools/side-chat/quote-companion-core.ts | 41 +- .../tools/side-chat/quote-companion-panel.tsx | 28 +- .../tools/side-chat/use-quote-companion.ts | 138 ++--- .../use-side-conversation-workspace.ts | 22 +- .../features/workbar/ui/workbar-host.tsx | 4 - .../features/workbar/ui/workbar-surface.tsx | 61 +- .../src/renderer/styles/workbar/shell.css | 4 - .../src/renderer/styles/workbar/side-chat.css | 5 - docs/astryx-surface-file-inventory.md | 2 +- docs/side-conversation.md | 43 +- .../cli/src/runtime-host-session-driver.ts | 13 +- packages/core/src/runtime-inputs.ts | 7 +- packages/core/src/session.ts | 17 +- .../src/__tests__/protocol.test.ts | 4 + .../session-revision-protocol.test.ts | 42 ++ .../session-revision-two-client-uds.test.ts | 34 + packages/runtime-host/src/protocol/index.ts | 9 +- .../src/protocol/session-revision.ts | 23 +- .../server/session-revision-coordinator.ts | 84 ++- .../__tests__/session-copy-cleanup.test.ts | 4 +- .../src/__tests__/session-store.test.ts | 2 + packages/storage/src/session-copy-cleanup.ts | 9 +- packages/storage/src/session-store.ts | 20 +- 34 files changed, 791 insertions(+), 556 deletions(-) diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 777767e19a..f6bee3f5ca 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -401,6 +401,14 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc const companion = page.locator('.maka-quote-companion'); await expect(companion).toBeVisible(); + + // The companion forks lazily on the first send, not when the panel opens. + const sideComposer = companion.locator(COMPOSER_INPUT); + await sideComposer.fill('inspect this source without changing it'); + await sideComposer.press('Enter'); + await expect(companion).toContainText( + 'Fake backend received: inspect this source without changing it', + ); const firstForkId = await waitForCompanionForkId(page, sessionId); await expect(sidebar.locator(`[data-session-id=${JSON.stringify(firstForkId)}]`)).toHaveCount(0); @@ -416,13 +424,6 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc await page.getByRole('button', { name: '展开任务工作栏' }).click(); await expect(companion).toBeVisible(); - const sideComposer = companion.locator(COMPOSER_INPUT); - await sideComposer.fill('inspect this source without changing it'); - await sideComposer.press('Enter'); - await expect(companion).toContainText( - 'Fake backend received: inspect this source without changing it', - ); - const workbarToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first(); const closeActiveSideChat = () => workbarToolbar @@ -449,6 +450,13 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); await openSideChat.click(); await expect(companion).toBeVisible(); + // Fork again on the reopened panel's first send. + const reopenedComposer = companion.locator(COMPOSER_INPUT); + await reopenedComposer.fill('inspect once more before switching away'); + await reopenedComposer.press('Enter'); + await expect(companion).toContainText( + 'Fake backend received: inspect once more before switching away', + ); const secondForkId = await waitForCompanionForkId(page, sessionId); await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index 86b0bac861..5b186c081d 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -92,6 +92,11 @@ describe('permission response IPC boundary', () => { sourceTurnId: 'turn-2', turnId: 'turn-3', }); + // A through-turn branch keeps its sourceTurnId; a spurious copyId is dropped. + assert.deepEqual( + normalizeBranchFromTurnInput({ sourceTurnId: 'turn-legacy', copyId: 'ignored-here' }), + { sourceTurnId: 'turn-legacy' }, + ); assert.deepEqual( normalizeBranchFromTurnInput({ sourceTurnId: 'turn-3', @@ -99,7 +104,16 @@ describe('permission response IPC boundary', () => { sideConversation: true, ignored: 1, }), - { sourceTurnId: 'turn-3', name: 'Branch name', sideConversation: true }, + { + sourceTurnId: 'turn-3', + name: 'Branch name', + sideConversation: true, + }, + ); + // An empty side-conversation branch omits sourceTurnId entirely. + assert.deepEqual( + normalizeBranchFromTurnInput({ sideConversation: true }), + { sideConversation: true }, ); assert.deepEqual( normalizeRuntimeHostBranchFromTurnInput({ @@ -130,11 +144,23 @@ describe('permission response IPC boundary', () => { const invalidActions: Array<() => unknown> = [ () => normalizeRegenerateTurnInput({ sourceTurnId: 'turn-1', turnId: 1 }), - () => normalizeBranchFromTurnInput({ sourceTurnId: 'turn-1', name: 1 }), - () => normalizeBranchFromTurnInput({ sourceTurnId: 'turn-1', sideConversation: 'yes' }), - () => normalizeBranchFromTurnInput({ sourceTurnId: 'x'.repeat(129) }), + () => + normalizeBranchFromTurnInput({ + sourceTurnId: 'turn-1', + name: 1, + }), + () => + normalizeBranchFromTurnInput({ + sourceTurnId: 'turn-1', + sideConversation: 'yes', + }), + () => + normalizeBranchFromTurnInput({ sourceTurnId: 'x'.repeat(129) }), () => normalizeReviseBeforeTurnInput({ sourceTurnId: 1 }), - () => normalizeRuntimeHostBranchFromTurnInput({ sourceTurnId: 'turn-1' }), + () => + normalizeRuntimeHostBranchFromTurnInput({ + sourceTurnId: 'turn-1', + }), () => normalizeRuntimeHostReviseBeforeTurnInput({ sourceTurnId: 'turn-1', copyId: '' }), ]; for (const action of invalidActions) assert.throws(action, /Invalid/); diff --git a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts index fe15499fd0..625380d509 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts @@ -20,7 +20,10 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; -import type { SessionSummary, TurnRecord } from '@maka/core/session'; +import type { + SessionSummary, + TurnRecord, +} from '@maka/core/session'; import { abandonPendingCompanionCopy, createFakeWorkbarServices, 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 ca798394e8..d2e4f29224 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -26,7 +26,11 @@ import { createRoot, type Root } from 'react-dom/client'; 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, TurnRecord } from '@maka/core/session'; +import type { + SessionChangedEvent, + SessionSummary, + TurnRecord, +} from '@maka/core/session'; import { createFakeWorkbarServices, useQuoteCompanion, @@ -54,37 +58,6 @@ type SideChatStopTarget = Parameters[1]; type QueueUpdate = Extract; type QueueEntry = NonNullable[number]; -test('declining Full access through the side-chat hook does not persist the permission mode', async () => { - let confirmations = 0; - let writes = 0; - let setPermissionMode!: (mode: PermissionMode) => Promise; - - const { container } = await renderProbe( - { - setPermissionMode: async (sessionId, mode) => { - writes += 1; - return session(sessionId, { permissionMode: mode }); - }, - }, - { - confirmBypass: async () => { - confirmations += 1; - return false; - }, - onSetPermissionMode: (setter) => { - setPermissionMode = setter; - }, - }, - ); - - assert.ok(container.firstElementChild); - const result = await act(async () => setPermissionMode('bypass')); - - assert.equal(result, false); - assert.equal(confirmations, 1); - assert.equal(writes, 0); -}); - function completeEvent(id: string, turnId: string, ts: number): SessionEvent { return { type: 'complete', id, turnId, ts, stopReason: 'end_turn' }; } @@ -161,7 +134,7 @@ async function renderProbe( onSend?: (send: (text: string) => Promise) => void; onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; - onSetPermissionMode?: (setPermissionMode: (mode: PermissionMode) => Promise) => void; + onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; confirmBypass?: () => Promise; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; @@ -185,6 +158,7 @@ async function renderProbe( onSend: options.onSend ?? (() => undefined), onSteer: options.onSteer, onStop: options.onStop, + onSetPermissionMode: options.onSetPermissionMode, pendingQuotes: options.pendingQuotes, onQuotesConsumed: options.onQuotesConsumed, sourceSession: options.sourceSession, @@ -203,8 +177,9 @@ async function renderProbe( }); await waitUntil( () => - options.ready?.(container) ?? - container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation', + // The fork is created lazily on the first send, so mounting no longer + // produces a companion. Default readiness is just "the probe mounted". + options.ready?.(container) ?? container.firstElementChild != null, ); return { container, root, services }; } @@ -221,6 +196,7 @@ async function renderOwnershipProbe( let send!: (text: string) => Promise; let steer!: (text: string) => Promise; let stop!: () => Promise; + let setPermissionMode!: (mode: PermissionMode) => Promise; let eventHandler: ((event: SessionEvent) => void) | undefined; const subscribeEvents = sideChat.subscribeEvents; const rendered = await renderProbe( @@ -240,6 +216,7 @@ async function renderOwnershipProbe( onSend: (value) => (send = value), onSteer: (value) => (steer = value), onStop: (value) => (stop = value), + onSetPermissionMode: (value) => (setPermissionMode = value), ...options, }, ); @@ -248,6 +225,7 @@ async function renderOwnershipProbe( send: (text: string) => send(text), steer: (text: string) => steer(text), stop: () => stop(), + setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode), emit(event: SessionEvent) { assert.ok(eventHandler); eventHandler(event); @@ -330,93 +308,76 @@ afterEach(async () => { Object.assign(globalThis, originalGlobals); }); -test('retries a busy Side Conversation at the newest settled boundary and clears its banner', async () => { - let listCount = 0; - let sessionChange: ((event: SessionChangedEvent) => void) | undefined; - let releaseRetry: (() => void) | undefined; - const branchInputs: Array<{ sourceTurnId: string; copyId: string }> = []; - const { container } = await renderProbe( - { - listTurns: async () => { - listCount += 1; - return listCount === 1 - ? [settledTurn('turn-before-busy')] - : [settledTurn('turn-before-busy'), settledTurn('turn-after-busy')]; - }, - branchFromTurn: async (_sessionId, input) => { - branchInputs.push({ sourceTurnId: input.sourceTurnId, copyId: input.copyId }); - if (branchInputs.length === 1) { - return { ok: false as const, reason: 'session_busy' as const }; - } - await new Promise((resolve) => { - releaseRetry = resolve; - }); - return { ok: true as const, session: session('side-conversation') }; - }, - subscribeSessionChanges: (handler) => { - sessionChange = handler; - return () => { - if (sessionChange === handler) sessionChange = undefined; - }; - }, +test('first send while the source is still on its first turn forks with an empty context', async () => { + const branchInputs: (string | undefined)[] = []; + const rendered = await renderOwnershipProbe({ + // The panel opens while the main session is still running its first turn: + // no completed turn exists to branch from yet. + listTurns: async () => [runningTurn('first-turn')], + branchFromTurn: async (_sessionId, input) => { + branchInputs.push(input.sourceTurnId); + return { ok: true as const, session: session('side-conversation') }; }, - { ready: () => branchInputs.length === 1 && sessionChange !== undefined }, - ); - assert.match(container.textContent, /main conversation or a linked task is still running/i); - const probe = container.firstElementChild; + send: async () => ({ ok: true as const, turnId: 'empty-first-turn' }), + }); + const probe = rendered.container.firstElementChild; assert.ok(probe); + // No eager fork at mount — the composer is immediately usable and nothing is + // branched until the user sends. + assert.equal(branchInputs.length, 0); + assert.equal(probe.getAttribute('data-companion-id'), ''); await act(async () => { - sessionChange?.({ - reason: 'turn-status-change', - sessionId: 'source-session', - turnId: 'turn-after-busy', - ts: Date.now(), - }); + assert.equal(await rendered.send('explain the running turn'), true); await Promise.resolve(); }); - await waitUntil(() => branchInputs.length === 2 && releaseRetry !== undefined); - assert.equal(probe.getAttribute('data-preparing'), 'false'); - assert.match(container.textContent, /main conversation or a linked task is still running/i); + await awaitCompanion(rendered.container); + // Forking mid-first-turn copies no source transcript: an empty context. + assert.deepEqual(branchInputs, [undefined]); + assert.equal(probe.getAttribute('data-error'), ''); +}); + +test('first send after a completed turn forks through the settled turn', async () => { + const branchInputs: (string | undefined)[] = []; + const rendered = await renderOwnershipProbe({ + listTurns: async () => [settledTurn('done-turn')], + branchFromTurn: async (_sessionId, input) => { + branchInputs.push(input.sourceTurnId); + return { ok: true as const, session: session('side-conversation') }; + }, + send: async () => ({ ok: true as const, turnId: 'through-turn' }), + }); + const probe = rendered.container.firstElementChild; + assert.ok(probe); + assert.equal(branchInputs.length, 0); await act(async () => { - releaseRetry?.(); + assert.equal(await rendered.send('explain the finished turn'), true); await Promise.resolve(); }); - await waitUntil( - () => probe.getAttribute('data-companion-id') === 'side-conversation', - () => - `branch inputs: ${JSON.stringify(branchInputs)}; companion: ${probe.getAttribute('data-companion-id')}; error: ${probe.getAttribute('data-error')}`, - ); - - assert.deepEqual( - branchInputs.map(({ sourceTurnId }) => sourceTurnId), - ['turn-before-busy', 'turn-after-busy'], - ); - assert.notEqual(branchInputs[0]?.copyId, branchInputs[1]?.copyId); + await awaitCompanion(rendered.container); + // A settled turn exists, so the fork carries the full context through it. + assert.deepEqual(branchInputs, ['done-turn']); assert.equal(probe.getAttribute('data-error'), ''); }); -test('does not restart foreground setup when the source Session object refreshes', async () => { +test('does not fork on mount or when the source Session object refreshes', async () => { let branchCount = 0; const { container, root, services } = await renderProbe( { listTurns: async () => [settledTurn('settled-turn')], branchFromTurn: async () => { branchCount += 1; - if (branchCount === 1) { - return { ok: false as const, reason: 'session_busy' as const }; - } - return await new Promise(() => undefined); + return { ok: true as const, session: session('side-conversation') }; }, }, - { sourceSession: session('source-session'), ready: () => branchCount === 1 }, + { sourceSession: session('source-session') }, ); const probe = container.firstElementChild; assert.ok(probe); - await waitUntil( - () => branchCount === 1 && probe.getAttribute('data-preparing') === 'false', - ); + // Lazy fork: mounting never branches, and the composer is immediately usable. + assert.equal(branchCount, 0); + assert.equal(probe.getAttribute('data-companion-id'), ''); await act(async () => { root.render( @@ -429,14 +390,13 @@ test('does not restart foreground setup when the source Session object refreshes ); await Promise.resolve(); }); - - assert.equal(branchCount, 1); - assert.equal(probe.getAttribute('data-preparing'), 'false'); + // A refreshed source identity must not spuriously trigger a fork. + assert.equal(branchCount, 0); }); -test('waits for the source model to become available before forking', async () => { +test('does not fork or send when the source model is unavailable', async () => { let branchCount = 0; - const { container, root, services } = await renderProbe( + const rendered = await renderOwnershipProbe( { listTurns: async () => [settledTurn('settled-turn')], branchFromTurn: async () => { @@ -444,96 +404,56 @@ test('waits for the source model to become available before forking', async () = return { ok: true as const, session: session('side-conversation') }; }, }, - { - sourceSession: session('source-session'), - modelChoices: [], - ready: (current) => - current.firstElementChild?.getAttribute('data-preparing') === 'false', - }, + { sourceSession: session('source-session'), modelChoices: [] }, ); - const probe = container.firstElementChild; + const probe = rendered.container.firstElementChild; assert.ok(probe); assert.equal(branchCount, 0); - assert.equal(probe.getAttribute('data-companion-id'), ''); - - await rerenderProbeSource({ root, services }, session('source-session')); - await waitUntil(() => probe.getAttribute('data-companion-id') === 'side-conversation'); - - assert.equal(branchCount, 1); -}); - -test('replaces an empty companion whose exact model is no longer available', async () => { - const { sourceA, sourceB, forkB } = exactModelRebindScenario(); - let branchCount = 0; - const cleaned: string[] = []; - const { container, root, services } = await renderProbe( - { - branchFromTurn: async () => { - branchCount += 1; - return { - ok: true as const, - session: branchCount === 1 ? session('side-conversation-a') : forkB, - }; - }, - cleanupSessionCopy: async (sessionId) => { - cleaned.push(sessionId); - }, - }, - { - sourceSession: sourceA, - modelChoices: [choiceFor(sourceA)], - ready: (current) => - current.firstElementChild?.getAttribute('data-companion-id') === - 'side-conversation-a', - }, - ); - const probe = container.firstElementChild; - assert.ok(probe); - - await rerenderProbeSource({ root, services }, sourceB); - await waitUntil(() => probe.getAttribute('data-companion-id') === forkB.id); - assert.equal(branchCount, 2); - assert.deepEqual(cleaned, ['side-conversation-a']); + await act(async () => { + assert.equal(await rendered.send('cannot send without a ready model'), false); + await Promise.resolve(); + }); + // The send is refused before any branch is attempted. + assert.equal(branchCount, 0); + assert.equal(probe.getAttribute('data-companion-id'), ''); }); -test('does not commit a fork whose model becomes unavailable during setup', async () => { +test('cleans up a first-send fork whose model no longer matches on commit', async () => { const source = session('source-session'); - const pendingFork = deferred(); + const mismatchedFork = session('side-conversation', REBOUND_MODEL); const cleaned: string[] = []; let branchCount = 0; - const { container, root, services } = await renderProbe( + const rendered = await renderOwnershipProbe( { + listTurns: async () => [settledTurn('settled-turn')], branchFromTurn: async () => { branchCount += 1; - return { ok: true as const, session: await pendingFork.promise }; + return { ok: true as const, session: mismatchedFork }; }, cleanupSessionCopy: async (sessionId) => { cleaned.push(sessionId); }, }, - { - sourceSession: source, - modelChoices: [choiceFor(source)], - ready: () => branchCount === 1, - }, + { sourceSession: source, modelChoices: [choiceFor(source)] }, ); - const probe = container.firstElementChild; + const probe = rendered.container.firstElementChild; assert.ok(probe); await act(async () => { - root.render(probeTree(services, source, [])); - pendingFork.resolve(session('side-conversation-a')); - await pendingFork.promise; + assert.equal(await rendered.send('the fork model drifted'), false); + await Promise.resolve(); }); - await waitUntil(() => probe.getAttribute('data-preparing') === 'false'); - + await waitUntil(() => cleaned.length === 1); + // The fork committed but its model is no longer authorized, so it is torn + // down instead of being adopted. + assert.equal(branchCount, 1); + assert.deepEqual(cleaned, ['side-conversation']); assert.equal(probe.getAttribute('data-companion-id'), ''); - assert.deepEqual(cleaned, ['side-conversation-a']); }); -test('does not replace a fork while its send waits for observation readiness', async () => { - const { sourceA, sourceB, forkB } = exactModelRebindScenario(); +test('retains a fork whose in-flight send is waiting for observation when the model rebinds', async () => { + const { sourceA, sourceB } = exactModelRebindScenario(); let branchCount = 0; let seedA: (() => void) | undefined; const cleaned: string[] = []; @@ -543,10 +463,7 @@ test('does not replace a fork while its send waits for observation readiness', a { branchFromTurn: async () => { branchCount += 1; - return { - ok: true as const, - session: branchCount === 1 ? session('side-conversation') : forkB, - }; + return { ok: true as const, session: session('side-conversation') }; }, subscribeEvents: (sessionId, _handler, onSeeded) => { if (sessionId === 'side-conversation') seedA = onSeeded; @@ -572,101 +489,31 @@ test('does not replace a fork while its send waits for observation readiness', a await act(async () => { firstSend = currentSend('waiting send'); await Promise.resolve(); + }); + // Let the lazy fork commit and establish its subscription (which then blocks + // the send on observation readiness) before the source model rebinds. + await waitUntil(() => seedA !== undefined); + await act(async () => { rendered.root.render(ownershipProbeTree(rendered.services, sourceB, (send) => { currentSend = send; })); await Promise.resolve(); }); + // An in-flight send holds the submit lock, so the model rebind must not + // implicitly discard or replace the fork it is still waiting on. assert.deepEqual(cleaned, [], 'the send lock must retain its fork'); await act(async () => { seedA?.(); assert.equal(await firstSend, false); }); - assert.deepEqual(sendTargets, ['side-conversation']); const probe = rendered.container.firstElementChild; assert.ok(probe); - await waitUntil(() => probe.getAttribute('data-companion-id') === forkB.id); - assert.deepEqual(cleaned, ['side-conversation']); - assert.equal(branchCount, 2); - - await act(async () => { - assert.equal(await currentSend('retry after rebind'), false); - }); - assert.deepEqual(sendTargets, ['side-conversation', 'side-conversation-b']); -}); - -test('replaces an empty stale fork after its pending admission is retracted', async () => { - const { sourceA, sourceB, forkB } = exactModelRebindScenario(); - const pendingSend = deferred<{ - ok: true; - steered: true; - turnId: string; - messageId: string; - }>(); - let admissionId: string | undefined; - let branchCount = 0; - const cleaned: string[] = []; - let currentSend!: (text: string) => Promise; - const rendered = await renderOwnershipProbe( - { - branchFromTurn: async () => { - branchCount += 1; - return { - ok: true as const, - session: branchCount === 1 ? session('side-conversation') : forkB, - }; - }, - cleanupSessionCopy: async (sessionId) => { - cleaned.push(sessionId); - }, - send: async (_sessionId, command) => { - admissionId = command.turnId; - return pendingSend.promise; - }, - }, - { - sourceSession: sourceA, - modelChoices: [choiceFor(sourceA)], - }, - ); - currentSend = rendered.send; - - let sendResult!: Promise; - await act(async () => { - sendResult = currentSend('pending send'); - await Promise.resolve(); - }); - await waitUntil(() => admissionId !== undefined); - await rerenderOwnershipSource(rendered, sourceB, (send) => { currentSend = send; }); - assert.deepEqual(cleaned, [], 'pending admission must retain its fork'); - - await act(async () => { - rendered.emit({ - type: 'message_admission', - id: 'retracted-after-rebind', - turnId: 'old-turn', - ts: 1, - messageId: admissionId as string, - outcome: 'retracted', - }); - await Promise.resolve(); - }); - const probe = rendered.container.firstElementChild; - assert.ok(probe); - await waitUntil(() => probe.getAttribute('data-companion-id') === forkB.id); - assert.deepEqual(cleaned, ['side-conversation']); - assert.equal(branchCount, 2); - - await act(async () => { - pendingSend.resolve({ - ok: true, - steered: true, - turnId: 'old-turn', - messageId: admissionId as string, - }); - assert.equal(await sendResult, false); - }); + // The one fork was reused for the send and never cleaned up behind it. + assert.deepEqual(sendTargets, ['side-conversation']); + assert.deepEqual(cleaned, []); + assert.equal(branchCount, 1); + assert.equal(probe.getAttribute('data-companion-id'), 'side-conversation'); }); test('retains an admitted fork interrupted before send settles when its model changes', async () => { @@ -768,6 +615,7 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a sendResult = send('new prompt'); await Promise.resolve(); }); + await awaitProcessing(container); await act(async () => { emit(completeEvent('late-old-terminal', 'old-turn', 1)); @@ -824,6 +672,7 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag sendResult = send('steer the active turn'); await Promise.resolve(); }); + await waitUntil(() => admissionId !== undefined); await act(async () => { emit(completeEvent('late-old-terminal', 'old-turn', 1)); emit( @@ -953,6 +802,7 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s sendResult = send('continue in the successor turn'); await Promise.resolve(); }); + await waitUntil(() => admissionId !== undefined); await act(async () => { emit( messageAdmittedEvent( @@ -1069,6 +919,7 @@ test('clears a stopped Side Conversation admission when its live retraction is l sendResult = send('stop this queued send'); await Promise.resolve(); }); + await waitUntil(() => admissionId !== undefined); let stopResult!: Promise; await act(async () => { stopResult = stop(); @@ -1198,6 +1049,7 @@ test('releases a queued Side Conversation admission from the Host queue retract' sendResult = send('retract this queued send'); await Promise.resolve(); }); + await awaitProcessing(container); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { @@ -1237,7 +1089,6 @@ test('keeps the same Side Conversation admission across a recoverable subscripti }, send: async () => pendingSend.promise, }); - assert.equal(subscriptionCount, 1); let sendResult!: Promise; await act(async () => { @@ -1245,6 +1096,8 @@ test('keeps the same Side Conversation admission across a recoverable subscripti await Promise.resolve(); }); await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); + // The lazy fork subscribes exactly once, when the first send commits it. + assert.equal(subscriptionCount, 1); await act(async () => { emit(recoverableErrorEvent('recoverable-subscription-error', 'old-turn', 1)); await Promise.resolve(); @@ -1481,11 +1334,15 @@ test('fails a send when observation seed rejects and resubscribes for retry', as return { ok: true as const, turnId: 'retry-turn' }; }, }); - assert.ok(rejectSeed); let failedResult!: Promise; await act(async () => { failedResult = send('observer failure'); + await Promise.resolve(); + }); + // The fork subscribes during the first send; fail that observation seed. + await waitUntil(() => rejectSeed !== undefined); + await act(async () => { rejectSeed?.(new Error('observer failed')); assert.equal(await failedResult, false); }); @@ -1534,6 +1391,197 @@ test('releases a send waiting for observation when the Side Conversation is disp mountedRoot = undefined; }); +test('applies a permission mode picked before the first send once the fork is created', async () => { + const permissionCalls: Array<{ sessionId: string; mode: PermissionMode }> = []; + const probe = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'turn-1' }), + setPermissionMode: async (sessionId, mode) => { + permissionCalls.push({ sessionId, mode }); + return { ...session('side-conversation'), permissionMode: mode }; + }, + }); + + // No fork exists yet: the choice is staged and drives the read-only chip. + await act(async () => { + assert.equal(await probe.setPermissionMode('bypass'), true); + await Promise.resolve(); + }); + const el = probe.container.firstElementChild; + assert.equal(el?.getAttribute('data-permission-mode'), 'bypass'); + assert.equal(el?.getAttribute('data-companion-id'), ''); + assert.deepEqual(permissionCalls, []); + + // The first send creates the fork and applies the staged mode to it. + await act(async () => { + assert.equal(await probe.send('first message'), true); + await Promise.resolve(); + }); + await waitUntil(() => permissionCalls.length === 1); + assert.deepEqual(permissionCalls, [{ sessionId: 'side-conversation', mode: 'bypass' }]); +}); + +test('replaces a stale empty fork on the next send after the source model rebinds', async () => { + const { sourceA, sourceB, forkB } = exactModelRebindScenario(); + let rejectSeed: ((error: unknown) => void) | undefined; + let branchCount = 0; + const cleaned: string[] = []; + let currentSend!: (text: string) => Promise; + const rendered = await renderOwnershipProbe( + { + subscribeEvents: (sessionId, _handler, onSeeded, onSeedError) => { + // The first fork's observation seed fails; the replacement seeds fine. + if (sessionId === 'side-conversation') rejectSeed ??= onSeedError; + else onSeeded?.(); + return () => undefined; + }, + branchFromTurn: async () => { + branchCount += 1; + return { + ok: true as const, + session: branchCount === 1 ? session('side-conversation') : forkB, + }; + }, + cleanupSessionCopy: async (sessionId) => { + cleaned.push(sessionId); + }, + send: async () => ({ ok: true as const, turnId: 'turn-1' }), + }, + { sourceSession: sourceA, modelChoices: [choiceFor(sourceA)] }, + ); + currentSend = rendered.send; + + // The first send commits an (empty) fork, then its observation seed fails, so + // the fork is retained with no content. + let failed!: Promise; + await act(async () => { + failed = currentSend('first message'); + await Promise.resolve(); + }); + await waitUntil(() => rejectSeed !== undefined); + await act(async () => { + rejectSeed?.(new Error('seed failed')); + assert.equal(await failed, false); + }); + const el = rendered.container.firstElementChild; + assert.equal(el?.getAttribute('data-companion-id'), 'side-conversation'); + assert.equal(el?.getAttribute('data-model-ready'), 'true'); + + // The source model rebinds; the empty fork's inherited model is now stale but + // the composer stays usable, and the next send must replace the fork rather + // than being wedged by the retained stale one. + await rerenderOwnershipSource(rendered, sourceB, (send) => { + currentSend = send; + }); + assert.equal(el?.getAttribute('data-model-ready'), 'true'); + + await act(async () => { + assert.equal(await currentSend('retry after rebind'), true); + await Promise.resolve(); + }); + await waitUntil( + () => rendered.container.firstElementChild?.getAttribute('data-companion-id') === forkB.id, + ); + assert.equal(branchCount, 2); + assert.deepEqual(cleaned, ['side-conversation']); +}); + +test('fails closed when the staged permission write fails on the first send', async () => { + let sendCalls = 0; + const probe = await renderOwnershipProbe({ + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'turn-1' }; + }, + setPermissionMode: async () => { + throw new Error('permission write failed'); + }, + }); + + // Stage a stricter mode before the fork exists (source default is 'ask'). + await act(async () => { + assert.equal(await probe.setPermissionMode('explore'), true); + await Promise.resolve(); + }); + + // The first send creates the fork; applying the staged mode fails, so the + // send aborts WITHOUT dispatching the turn and keeps the staged choice. + await act(async () => { + assert.equal(await probe.send('do not run under the inherited mode'), false); + await Promise.resolve(); + }); + assert.equal(sendCalls, 0); + assert.equal( + probe.container.firstElementChild?.getAttribute('data-permission-mode'), + 'explore', + ); +}); + +test('replays the empty copy point across an ambiguous retry even after the source settles', async () => { + const sourceTurnIds: (string | undefined)[] = []; + let listCount = 0; + let branchCount = 0; + const probe = await renderOwnershipProbe({ + listTurns: async () => { + listCount += 1; + return listCount === 1 ? [runningTurn('t1')] : [settledTurn('t1')]; + }, + branchFromTurn: async (_sessionId, input) => { + sourceTurnIds.push(input.sourceTurnId); + branchCount += 1; + if (branchCount === 1) throw new Error('ambiguous outcome lost'); + return { ok: true as const, session: session('side-conversation') }; + }, + send: async () => ({ ok: true as const, turnId: 'turn-1' }), + }); + + // First send: only a running turn exists, so the fork is empty — but the + // branch's outcome is lost (throws), leaving the retry lease open. + await act(async () => { + assert.equal(await probe.send('first attempt'), false); + await Promise.resolve(); + }); + // Second send: the source has since settled a turn, but the retry must REPLAY + // the empty copy point (same copyId) instead of switching to through_turn, + // or the Host fingerprint would reject the reused identity. + await act(async () => { + await probe.send('retry'); + await Promise.resolve(); + }); + await waitUntil(() => branchCount === 2); + assert.deepEqual(sourceTurnIds, [undefined, undefined]); +}); + +test('declining Full access through the side-chat hook does not persist the permission mode', async () => { + let confirmations = 0; + let writes = 0; + let setPermissionMode!: (mode: PermissionMode) => Promise; + + const { container } = await renderProbe( + { + setPermissionMode: async (sessionId, mode) => { + writes += 1; + return session(sessionId, { permissionMode: mode }); + }, + }, + { + confirmBypass: async () => { + confirmations += 1; + return false; + }, + onSetPermissionMode: (setter) => { + setPermissionMode = setter; + }, + }, + ); + + assert.ok(container.firstElementChild); + const result = await act(async () => setPermissionMode('bypass')); + + assert.equal(result, false); + assert.equal(confirmations, 1); + assert.equal(writes, 0); +}); + function QuoteCompanionProbe(props: { sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; @@ -1554,7 +1602,6 @@ function QuoteCompanionProbe(props: { return createElement('div', { 'data-error': companion.error ?? '', 'data-companion-id': companion.companionSession?.id ?? '', - 'data-preparing': String(companion.preparing), }, companion.error); } @@ -1562,6 +1609,7 @@ function QuoteCompanionOwnershipProbe(props: { onSend: (send: (text: string) => Promise) => void; onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; + onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; sourceSession?: SessionSummary; @@ -1580,6 +1628,7 @@ function QuoteCompanionOwnershipProbe(props: { props.onSend(companion.send); props.onSteer?.(companion.steer); props.onStop?.(companion.stop); + props.onSetPermissionMode?.(companion.setPermissionMode); return createElement('div', { 'data-companion-id': companion.companionSession?.id ?? '', 'data-error': companion.error ?? '', @@ -1587,6 +1636,8 @@ function QuoteCompanionOwnershipProbe(props: { 'data-live-text': companion.liveTurn?.steps.find((step) => step.text)?.text?.text ?? '', 'data-streaming': String(companion.streaming), 'data-processing': String(companion.processing), + 'data-model-ready': String(companion.modelReady), + 'data-permission-mode': companion.permissionMode ?? '', }); } @@ -1631,6 +1682,10 @@ function settledTurn(turnId: string): TurnRecord { return { turnId, status: 'completed', partialOutputRetained: false }; } +function runningTurn(turnId: string): TurnRecord { + return { turnId, status: 'running', partialOutputRetained: false }; +} + async function waitUntil(predicate: () => boolean, diagnostics?: () => string): Promise { for (let attempt = 0; attempt < 50; attempt += 1) { if (predicate()) return; @@ -1642,3 +1697,16 @@ async function waitUntil(predicate: () => boolean, diagnostics?: () => string): `Timed out waiting for the Side Conversation state${diagnostics ? ` (${diagnostics()})` : ''}`, ); } + +// The fork is created lazily during the first send. Emitting fork events (or +// stopping) before that fork commits would race a not-yet-established +// subscription, so tests await the committed companion first. +async function awaitCompanion(container: Element, id = 'side-conversation'): Promise { + await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === id); +} + +// A live-turn admission is armed only once the send reaches its optimistic +// dispatch, which is a stricter barrier than the fork merely committing. +async function awaitProcessing(container: Element): Promise { + await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); +} 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 c5b865ed70..1c71e36785 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 @@ -194,7 +194,7 @@ test("retries committed Branch and Revision copies with the renderer-owned ident const calls: Array<{ kind: "branch" | "revision"; targetSessionId: string; - sourceTurnId: string; + sourceTurnId: string | undefined; }> = []; let fallbackIds = 0; const ipc = ipcHarness(); @@ -237,25 +237,23 @@ test("retries committed Branch and Revision copies with the renderer-owned ident { channel: "sessions:branchFromTurn", copyId: "branch-copy-1", - sourceTurnId: "branch-source-turn", + payload: { sourceTurnId: "branch-source-turn", copyId: "branch-copy-1" }, }, { channel: "sessions:reviseBeforeTurn", copyId: "revision-copy-1", - sourceTurnId: "revision-source-turn", + payload: { sourceTurnId: "revision-source-turn", copyId: "revision-copy-1" }, }, ] as const) { await assert.rejects( - ipc.invoke(input.channel, "source-session", { - sourceTurnId: input.sourceTurnId, - copyId: input.copyId, - }), + ipc.invoke(input.channel, "source-session", input.payload), /response was lost/, ); - const retried = (await ipc.invoke(input.channel, "source-session", { - sourceTurnId: input.sourceTurnId, - copyId: input.copyId, - })) as { id: string }; + const retried = (await ipc.invoke( + input.channel, + "source-session", + input.payload, + )) as { id: string }; assert.equal(retried.id, input.copyId); } diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index e3b51fc19d..bde0503b47 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -469,9 +469,6 @@ describe('useWorkbarController', () => { await act(async () => controller().commands.toggleRight()); assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true); - await act(async () => - controller().host.onPreparingStateChange?.(panelId, false), - ); await act(async () => controller().host.onContentStateChange?.(panelId, true)); const tab = controller().host.panelsState.right.tabs.find( (candidate) => candidate.id === `side-chat:${panelId}`, diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index e29fe05aab..414ab12c87 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -53,7 +53,13 @@ interface WorkspaceFileReferencePosition { start: number; } -export type RuntimeHostBranchFromTurnInput = BranchFromTurnInput & { copyId: string }; +/** Branch input after normalization. Absent `sourceTurnId` forks empty. */ +export type NormalizedBranchFromTurnInput = { + sourceTurnId?: string; + name?: string; + sideConversation?: boolean; +}; +export type RuntimeHostBranchFromTurnInput = NormalizedBranchFromTurnInput & { copyId: string }; export type RuntimeHostReviseBeforeTurnInput = ReviseBeforeTurnInput & { copyId: string }; interface NormalizedSendSessionCommand { @@ -145,7 +151,7 @@ export function normalizeRegenerateTurnInput(input: unknown): RegenerateTurnInpu }; } -export function normalizeBranchFromTurnInput(input: unknown): BranchFromTurnInput { +export function normalizeBranchFromTurnInput(input: unknown): NormalizedBranchFromTurnInput { const value = requireObject(input, 'Invalid branch turn input'); const name = value.name === undefined @@ -154,8 +160,18 @@ export function normalizeBranchFromTurnInput(input: unknown): BranchFromTurnInpu if (value.sideConversation !== undefined && typeof value.sideConversation !== 'boolean') { throw new Error('Invalid branch sideConversation'); } + // Absent sourceTurnId forks with an empty context (a side conversation opened + // before the source has any settled turn). + const sourceTurnId = + value.sourceTurnId === undefined + ? undefined + : normalizeRequiredString( + value.sourceTurnId, + 'Invalid branch sourceTurnId', + MAX_TURN_ID_LENGTH, + ); return { - sourceTurnId: normalizeRequiredString(value.sourceTurnId, 'Invalid branch sourceTurnId', MAX_TURN_ID_LENGTH), + ...(sourceTurnId === undefined ? {} : { sourceTurnId }), ...(name ? { name } : {}), ...(value.sideConversation === true ? { sideConversation: true } : {}), }; diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index a2f0685983..582e6e9fae 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -23,7 +23,10 @@ import type { ActiveInteractionRequestEvent } from '@maka/core/events'; import { redactSecrets } from '@maka/core/redaction'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import { isSideConversationSession } from '@maka/core/side-conversation'; -import type { SessionChangedEvent, SessionChangedReason } from '@maka/core/session'; +import type { + SessionChangedEvent, + SessionChangedReason, +} from '@maka/core/session'; import type { BotRegistry } from '@maka/runtime/bots'; import { type RuntimeHostSshOperatorActivationInput, @@ -163,7 +166,7 @@ export interface DesktopRuntimeHostCandidateDeps { sessionId: string; kind: 'branch' | 'revision'; sourceSessionId: string; - sourceTurnId: string; + sourceTurnId?: string; intent?: 'side_conversation'; }) => Promise; }) => SessionCopyCleanupAuthority; @@ -780,7 +783,7 @@ export async function createDesktopRuntimeHostCandidate( await client.copySession(kind, { sourceSessionId, targetSessionId: sessionId, - sourceTurnId, + ...(sourceTurnId === undefined ? {} : { sourceTurnId }), ...(intent ? { intent } : {}), }); }, 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 6e21583524..acd6dab4aa 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 @@ -722,7 +722,9 @@ export function registerRuntimeHostSessionExecutionIpc( deps.client.copySession("branch", { sourceSessionId: sessionId, targetSessionId: normalized.copyId, - sourceTurnId: normalized.sourceTurnId, + ...(normalized.sourceTurnId === undefined + ? {} + : { sourceTurnId: normalized.sourceTurnId }), ...(normalized.sideConversation ? { intent: 'side_conversation' as const } : {}), }); let branch; @@ -733,7 +735,9 @@ export function registerRuntimeHostSessionExecutionIpc( sessionId: normalized.copyId, kind: 'branch', sourceSessionId: sessionId, - sourceTurnId: normalized.sourceTurnId, + ...(normalized.sourceTurnId === undefined + ? {} + : { sourceTurnId: normalized.sourceTurnId }), intent: 'side_conversation', ownerId: bindCopyOwner(event), }, diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 688248f0a2..77a7fbad4f 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -295,7 +295,7 @@ export function useWorkbarController( initialPrompt, newId: () => crypto.randomUUID(), }); - sideConversations.upsertPanel(panel, true); + sideConversations.upsertPanel(panel); layout.openDynamicWorkbarTab( { id: `side-chat:${panel.id}`, @@ -413,7 +413,7 @@ export function useWorkbarController( quote, newId: () => crypto.randomUUID(), }); - sideConversations.upsertPanel(panel, !activePanel); + sideConversations.upsertPanel(panel); const placement = activeSideChat?.placement ?? 'right'; layout.openDynamicWorkbarTab( { @@ -463,17 +463,10 @@ export function useWorkbarController( placement: SessionWorkbarPlacement, tabs: readonly SessionWorkbarTab[], ) => { - const closableTabs = tabs.filter( - (tab) => - tab.kind !== 'side-chat' || - !sideConversations.preparingPanelIds.has( - tab.id.slice('side-chat:'.length), - ), - ); - if (closableTabs.length === 0) return; + if (tabs.length === 0) return; const needsConfirmation = !skipSideChatCloseConfirmation && - closableTabs.some( + tabs.some( (tab) => tab.kind === 'side-chat' && sideConversations.contentPanelIds.has( @@ -482,16 +475,15 @@ export function useWorkbarController( ); if (needsConfirmation) { setPendingSideChatClose( - closableTabs.map((tab) => ({ placement, tab })), + tabs.map((tab) => ({ placement, tab })), ); return; } - closeTabsImmediately(placement, closableTabs); + closeTabsImmediately(placement, tabs); }, [ closeTabsImmediately, sideConversations.contentPanelIds, - sideConversations.preparingPanelIds, skipSideChatCloseConfirmation, ], ); @@ -733,9 +725,7 @@ export function useWorkbarController( ), onForkVisibilityChange, onContentStateChange: sideConversations.setContent, - preparingSideChatPanelIds: sideConversations.preparingPanelIds, activeSideChatPanelIds: sideConversations.activePanelIds, - onPreparingStateChange: sideConversations.setPreparing, onInitialPromptStarted: (panelId) => sideConversations.updatePanel(panelId, (panel) => consumeCompanionInitialPrompt(panel, panelId) ?? panel, diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 37c4c87fc7..8963776e0e 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -222,7 +222,7 @@ export interface SideChatSessionPort { branchFromTurn( sessionId: string, input: { - sourceTurnId: string; + sourceTurnId?: string; name?: string; copyId: string; sideConversation: true; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index a12512b0fc..69a288957a 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -28,7 +28,10 @@ import { import type { PermissionMode } from '@maka/core/permission'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { QuoteRef, SessionEvent } from '@maka/core/events'; -import type { SessionSummary, TurnRecord } from '@maka/core/session'; +import type { + SessionSummary, + TurnRecord, +} from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { SideChatSessionPort, @@ -108,11 +111,25 @@ export interface EnsureCompanionForkDeps { /** The latest successfully completed turn of the source session. * Failed and aborted turns are not reference context: branching through one * makes a fresh side prompt look like a continuation of unfinished parent - * work. If no completed turn exists, the side conversation starts empty. */ + * work. Callers decide whether a missing completed turn is temporary (the + * first turn is still running) or a hard setup failure. */ export function latestSettledTurnId(turns: readonly TurnRecord[]): string | undefined { return [...turns].reverse().find((turn) => turn.status === 'completed')?.turnId; } +/** + * Sentinel boundary stored in the retry lease for an empty copy. Turn ids match + * /^[A-Za-z0-9_-]{1,128}$/, so the NUL-prefixed token can never be one. This + * lets the retry lease keep a plain string field while still representing the + * absent (empty) source turn. + */ +const EMPTY_SOURCE_TURN_SENTINEL = '\0empty'; + +/** Decode a persisted retry-lease boundary into an optional source turn id. */ +function sourceTurnIdFromBoundary(boundary: string): string | undefined { + return boundary === EMPTY_SOURCE_TURN_SENTINEL ? undefined : boundary; +} + function companionCopyAttemptKey( sourceSessionId: string, panelId: string, @@ -234,8 +251,11 @@ export async function ensureCompanionFork( ): Promise { const { api, sourceSession, name, isDisposed } = deps; - // Branch at the latest SETTLED turn (durable), not the last message that - // happens to carry a turnId — so a fork never starts from a mid-flight turn. + // Prefer the latest SETTLED turn (durable) as the branch boundary — a fork + // never starts from a mid-flight turn. When the source has no completed turn + // yet (most visibly the main session's very first turn is still running), the + // side conversation forks with an EMPTY context instead of failing: it + // inherits the source model / cwd / permission but copies no messages. let turns: TurnRecord[]; try { turns = await api.listTurns(sourceSession.id); @@ -243,14 +263,19 @@ export async function ensureCompanionFork( return { status: 'error', code: 'fork_setup_failed' }; } if (isDisposed()) return { status: 'disposed' }; + // The boundary is derived once, persisted in the retry lease, and REPLAYED on + // every retry of the same copyId — never recomputed from the live turns. + // Otherwise an ambiguous first send of an empty copy could retry through a + // settled turn once the source's first turn settled, and the Host fingerprint + // (which includes the source turn) would reject the same copyId. const boundaryTurnId = latestSettledTurnId(turns); - if (!boundaryTurnId) return { status: 'error', code: 'fork_setup_failed' }; + const attemptBoundary = boundaryTurnId ?? EMPTY_SOURCE_TURN_SENTINEL; let created: SessionSummary; try { let copyAttempt = acquireSessionCopyAttempt( companionCopyAttemptKey(sourceSession.id, deps.panelId), - boundaryTurnId, + attemptBoundary, ); if (copyAttempt.phase === 'abandoning') { if (!(await abandonPendingCompanionCopy(api, sourceSession.id, deps.panelId))) { @@ -258,7 +283,7 @@ export async function ensureCompanionFork( } copyAttempt = acquireSessionCopyAttempt( companionCopyAttemptKey(sourceSession.id, deps.panelId), - boundaryTurnId, + attemptBoundary, ); } if ( @@ -270,7 +295,7 @@ export async function ensureCompanionFork( return { status: 'error', code: 'fork_setup_failed' }; } const result = await api.branchFromTurn(sourceSession.id, { - sourceTurnId: copyAttempt.sourceTurnId, + sourceTurnId: sourceTurnIdFromBoundary(copyAttempt.sourceTurnId), name, copyId: copyAttempt.copyId, sideConversation: true, 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 616fb2675c..5f50ee231b 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 @@ -19,7 +19,6 @@ import { useCallback, useEffect, useRef, type ComponentProps } from 'react'; import { Banner } from '@astryxdesign/core/Banner'; -import { Spinner } from '@astryxdesign/core/Spinner'; import { ChatView, ChatSurfaceLayout, @@ -73,7 +72,6 @@ export function QuoteCompanionPanel(props: { onRemoveQuote?: (target: CompanionQuoteTarget) => void; onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; onContentStateChange?: (panelId: string, hasContent: boolean) => void; - onPreparingStateChange?: (panelId: string, preparing: boolean) => void; onInitialPromptStarted?: (panelId: string) => void; onPromptAccepted?: (panelId: string, prompt: string) => void; onActivityStateChange?: (panelId: string, active: boolean) => void; @@ -110,9 +108,6 @@ export function QuoteCompanionPanel(props: { useEffect(() => { props.onContentStateChange?.(props.panelId, companion.hasContent); }, [companion.hasContent, props.onContentStateChange, props.panelId]); - useEffect(() => { - props.onPreparingStateChange?.(props.panelId, companion.preparing); - }, [companion.preparing, props.onPreparingStateChange, props.panelId]); useEffect(() => { props.onActivityStateChange?.( props.panelId, @@ -125,15 +120,14 @@ export function QuoteCompanionPanel(props: { props.panelId, ]); useEffect(() => { - if (!props.active || companion.preparing) return; + if (!props.active) return; const frame = window.requestAnimationFrame(() => composerRef.current?.focus()); return () => window.cancelAnimationFrame(frame); - }, [companion.preparing, props.active]); + }, [props.active]); useEffect(() => { const prompt = props.initialPrompt?.trim(); if ( !props.active || - companion.preparing || !companion.modelReady || !prompt || initialPromptStartedRef.current @@ -157,7 +151,6 @@ export function QuoteCompanionPanel(props: { composerRef.current?.focus(); }); }, [ - companion.preparing, companion.send, props.active, props.initialPrompt, @@ -209,10 +202,7 @@ export function QuoteCompanionPanel(props: { ); return ( -
+
- -
- ) : ( -