diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f8c1d754fbf..013b90d7143 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -415,7 +415,10 @@ function equivalentParsedAgentStatusPayload( a.toolInput === b.toolInput && a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && - a.interrupted === b.interrupted + a.interrupted === b.interrupted && + // Why: a session-boundary done must never be deduped against a cached real done — + // the flag has to reach receivers deterministically (STA-3386). + a.sessionBoundary === b.sessionBoundary ) } diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx index 1fd3099323d..42f6145b1f3 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx @@ -33,6 +33,9 @@ function hasSuccessfulDoneTransition( previousEntry && previousEntry.state !== 'done' && entry.state === 'done' && + // Why: a session-boundary done is an idle connect (STA-3386), not a value moment — + // a stale working row + resume would otherwise nag on launch. + entry.sessionBoundary !== true && !entry.interrupted && hasMeaningfulPrompt(entry) ) { diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index b6b2976e26b..6965ca8bbb2 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -1399,3 +1399,103 @@ describe('agent status retention + prefix sweep', () => { expect(retained['tab-b:0']).toBeUndefined() }) }) + +describe('session-boundary done semantics (STA-3386)', () => { + const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + + it('keeps the launch config registry entry alive across a session-boundary done', () => { + const store = createTestStore() + store.setState({ + agentLaunchConfigByPaneKey: { + [PANE]: { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'claude' }, + launchToken: 'token-1' + } + } + } as unknown as Partial) + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + // Why: the session just CONNECTED — the registered-launch-agent identity must survive + // or an idle resumed TUI loses its pane identity evidence at startup. + expect(store.getState().agentLaunchConfigByPaneKey[PANE]).toBeDefined() + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: 'fix bug', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + expect(store.getState().agentLaunchConfigByPaneKey[PANE]).toBeUndefined() + }) + + it('pushes a real completion into history when a session boundary lands on it', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'fix bug', agentType: 'claude' }) + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: 'fix bug', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + + const entry = store.getState().agentStatusByPaneKey[PANE] + expect(entry.sessionBoundary).toBe(true) + // Why: the finished timestamp and unread badge fall through to history for boundary + // entries — losing the real done here erases an unacknowledged completion. + expect(entry.stateHistory.some((h) => h.state === 'done')).toBe(true) + }) + + it('never records a session boundary itself in state history', () => { + const store = createTestStore() + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'fix bug', agentType: 'claude' }) + + const entry = store.getState().agentStatusByPaneKey[PANE] + expect(entry.stateHistory.some((h) => h.state === 'done')).toBe(false) + }) + + it('carries the flag across metadata-less done repaints but yields to turn evidence', () => { + const store = createTestStore() + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + + // Why: OSC 9999 repaints and reconnect replays re-deliver a metadata-less done. + store.getState().setAgentStatus(PANE, { state: 'done', prompt: '', agentType: 'claude' }) + expect(store.getState().agentStatusByPaneKey[PANE].sessionBoundary).toBe(true) + + // Why: an assistant message proves a REAL completion — the flag must not suppress it. + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + expect(store.getState().agentStatusByPaneKey[PANE].sessionBoundary).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 972d569b230..95c8726f52a 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1740,11 +1740,19 @@ export const createAgentStatusSlice: StateCreator