Skip to content
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@
"@maka/ui": 1
},
"importSpecifiers": 39,
"nonTriviaTokens": 4278
"nonTriviaTokens": 4089
},
"src/renderer/app-shell-chrome-actions.tsx": {
"importDeclarations": 5,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
const order: string[] = [];
Expand Down
112 changes: 41 additions & 71 deletions apps/desktop/src/renderer/app-shell-chat-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down