mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user