fix(agent-hooks): keep session-boundary dones out of teardown and completion history (STA-3386)

Review round 2:
- A boundary done no longer deletes the pane's launch-config registry
  entry, so a resumed idle TUI keeps its registered-launch-agent
  identity evidence.
- A boundary landing on a REAL done pushes that completion into
  stateHistory so the finished timestamp and unread badge survive a
  resume//clear right after a finish.
- The done->done flag carry yields to turn evidence (assistant message
  or changed prompt) so a genuine completion can never be suppressed.
- Star-nag value-moment observer and the server's OSC-equivalence
  dedupe now discriminate the flag.
This commit is contained in:
Brennan Benson
2026-08-05 21:01:20 -07:00
parent 0af2d1d18a
commit b49a554fd7
4 changed files with 127 additions and 6 deletions
+4 -1
View File
@@ -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
)
}
@@ -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)
) {
@@ -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<AppState>)
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()
})
})
+20 -5
View File
@@ -1740,11 +1740,19 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// Rolling log of state transitions for the dashboard's activity blocks; push only on
// real state changes to avoid dupes from prompt-only pings within the same state.
// A session-boundary 'done' (idle connect, STA-3386) is not a turn event — keep it
// out of history so activity feeds and unread counts never surface it.
// out of history so activity feeds and unread counts never surface it. The inverse
// also holds: a boundary landing on a REAL done (resume//clear right after a finish)
// must push that completion into history, or the finished timestamp and unread badge
// lose the turn the moment the flag overwrites the live entry.
let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? []
const boundaryLandsOnRealDone =
existing?.state === 'done' &&
existing.sessionBoundary !== true &&
payload.state === 'done' &&
payload.sessionBoundary === true
if (
existing &&
existing.state !== payload.state &&
(existing.state !== payload.state || boundaryLandsOnRealDone) &&
!(existing.state === 'done' && existing.sessionBoundary === true)
) {
history = [
@@ -1939,10 +1947,15 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
interrupted: payload.interrupted,
// Why: done→done repaints (OSC 9999, reconnect snapshot replays) re-deliver a
// metadata-less `done`; preserving the flag there keeps completion-reactive
// consumers from treating the still-idle session as newly finished.
// consumers from treating the still-idle session as newly finished. Turn evidence
// (an assistant message or a changed prompt) proves a REAL completion — never
// carry the flag over one, or a genuine finish could be silently suppressed.
sessionBoundary:
payload.sessionBoundary ??
(existing?.state === 'done' && payload.state === 'done'
(existing?.state === 'done' &&
payload.state === 'done' &&
payload.lastAssistantMessage === undefined &&
payload.prompt === existing.prompt
? existing.sessionBoundary
: undefined)
}
@@ -2054,8 +2067,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
}
}
// Why: launch tokens can outlive an Orca-started TUI in the shell; once the session is done they must no longer authorize config reuse.
// A session-boundary done is the session CONNECTING (STA-3386) — deleting here would strip
// the pane's registered-launch-agent identity evidence the moment a resumed TUI sits idle.
if (
(providerSessionChanged || entry.state === 'done') &&
(providerSessionChanged || (entry.state === 'done' && entry.sessionBoundary !== true)) &&
paneKey in s.agentLaunchConfigByPaneKey
) {
nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey }