diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 387101dc9c..6343003fb6 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -352,7 +352,7 @@ "@maka/ui": 1 }, "importSpecifiers": 39, - "nonTriviaTokens": 4278 + "nonTriviaTokens": 4089 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 495643628f..552a34bc37 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -423,6 +423,81 @@ describe('composer first-send cleanup', () => { assert.deepEqual(removed, []); }); + it('does not report a resolved Session from an existing-Session send', async () => { + // `onSessionResolved` is the contract for a Session this send created and + // whose first message projected (the new-Session branch). An existing- + // Session send must never fire it, or a consumer binding follow-up state + // to a newly resolved Session (e.g. a Work Board start claim) would bind + // it to an unrelated pre-existing conversation. + let resolved = 0; + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ + ok: true, + attachments: [], + skillInvocation: { loaded: [], failed: [] }, + }), + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'existing-session' }, + }); + const result = await actions.send('hello', undefined, { + onSessionResolved: () => { + resolved += 1; + }, + }); + assert.equal(result, true); + } finally { + restoreWindow(); + } + + assert.equal(resolved, 0); + }); + + it('does not report a resolved Session when the first send is outcome_unknown', async () => { + // `onSessionResolved` is the contract for a Session this send created AND + // whose first message projected. `outcome_unknown` maps to `unreconciled`: + // `send()` intentionally returns `true` (the Message may well have been + // admitted), but the callback must not fire — a consumer binding follow-up + // state to a newly resolved Session (e.g. a Work Board start claim) would + // otherwise bind to a Session whose first message was never confirmed. + let resolved = 0; + const removed: string[] = []; + const restoreWindow = installWindow({ + newTasks: { create: async () => ({ id: 'session-1' }) }, + sessions: { + submitMessage: async () => ({ + ok: false, + reason: 'outcome_unknown' as const, + }), + remove: async (sessionId: string) => { + removed.push(sessionId); + }, + }, + }); + + try { + const actions = createAppShellChatActions(createActionsDeps()); + const result = await actions.send('hello', undefined, { + onSessionResolved: () => { + resolved += 1; + }, + }); + // The row stays for canonical transcript to settle, so the session is + // kept and the send reports success — only the callback is silenced. + assert.equal(result, true); + assert.deepEqual(removed, []); + } finally { + restoreWindow(); + } + + assert.equal(resolved, 0); + }); + it('returns a sparse existing session to latest before sending', async () => { const latest = deferred(); const order: string[] = []; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 275740b9c4..7a877ebf25 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -454,6 +454,41 @@ export function createAppShellChatActions(deps: { }; try { const messageId = crypto.randomUUID(); + async function submitIntoSession(sessionId: string, messageId: string) { + if (exactTurn) armTurnActive(sessionId, messageId); + const attachmentItems = + pending && pending.length > 0 + ? toComposerIngestItems(pending) + : undefined; + const retainedAttachments = + pending && pending.length > 0 + ? retainedAttachmentRefs(pending) + : undefined; + const sendCommand = { + text, + ...(options.displayText ? { displayText: options.displayText } : {}), + ...copiedArray('attachmentItems', attachmentItems), + ...(retainedAttachments && retainedAttachments.length > 0 + ? { retainedAttachments } + : {}), + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), + }; + return submitAndProject({ + sessionId, + messageId, + placement: 'current_turn', + command: { + ...sendCommand, + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), + }, + ...(options.displayText ? { displayText: options.displayText } : {}), + ...copiedArray('quotes', quotes), + exactTurn, + isSurfaceVisible: () => activeIdRef.current === sessionId, + }); + } if (!initialSessionId) { if (!initialNewTaskTarget) return false; if (pending && pending.length > 0) preflightAttachmentItems(pending, uiLocale); @@ -502,47 +537,15 @@ export function createAppShellChatActions(deps: { await discardUnsentSession(); return false; } - if (exactTurn) armTurnActive(session.id, messageId); - const attachmentItems = - pending && pending.length > 0 - ? toComposerIngestItems(pending) - : undefined; - const retainedAttachments = - pending && pending.length > 0 - ? retainedAttachmentRefs(pending) - : undefined; - const sendCommand = { - text, - ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray('attachmentItems', attachmentItems), - ...(retainedAttachments && retainedAttachments.length > 0 - ? { retainedAttachments } - : {}), - ...copiedArray('directoryReferences', directoryReferences), - ...copiedArray('quotes', quotes), - ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), - }; - const submitted = await submitAndProject({ - sessionId: session.id, - messageId, - placement: 'current_turn', - command: { - ...sendCommand, - ...(options.turnOrchestration - ? { turnOrchestration: options.turnOrchestration } - : {}), - }, - ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray('quotes', quotes), - exactTurn, - isSurfaceVisible: () => activeIdRef.current === session.id, - }); + const submitted = await submitIntoSession(session.id, messageId); if (submitted.kind === 'refused') { await discardUnsentSession(); return false; } unsentSessionId = undefined; - options.onSessionResolved?.(session.id); + // The callback fires only when this send's first message projected; + // an unreconciled first message stays unreported. + if (submitted.kind === 'projected') options.onSessionResolved?.(session.id); await refreshSessions(); return true; } @@ -577,42 +580,9 @@ export function createAppShellChatActions(deps: { inlineReferences: [], }, ); - if (exactTurn) armTurnActive(sessionId, messageId); - const attachmentItems = - pending && pending.length > 0 - ? toComposerIngestItems(pending) - : undefined; - const retainedAttachments = - pending && pending.length > 0 - ? retainedAttachmentRefs(pending) - : undefined; - const sendCommand = { - text, - ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray('attachmentItems', attachmentItems), - ...(retainedAttachments && retainedAttachments.length > 0 - ? { retainedAttachments } - : {}), - ...copiedArray('directoryReferences', directoryReferences), - ...copiedArray('quotes', quotes), - ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), - }; - const submitted = await submitAndProject({ - sessionId, - messageId, - placement: 'current_turn', - command: { - ...sendCommand, - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), - }, - ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray('quotes', quotes), - exactTurn, - isSurfaceVisible: () => activeIdRef.current === sessionId, - }); + const submitted = await submitIntoSession(sessionId, messageId); if (submitted.kind === 'refused') return false; - if (submitted.kind === 'unreconciled') return true; - options.onSessionResolved?.(sessionId); + // An existing-Session send never reports a resolved Session. return true; } catch (error) { // Capture ownership before cleanup clears the optimistic Session. A