mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3286,8 +3286,7 @@ export function useIpcEvents(): void {
|
||||
observeAgentHookCompletionForNotification({
|
||||
paneKey,
|
||||
worktreeId: statusWorktreeId,
|
||||
payload: notificationPayload,
|
||||
...(data.hookEventName ? { hookEventName: data.hookEventName } : {})
|
||||
payload: notificationPayload
|
||||
})
|
||||
}
|
||||
return 'applied'
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user