fix(agent-hooks): mark SessionStart rows as session boundaries, not completions (STA-3386)

Review follow-up: represent the idle connect as a first-class
sessionBoundary flag on the status payload instead of gating one
renderer consumer on hookEventName.

- sessionBoundary rides AgentStatusPayload/AgentStatusEntry (done-only,
  clamped like interrupted); drops the hookEventName IPC threading.
- Completion-reactive consumers ignore session boundaries: the
  completion coordinator (task-complete notifications), automation
  dispatch observers (a connecting agent no longer completes the run
  and closes its tab), activity unread counts, and the dashboard
  finished timestamp; the status slice keeps boundaries out of
  stateHistory and preserves the flag across done->done repaints.
- SessionStart sources are allowlisted (startup/resume/clear) so
  compact restarts or unknown sources fail closed mid-turn.
- A live SessionStart now un-retires a reusable pane like a fresh
  prompt, so resume-in-reused-pane earns its row too.
This commit is contained in:
Brennan Benson
2026-08-05 20:43:57 -07:00
parent b393c544ff
commit 0af2d1d18a
16 changed files with 173 additions and 38 deletions
+30
View File
@@ -2257,6 +2257,36 @@ describe('AgentHookServer listener replay', () => {
}
})
it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' }))
})
await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' })
server.retirePaneAuthority(PANE)
// Why: `claude --resume` in the reused shell pane emits only SessionStart while idle;
// without the un-retire the resumed session stays rowless until a prompt (STA-3386).
await postHook({ hook_event_name: 'SessionStart', source: 'resume' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true })
])
} finally {
server.stop()
}
})
it('accepts a live remote prompt but not replay after launch authority retires', () => {
const server = new AgentHookServer()
server.ingestRemote(
+6 -2
View File
@@ -394,7 +394,6 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
connectionId: entry.connectionId,
receivedAt: entry.receivedAt,
stateStartedAt: entry.stateStartedAt,
...(entry.hookEventName ? { hookEventName: entry.hookEventName } : {}),
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
@@ -997,7 +996,12 @@ export class AgentHookServer {
return 'accept'
}
// Why: command completion retires launch authority but leaves its shell pane reusable.
if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) {
// A live SessionStart proves a new agent process owns the retired pane just like a
// fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386).
if (
(event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') &&
event.isReplay !== true
) {
this.closedAgentStatusPaneKeys.delete(paneKey)
this.closedAgentStatusPaneKeys.delete(ownerPaneKey)
return 'restart'
-2
View File
@@ -1426,7 +1426,6 @@ function openMainWindow(): BrowserWindow {
providerSessionOnly,
promptInteractionKey,
restoredUnconfirmed,
hookEventName,
isReplay
}) => {
if (mainWindow?.isDestroyed()) {
@@ -1461,7 +1460,6 @@ function openMainWindow(): BrowserWindow {
connectionId,
receivedAt,
stateStartedAt,
...(hookEventName ? { hookEventName } : {}),
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
@@ -60,7 +60,13 @@ export function countActivityUnread(
}
}
}
if (isUnreadAgentState(entry.state) && ackAt < entry.stateStartedAt) {
// Why: a session-boundary done is an idle connect (STA-3386), not an event to read;
// history never contains one (the status slice skips the push), so only the live entry needs the gate.
if (
isUnreadAgentState(entry.state) &&
entry.sessionBoundary !== true &&
ackAt < entry.stateStartedAt
) {
count += 1
}
}
@@ -15,7 +15,9 @@ export function lastEnteredDoneAt(
return null
}
const entry = agent.entry
if (entry.state === 'done') {
// Why: a session-boundary done means the session connected idle (STA-3386) — nothing
// finished; fall through to history (which never records session boundaries).
if (entry.state === 'done' && entry.sessionBoundary !== true) {
return entry.stateStartedAt
}
for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) {
@@ -810,6 +810,13 @@ export function createAgentCompletionCoordinator(
dispatchAttention(payload)
return
}
if (payload.state === 'done' && payload.sessionBoundary === true) {
// Why: a session-boundary 'done' is an idle session connecting (resume/launch/clear,
// STA-3386), not a completed turn — record the activity/evidence above but never
// complete. Pending timers stay untouched: a quiet-window done from a real prior
// turn must still fire.
return
}
if (isCompletionHookState(payload.state)) {
// Why: the turn is ending, so a debounced attention from an earlier pause must not fire after completion.
clearPendingCodexAttention()
@@ -421,20 +421,20 @@ describe('agent hook completion notifications', () => {
)
})
it('does not fire a completion notification for a SessionStart idle row', async () => {
it('does not fire a completion notification for a session-boundary done row', async () => {
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
// Why: Claude SessionStart lands as 'done' so a resumed session gets its sidebar
// row while idle (STA-3386) — connecting to a session is not completing a turn.
// Why: Claude SessionStart lands as a sessionBoundary 'done' so a resumed session gets
// its sidebar row while idle (STA-3386) — connecting to a session is not completing a turn.
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
hookEventName: 'SessionStart',
payload: {
state: 'done',
prompt: '',
agentType: 'claude',
sessionBoundary: true,
stateStartedAt: 1_700_000_000_000
}
})
@@ -272,19 +272,12 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion
export function observeAgentHookCompletionForNotification({
paneKey,
worktreeId,
payload,
hookEventName
payload
}: {
paneKey: string
worktreeId: string
payload: AgentCompletionStatusSnapshot
hookEventName?: string
}): void {
if (hookEventName === 'SessionStart' && payload.state === 'done') {
// Why: a SessionStart that lands 'done' is an idle session connecting (resume/launch,
// STA-3386) — not a completed turn, so it must not raise agent-task-complete.
return
}
pruneClosedPaneCoordinators()
if (!paneCanReceiveHookCompletion(paneKey)) {
return
@@ -539,6 +539,35 @@ describe('useAutomationDispatchEvents setup launch', () => {
})
})
it('ignores a session-boundary done so a connecting agent cannot complete the run (STA-3386)', async () => {
let launchArgs: {
onAgentStatus?: (payload: { state: string; sessionBoundary?: boolean }) => void
} = {}
mockLaunchAgentBackgroundSession.mockImplementation(async (args) => {
launchArgs = args
return {
tabId: 'agent-tab',
paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d',
ptyId: 'agent-pty',
startupPlan: {},
terminalOwnership: {
finalize: mockFinalizeTerminalOwnership,
release: mockReleaseTerminalOwnership
}
}
})
await registerAndDispatch()
// Why: Claude fires SessionStart (a sessionBoundary done) at launch, before the argv
// prompt submits — treating it as run completion would close the tab on an empty run.
launchArgs.onAgentStatus?.({ state: 'done', sessionBoundary: true })
await Promise.resolve()
expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled()
launchArgs.onAgentStatus?.({ state: 'done' })
await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce())
})
it('consumes duplicate done and zero-exit completion through one finalizer', async () => {
let launchArgs: {
onAgentStatus?: (payload: { state: string }) => void
@@ -416,6 +416,10 @@ export function useAutomationDispatchEvents(): void {
}
if (
entry.state === 'done' &&
// Why: a session-boundary done is the agent CONNECTING (Claude SessionStart
// fires at launch, before the argv prompt submits) — completing here would
// close the tab and record an empty run result.
entry.sessionBoundary !== true &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
latestAssistantMessage =
@@ -528,7 +532,8 @@ export function useAutomationDispatchEvents(): void {
onAgentStatus: (payload) => {
latestAssistantMessage =
payload.lastAssistantMessage?.trim() || latestAssistantMessage
if (payload.state !== 'done') {
// Why: session-boundary done = launch connect, not run completion (see observeAgentStatus).
if (payload.state !== 'done' || payload.sessionBoundary === true) {
return
}
handleAgentDone()
+1 -2
View File
@@ -3286,8 +3286,7 @@ export function useIpcEvents(): void {
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: statusWorktreeId,
payload: notificationPayload,
...(data.hookEventName ? { hookEventName: data.hookEventName } : {})
payload: notificationPayload
})
}
return 'applied'
+16 -2
View File
@@ -1739,8 +1739,14 @@ 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.
let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? []
if (existing && existing.state !== payload.state) {
if (
existing &&
existing.state !== payload.state &&
!(existing.state === 'done' && existing.sessionBoundary === true)
) {
history = [
...history,
{
@@ -1930,7 +1936,15 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
// Why: `interrupted` is done-only; parseAgentStatusPayload already clamps it for non-done states, so write it through directly.
interrupted: payload.interrupted
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.
sessionBoundary:
payload.sessionBoundary ??
(existing?.state === 'done' && payload.state === 'done'
? existing.sessionBoundary
: undefined)
}
generatedTitleEntry.current = entry
if (
+16 -2
View File
@@ -1384,9 +1384,14 @@ describe('shared agent-hook-listener', () => {
'production'
)
// Why: 'working' would show a phantom spinner on an idle TUI (Devin precedent);
// Why: 'working' would show a phantom spinner on an idle TUI; a session-boundary
// 'done' renders the row idle, which is the truth at SessionStart.
expect(event?.payload).toMatchObject({ state: 'done', prompt: '', agentType: 'claude' })
expect(event?.payload).toMatchObject({
state: 'done',
prompt: '',
agentType: 'claude',
sessionBoundary: true
})
expect(event?.payload.interrupted).toBeUndefined()
expect(event?.hookEventName).toBe('SessionStart')
// Why: SessionStart carries resume identity, so in-app resume works before any prompt.
@@ -1427,6 +1432,13 @@ describe('shared agent-hook-listener', () => {
{ paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart', source: 'compact' } },
'production'
)
// Why: unknown/missing sources fail closed — only startup/resume/clear are idle boundaries.
const unknownSource = normalizeHookPayload(
state,
'claude',
{ paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart' } },
'production'
)
const child = normalizeHookPayload(
state,
'claude',
@@ -1441,8 +1453,10 @@ describe('shared agent-hook-listener', () => {
// Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle) and a
// child-attributed SessionStart must not flip the lead's live turn to an idle row.
expect(compacted).toBeNull()
expect(unknownSource).toBeNull()
expect(child).toBeNull()
expect(stopped?.payload).toMatchObject({ state: 'done', prompt: 'say hi' })
expect(stopped?.payload.sessionBoundary).toBeUndefined()
})
it('normalizes Devin documented lifecycle events', () => {
+24 -9
View File
@@ -2742,23 +2742,32 @@ function normalizeClaudeEvent(
return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload)
}
if (eventName === 'SessionStart') {
// Why: SessionStart fires while the TUI is idle (startup/resume/clear), so land the row
// 'done' — 'working' would show a phantom spinner before any prompt (Devin precedent).
// This is the only signal a resumed session emits before its first prompt (STA-3386).
if (eventAgentId !== undefined || hookPayload['source'] === 'compact') {
// Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle), and a
// child-attributed SessionStart must not flip the lead's live turn to an idle row.
// Why: SessionStart is the only signal a resumed session emits before its first prompt
// (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom
// spinner on an idle TUI (why Devin/Pi/Grok drop the event), and the sessionBoundary
// flag keeps completion-reactive consumers (notifications, automation runs) out of it.
const sessionStartSource = hookPayload['source']
if (
eventAgentId !== undefined ||
(sessionStartSource !== 'startup' &&
sessionStartSource !== 'resume' &&
sessionStartSource !== 'clear')
) {
// Why: allowlist idle boundaries and fail closed — a compact restart (or any unknown
// source) fires mid-turn, and a child-attributed SessionStart must not flip the lead's
// live turn to an idle row.
return null
}
// Why: a new process owns the pane; stale children/tasks/crons must not gate the
// fresh session's idle row back up to 'working' (mirrors the Codex SessionStart reset).
// fresh session's idle row back up to 'working' (same reset Codex does on SessionStart).
state.claudeSubagentRosterByPaneKey.delete(paneKey)
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' })
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
stateName: 'done',
updateToolSnapshot: true
updateToolSnapshot: true,
sessionBoundary: true
})
}
const previousLead = state.claudeLeadStateByPaneKey.get(paneKey)
@@ -2912,7 +2921,12 @@ function buildClaudeStatusPayload(
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>,
options: { stateName: AgentStatusState; updateToolSnapshot: boolean; interrupted?: boolean }
options: {
stateName: AgentStatusState
updateToolSnapshot: boolean
interrupted?: boolean
sessionBoundary?: boolean
}
): ParsedAgentStatusPayload | null {
// Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview.
const snapshot = options.updateToolSnapshot
@@ -2935,6 +2949,7 @@ function buildClaudeStatusPayload(
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: snapshot.lastAssistantMessage,
interrupted: options.interrupted,
sessionBoundary: options.sessionBoundary,
subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey))
})
}
+14
View File
@@ -421,6 +421,20 @@ Fix dispatch fallback preview for normalized status prompts`
}
})
it('preserves sessionBoundary=true only on done (stale-signal suppression like interrupted)', () => {
expect(
parseAgentStatusPayload('{"state":"done","sessionBoundary":true}')!.sessionBoundary
).toBe(true)
for (const state of ['working', 'blocked', 'waiting'] as const) {
const result = parseAgentStatusPayload(`{"state":"${state}","sessionBoundary":true}`)
expect(result!.sessionBoundary).toBeUndefined()
}
// Why: parser uses `=== true`, so truthy sentinels don't count.
expect(
parseAgentStatusPayload('{"state":"done","sessionBoundary":"true"}')!.sessionBoundary
).toBeUndefined()
})
it('requires strict boolean true for interrupted (rejects truthy non-boolean)', () => {
// Why: parser uses `=== true`, so truthy string/number sentinels don't count.
expect(
+9 -4
View File
@@ -126,6 +126,8 @@ export type AgentStatusEntry = {
/** True when this `done` was reached via interrupt, not normal completion
* (agent-reported or Orca's guarded fallback). Undefined otherwise. */
interrupted?: boolean
/** True when this `done` is a session boundary, not a completed turn. See AgentStatusPayload. */
sessionBoundary?: boolean
/** Orchestration dispatch context for panes spawned by another agent.
* Why: parent/child hierarchy is pane-level state, not worktree lineage — workers often share the coordinator's worktree. */
orchestration?: AgentStatusOrchestrationContext
@@ -170,6 +172,11 @@ export type AgentStatusPayload = {
interactivePrompt?: string
lastAssistantMessage?: string
interrupted?: boolean
/** True when this `done` marks a session boundary (connect/resume/clear landing idle,
* e.g. Claude SessionStart — STA-3386), not a completed turn. Consumers that react to
* completions (notifications, automation runs, unread badges, finished timestamps)
* must ignore it. Only meaningful on `done`. */
sessionBoundary?: boolean
/** Live in-process children of the reporting session. See AgentStatusEntry. */
subagents?: AgentSubagentSnapshot[]
}
@@ -202,6 +209,7 @@ export function pickParsedAgentStatusPayload(
? { lastAssistantMessage: row.lastAssistantMessage }
: {}),
...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}),
...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}),
...(row.subagents !== undefined ? { subagents: row.subagents } : {})
}
}
@@ -219,10 +227,6 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
/** Identifies the SSH connection the event arrived on, or null for local.
* Only the remote-ingest path (`ingestRemote`) can stamp it; the HTTP path always sets null. See docs/design/agent-status-over-ssh.md §5. */
connectionId: string | null
/** Hook event that produced this status, when it came from a native hook.
* Why: lets receivers tell a session boundary from a turn result — a SessionStart
* 'done' is an idle connect (STA-3386), not a completion to notify about. */
hookEventName?: string
/** Timestamp (ms) when the hook server received this latest status event. */
receivedAt: number
/** Timestamp (ms) when the current state first appeared for this pane. */
@@ -405,6 +409,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload |
),
// Why: only meaningful on `done`; coerce to undefined elsewhere so it can't leak stale truth across transitions.
interrupted: obj.interrupted === true && state === 'done' ? true : undefined,
sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined,
subagents: normalizeSubagentsField(obj.subagents)
}
}