Skip to content

Commit be80c15

Browse files
authored
fix(desktop): report session resolution only for projected first sends (#4630)
Ensure onSessionResolved fires only when a send creates a new Session and its first message is confirmed as projected. Existing-session sends and outcome_unknown results no longer report session resolution, preventing follow-up state from binding to an unrelated or unconfirmed Session. Consolidate the duplicated send command and projection path into submitIntoSession, remove the redundant unreconciled branch, add regression coverage for both invalid callback paths, and update the renderer architecture ledger. Generated-by: pi
1 parent cd4aa3d commit be80c15

3 files changed

Lines changed: 117 additions & 72 deletions

File tree

apps/desktop/renderer-architecture.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@
351351
"@maka/ui": 1
352352
},
353353
"importSpecifiers": 39,
354-
"nonTriviaTokens": 4278
354+
"nonTriviaTokens": 4089
355355
},
356356
"src/renderer/app-shell-chrome-actions.tsx": {
357357
"importDeclarations": 5,

apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,81 @@ describe('composer first-send cleanup', () => {
423423
assert.deepEqual(removed, []);
424424
});
425425

426+
it('does not report a resolved Session from an existing-Session send', async () => {
427+
// `onSessionResolved` is the contract for a Session this send created and
428+
// whose first message projected (the new-Session branch). An existing-
429+
// Session send must never fire it, or a consumer binding follow-up state
430+
// to a newly resolved Session (e.g. a Work Board start claim) would bind
431+
// it to an unrelated pre-existing conversation.
432+
let resolved = 0;
433+
const restoreWindow = installWindow({
434+
sessions: {
435+
submitMessage: async () => ({
436+
ok: true,
437+
attachments: [],
438+
skillInvocation: { loaded: [], failed: [] },
439+
}),
440+
},
441+
});
442+
443+
try {
444+
const actions = createAppShellChatActions({
445+
...createActionsDeps(),
446+
activeIdRef: { current: 'existing-session' },
447+
});
448+
const result = await actions.send('hello', undefined, {
449+
onSessionResolved: () => {
450+
resolved += 1;
451+
},
452+
});
453+
assert.equal(result, true);
454+
} finally {
455+
restoreWindow();
456+
}
457+
458+
assert.equal(resolved, 0);
459+
});
460+
461+
it('does not report a resolved Session when the first send is outcome_unknown', async () => {
462+
// `onSessionResolved` is the contract for a Session this send created AND
463+
// whose first message projected. `outcome_unknown` maps to `unreconciled`:
464+
// `send()` intentionally returns `true` (the Message may well have been
465+
// admitted), but the callback must not fire — a consumer binding follow-up
466+
// state to a newly resolved Session (e.g. a Work Board start claim) would
467+
// otherwise bind to a Session whose first message was never confirmed.
468+
let resolved = 0;
469+
const removed: string[] = [];
470+
const restoreWindow = installWindow({
471+
newTasks: { create: async () => ({ id: 'session-1' }) },
472+
sessions: {
473+
submitMessage: async () => ({
474+
ok: false,
475+
reason: 'outcome_unknown' as const,
476+
}),
477+
remove: async (sessionId: string) => {
478+
removed.push(sessionId);
479+
},
480+
},
481+
});
482+
483+
try {
484+
const actions = createAppShellChatActions(createActionsDeps());
485+
const result = await actions.send('hello', undefined, {
486+
onSessionResolved: () => {
487+
resolved += 1;
488+
},
489+
});
490+
// The row stays for canonical transcript to settle, so the session is
491+
// kept and the send reports success — only the callback is silenced.
492+
assert.equal(result, true);
493+
assert.deepEqual(removed, []);
494+
} finally {
495+
restoreWindow();
496+
}
497+
498+
assert.equal(resolved, 0);
499+
});
500+
426501
it('returns a sparse existing session to latest before sending', async () => {
427502
const latest = deferred<void>();
428503
const order: string[] = [];

apps/desktop/src/renderer/app-shell-chat-actions.ts

Lines changed: 41 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,41 @@ export function createAppShellChatActions(deps: {
454454
};
455455
try {
456456
const messageId = crypto.randomUUID();
457+
async function submitIntoSession(sessionId: string, messageId: string) {
458+
if (exactTurn) armTurnActive(sessionId, messageId);
459+
const attachmentItems =
460+
pending && pending.length > 0
461+
? toComposerIngestItems(pending)
462+
: undefined;
463+
const retainedAttachments =
464+
pending && pending.length > 0
465+
? retainedAttachmentRefs(pending)
466+
: undefined;
467+
const sendCommand = {
468+
text,
469+
...(options.displayText ? { displayText: options.displayText } : {}),
470+
...copiedArray('attachmentItems', attachmentItems),
471+
...(retainedAttachments && retainedAttachments.length > 0
472+
? { retainedAttachments }
473+
: {}),
474+
...copiedArray('directoryReferences', directoryReferences),
475+
...copiedArray('quotes', quotes),
476+
...copiedArray('workspaceFileReferences', options.workspaceFileReferences),
477+
};
478+
return submitAndProject({
479+
sessionId,
480+
messageId,
481+
placement: 'current_turn',
482+
command: {
483+
...sendCommand,
484+
...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}),
485+
},
486+
...(options.displayText ? { displayText: options.displayText } : {}),
487+
...copiedArray('quotes', quotes),
488+
exactTurn,
489+
isSurfaceVisible: () => activeIdRef.current === sessionId,
490+
});
491+
}
457492
if (!initialSessionId) {
458493
if (!initialNewTaskTarget) return false;
459494
if (pending && pending.length > 0) preflightAttachmentItems(pending, uiLocale);
@@ -502,47 +537,15 @@ export function createAppShellChatActions(deps: {
502537
await discardUnsentSession();
503538
return false;
504539
}
505-
if (exactTurn) armTurnActive(session.id, messageId);
506-
const attachmentItems =
507-
pending && pending.length > 0
508-
? toComposerIngestItems(pending)
509-
: undefined;
510-
const retainedAttachments =
511-
pending && pending.length > 0
512-
? retainedAttachmentRefs(pending)
513-
: undefined;
514-
const sendCommand = {
515-
text,
516-
...(options.displayText ? { displayText: options.displayText } : {}),
517-
...copiedArray('attachmentItems', attachmentItems),
518-
...(retainedAttachments && retainedAttachments.length > 0
519-
? { retainedAttachments }
520-
: {}),
521-
...copiedArray('directoryReferences', directoryReferences),
522-
...copiedArray('quotes', quotes),
523-
...copiedArray('workspaceFileReferences', options.workspaceFileReferences),
524-
};
525-
const submitted = await submitAndProject({
526-
sessionId: session.id,
527-
messageId,
528-
placement: 'current_turn',
529-
command: {
530-
...sendCommand,
531-
...(options.turnOrchestration
532-
? { turnOrchestration: options.turnOrchestration }
533-
: {}),
534-
},
535-
...(options.displayText ? { displayText: options.displayText } : {}),
536-
...copiedArray('quotes', quotes),
537-
exactTurn,
538-
isSurfaceVisible: () => activeIdRef.current === session.id,
539-
});
540+
const submitted = await submitIntoSession(session.id, messageId);
540541
if (submitted.kind === 'refused') {
541542
await discardUnsentSession();
542543
return false;
543544
}
544545
unsentSessionId = undefined;
545-
options.onSessionResolved?.(session.id);
546+
// The callback fires only when this send's first message projected;
547+
// an unreconciled first message stays unreported.
548+
if (submitted.kind === 'projected') options.onSessionResolved?.(session.id);
546549
await refreshSessions();
547550
return true;
548551
}
@@ -577,42 +580,9 @@ export function createAppShellChatActions(deps: {
577580
inlineReferences: [],
578581
},
579582
);
580-
if (exactTurn) armTurnActive(sessionId, messageId);
581-
const attachmentItems =
582-
pending && pending.length > 0
583-
? toComposerIngestItems(pending)
584-
: undefined;
585-
const retainedAttachments =
586-
pending && pending.length > 0
587-
? retainedAttachmentRefs(pending)
588-
: undefined;
589-
const sendCommand = {
590-
text,
591-
...(options.displayText ? { displayText: options.displayText } : {}),
592-
...copiedArray('attachmentItems', attachmentItems),
593-
...(retainedAttachments && retainedAttachments.length > 0
594-
? { retainedAttachments }
595-
: {}),
596-
...copiedArray('directoryReferences', directoryReferences),
597-
...copiedArray('quotes', quotes),
598-
...copiedArray('workspaceFileReferences', options.workspaceFileReferences),
599-
};
600-
const submitted = await submitAndProject({
601-
sessionId,
602-
messageId,
603-
placement: 'current_turn',
604-
command: {
605-
...sendCommand,
606-
...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}),
607-
},
608-
...(options.displayText ? { displayText: options.displayText } : {}),
609-
...copiedArray('quotes', quotes),
610-
exactTurn,
611-
isSurfaceVisible: () => activeIdRef.current === sessionId,
612-
});
583+
const submitted = await submitIntoSession(sessionId, messageId);
613584
if (submitted.kind === 'refused') return false;
614-
if (submitted.kind === 'unreconciled') return true;
615-
options.onSessionResolved?.(sessionId);
585+
// An existing-Session send never reports a resolved Session.
616586
return true;
617587
} catch (error) {
618588
// Capture ownership before cleanup clears the optimistic Session. A

0 commit comments

Comments
 (0)