fix(agent-hooks): do not infer interrupt over a live working hook row

Escape that a TUI consumes as navigation left the turn running, but Orca
rewrote the still-working hook row to done+interrupted after 500ms of
silence. Keep working as hook authority, record the keystroke as a hint
that can stamp a later lead done, and let Claude Stop/is_interrupt paint
the red Interrupted state.

Fixes #13547
STA-3817

Co-authored-by: bbingz <zzb@gxsmjx.com>
This commit is contained in:
Brennan Benson
2026-08-20 16:59:07 -07:00
co-authored by bbingz
parent 012e9f410c
commit 5a8cc387a5
10 changed files with 756 additions and 71 deletions
@@ -0,0 +1,297 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { PANE } from './server.test-fixtures'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
beforeEach(() => {
_internals.resetCachesForTests()
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(() => {
vi.restoreAllMocks()
})
function ingestWorking(
server: AgentHookServer,
payload: {
prompt: string
agentType: 'claude' | 'codex' | 'opencode'
hookEventName?: string
}
): ReturnType<AgentHookServer['getStatusSnapshot']>[0] {
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: payload.hookEventName ?? 'UserPromptSubmit',
payload: {
state: 'working',
prompt: payload.prompt,
agentType: payload.agentType
}
},
'conn-1'
)
return server.getStatusSnapshot()[0]!
}
function inferEscape(
server: AgentHookServer,
baseline: {
receivedAt: number
stateStartedAt: number
prompt: string
agentType: string | undefined
}
) {
return server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: baseline.prompt,
baselineAgentType: baseline.agentType,
intent: 'plain-escape'
})
}
describe('interrupt inference yields to a live working hook row', () => {
it('does not retire a working Claude turn when Escape is consumed as TUI navigation', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const baseline = ingestWorking(server, { prompt: 'long tool call', agentType: 'claude' })
vi.setSystemTime(1_500)
expect(inferEscape(server, baseline)).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'long tool call',
agentType: 'claude',
interrupted: undefined
})
vi.setSystemTime(12_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'Stop',
payload: { state: 'done', prompt: 'long tool call', agentType: 'claude' }
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
prompt: 'long tool call',
agentType: 'claude',
interrupted: undefined
})
} finally {
vi.useRealTimers()
}
})
it('keeps a working row live when a later same-turn tool hook arrives after Escape', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const baseline = ingestWorking(server, { prompt: 'still running', agentType: 'claude' })
vi.setSystemTime(1_500)
expect(inferEscape(server, baseline)).toBe(false)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'PostToolUse',
payload: {
state: 'working',
prompt: 'still running',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'sleep 90'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'still running',
agentType: 'claude',
toolName: 'Bash',
interrupted: undefined
})
} finally {
vi.useRealTimers()
}
})
it('reports a real Claude interrupt from the Stop hook, not from Escape inference', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const baseline = ingestWorking(server, { prompt: 'cancel me', agentType: 'claude' })
vi.setSystemTime(1_200)
expect(inferEscape(server, baseline)).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
vi.setSystemTime(1_300)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'Stop',
payload: {
state: 'done',
prompt: 'cancel me',
agentType: 'claude',
interrupted: true
}
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
prompt: 'cancel me',
agentType: 'claude',
interrupted: true
})
} finally {
vi.useRealTimers()
}
})
it('does not latch working after a hook-confirmed interrupt, and late tool progress cannot resurrect it', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const baseline = ingestWorking(server, { prompt: 'stop the turn', agentType: 'claude' })
vi.setSystemTime(1_200)
expect(inferEscape(server, baseline)).toBe(false)
vi.setSystemTime(1_300)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'Stop',
payload: {
state: 'done',
prompt: 'stop the turn',
agentType: 'claude',
interrupted: true
}
},
'conn-1'
)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'PostToolUse',
payload: {
state: 'working',
prompt: 'stop the turn',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'sleep 90'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
prompt: 'stop the turn',
agentType: 'claude',
interrupted: true,
receivedAt: 1_300
})
} finally {
vi.useRealTimers()
}
})
it('stamps interrupted onto a lead done for agents that omit is_interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const baseline = ingestWorking(server, { prompt: 'long task', agentType: 'opencode' })
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'opencode',
intent: 'plain-escape',
inputCount: 2
})
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
interrupted: undefined
})
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'long task', agentType: 'opencode' }
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
prompt: 'long task',
agentType: 'opencode',
interrupted: true
})
} finally {
vi.useRealTimers()
}
})
})
@@ -62,7 +62,26 @@ describe('AgentHookServer listener replay', () => {
intent: 'plain-escape'
})
expect(applied).toBe(true)
expect(applied).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' },
payload: {
state: 'done',
prompt: 'long task',
agentType: 'codex',
model: 'gpt-5.6-sol'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
@@ -71,8 +90,8 @@ describe('AgentHookServer listener replay', () => {
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' },
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
receivedAt: 1_501,
stateStartedAt: 1_501
})
])
expect(listener).toHaveBeenLastCalledWith(
@@ -198,7 +217,8 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'plain-escape'
})
).toBe(true)
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
} finally {
vi.useRealTimers()
}
@@ -328,7 +348,8 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
} finally {
vi.useRealTimers()
}
@@ -402,7 +423,27 @@ describe('AgentHookServer listener replay', () => {
intent: 'ctrl-c'
})
expect(applied).toBe(true)
expect(applied).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'Stop',
payload: {
state: 'done',
prompt: 'wrap up',
agentType: 'claude',
interrupted: true,
subagents: [{ id: 'a1', state: 'idle', startedAt: 900, agentType: 'probe1' }]
}
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
interrupted: true,
@@ -442,7 +483,8 @@ describe('AgentHookServer listener replay', () => {
intent: 'plain-escape',
inputCount: 2
})
).toBe(true)
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
vi.setSystemTime(1_501)
server.ingestRemote(
@@ -462,8 +504,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'long task',
agentType: 'opencode',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
receivedAt: 1_501,
stateStartedAt: 1_501
})
])
expect(listener).toHaveBeenLastCalledWith(
@@ -595,7 +637,20 @@ describe('AgentHookServer listener replay', () => {
inputCount: 2
})
expect(applied).toBe(true)
expect(applied).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', agentType })
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'long task', agentType }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
@@ -53,7 +53,18 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'long task', agentType: 'pi' }
},
'conn-1'
)
vi.setSystemTime(6_000)
server.ingestRemote(
@@ -78,8 +89,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'long task',
agentType: 'pi',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
receivedAt: 1_501,
stateStartedAt: 1_501
})
])
} finally {
@@ -119,7 +130,24 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'Stop',
payload: {
state: 'done',
prompt: 'Do I have gpu acceleration on on my terminal?',
agentType: 'claude',
interrupted: true
}
},
'conn-1'
)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -146,8 +174,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'Do I have gpu acceleration on on my terminal?',
agentType: 'claude',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
receivedAt: 1_501,
stateStartedAt: 1_501
})
])
} finally {
@@ -187,7 +215,22 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'codex',
intent: 'plain-escape'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'done',
prompt: 'Run sleep 30, then reply done.',
agentType: 'codex'
}
},
'conn-1'
)
vi.setSystemTime(6_000)
server.ingestRemote(
@@ -214,8 +257,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'Run sleep 30, then reply done.',
agentType: 'codex',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
receivedAt: 1_501,
stateStartedAt: 1_501
})
])
} finally {
@@ -249,7 +292,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -268,7 +311,7 @@ describe('AgentHookServer listener replay', () => {
prompt: 'second task',
agentType: 'pi',
receivedAt: 2_000,
stateStartedAt: 2_000
stateStartedAt: 1_000
})
])
} finally {
@@ -304,7 +347,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -326,7 +369,7 @@ describe('AgentHookServer listener replay', () => {
agentType: 'claude',
interrupted: undefined,
receivedAt: 2_000,
stateStartedAt: 2_000
stateStartedAt: 1_000
})
])
} finally {
@@ -361,7 +404,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -381,7 +424,7 @@ describe('AgentHookServer listener replay', () => {
prompt: 'retryable task',
agentType: 'pi',
receivedAt: 2_000,
stateStartedAt: 2_000
stateStartedAt: 1_000
})
])
} finally {
@@ -417,7 +460,24 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'Stop',
payload: {
state: 'done',
prompt: 'repeat task',
agentType: 'claude',
interrupted: true
}
},
'conn-1'
)
vi.setSystemTime(16_501)
server.ingestRemote(
@@ -444,8 +504,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'repeat task',
agentType: 'claude',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
receivedAt: 1_501,
stateStartedAt: 1_501
})
])
} finally {
@@ -479,7 +539,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(16_501)
server.ingestRemote(
@@ -499,7 +559,7 @@ describe('AgentHookServer listener replay', () => {
agentType: 'pi',
interrupted: undefined,
receivedAt: 16_501,
stateStartedAt: 16_501
stateStartedAt: 1_000
})
])
} finally {
@@ -533,7 +593,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(16_501)
server.ingestRemote(
@@ -561,7 +621,7 @@ describe('AgentHookServer listener replay', () => {
toolName: 'bash',
toolInput: '/bin/sleep 90',
receivedAt: 16_501,
stateStartedAt: 16_501
stateStartedAt: 1_000
})
])
} finally {
@@ -127,7 +127,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -153,7 +153,7 @@ describe('AgentHookServer listener replay', () => {
agentType: 'opencode',
lastAssistantMessage: 'partial answer',
receivedAt: 2_000,
stateStartedAt: 2_000
stateStartedAt: 1_000
})
])
} finally {
@@ -193,7 +193,23 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'done',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
vi.setSystemTime(20_000)
server.ingestRemote(
@@ -219,8 +235,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'retryable task',
agentType: 'opencode',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
receivedAt: 1_501,
stateStartedAt: 1_501
})
])
} finally {
@@ -254,7 +270,19 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'unknown',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'custom hook' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
@@ -346,7 +374,26 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'custom-agent',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: GOOD_PANE,
state: 'working',
prompt: 'custom task',
agentType: 'custom-agent'
})
])
vi.setSystemTime(1_251)
server.ingestRemote(
{
paneKey: GOOD_PANE,
tabId: 'tab-good',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'custom task', agentType: 'custom-agent' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
@@ -203,7 +203,8 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'plain-escape'
})
).toBe(true)
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
} finally {
vi.useRealTimers()
}
+54 -28
View File
@@ -19,7 +19,6 @@ import {
hasPendingAgentResultText,
HOOK_REQUEST_SLOWLORIS_MS,
isNewTurnEvent,
markClaudeLeadTurnInterrupted,
markCodexLeadTurnInterrupted,
MAX_PANE_KEY_LEN,
movePaneCacheState,
@@ -86,6 +85,10 @@ import {
isAgentInterruptInputIntent,
type AgentInterruptInferenceRequest
} from '../../shared/agent-interrupt-intent'
import {
reconcileAgentInterruptHint,
type AgentInterruptHint
} from '../../shared/agent-interrupt-hint'
import {
isAskUserQuestionTool,
type AgentQuestionAnsweredInferenceRequest
@@ -738,6 +741,9 @@ export class AgentHookServer {
private codexSubagentPollTimers = new Map<string, ReturnType<typeof setTimeout>>()
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
private activeHookTurnCompletedAtByPaneKey = new Map<string, number>()
// Why: inference is a hint, not a terminal rewrite. Bounded per pane; cleared on
// status delete, live work, expiry, or a confirming lead `done`.
private interruptHintsByPaneKey = new Map<string, AgentInterruptHint>()
private promptSentHashSalt = randomBytes(16).toString('hex')
private closedAgentStatusTabIds = new Set<string>()
private closedAgentStatusPaneKeys = new Set<string>()
@@ -964,35 +970,15 @@ export class AgentHookServer {
return false
}
// Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane.
if (agentType === 'claude') {
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
}
if (agentType === 'codex') {
markCodexLeadTurnInterrupted(this.state, existing.paneKey)
}
const inferred = this.applyNormalizedStatus({
// Why: silence is not an interrupt; stamp a later lead done only for agents that omit is_interrupt.
this.interruptHintsByPaneKey.set(existing.paneKey, {
paneKey: existing.paneKey,
tabId: existing.tabId,
worktreeId: existing.worktreeId,
connectionId: existing.connectionId,
providerSession: existing.providerSession,
payload: {
state: 'done',
prompt: payload.prompt,
agentType,
...(payload.model ? { model: payload.model } : {}),
interrupted: true,
// Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore.
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
console.debug('[agent-hooks] inferred interrupted agent status', {
paneKey: inferred.paneKey,
prompt: payload.prompt,
stateStartedAt: existing.stateStartedAt,
agentType,
intent: request.intent
recordedAt: Date.now()
})
return true
return false
}
/** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */
@@ -1313,6 +1299,37 @@ export class AgentHookServer {
})
}
// Why: a later lead done may confirm the hint; live working expires it instead of painting red.
private applyInterruptHint(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): AgentHookEventPayload {
const reconciled = reconcileAgentInterruptHint({
hint: this.interruptHintsByPaneKey.get(next.paneKey),
now: Date.now(),
incoming: next,
previousStateStartedAt: previous?.stateStartedAt
})
if (reconciled.hint) {
this.interruptHintsByPaneKey.set(next.paneKey, reconciled.hint)
} else {
this.interruptHintsByPaneKey.delete(next.paneKey)
}
if (!reconciled.stampInterrupted || next.payload.interrupted === true) {
return next
}
if (next.payload.agentType === 'codex') {
markCodexLeadTurnInterrupted(this.state, next.paneKey)
}
return {
...next,
payload: {
...next.payload,
interrupted: true
}
}
}
private applyNormalizedStatus(
payload: AgentHookEventPayload,
onAccepted?: () => void,
@@ -1380,7 +1397,8 @@ export class AgentHookServer {
? previousCodexRoot?.payload.model
: undefined
// Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity.
const rootContextPreservingPayload =
const rootContextPreservingPayload = this.applyInterruptHint(
previous,
preservedProviderSession || preservedRootModel
? {
...stateReconciledPayload,
@@ -1390,6 +1408,7 @@ export class AgentHookServer {
: stateReconciledPayload.payload
}
: stateReconciledPayload
)
const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary(
previous,
rootContextPreservingPayload
@@ -1866,6 +1885,7 @@ export class AgentHookServer {
this.clearCodexSubagentPoll(key)
clearPaneCacheState(this.state, key)
this.activeHookTurnCompletedAtByPaneKey.delete(key)
this.interruptHintsByPaneKey.delete(key)
this.runtimeObservedStatusPaneKeys.delete(key)
this.currentAuthorityObservations.delete(key)
this.promptSentDedupeByPaneKey.delete(key)
@@ -2655,6 +2675,7 @@ export class AgentHookServer {
}
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey)
this.interruptHintsByPaneKey.delete(resolvedPaneKey)
if (!options?.preserveAuthority) {
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
@@ -3230,6 +3251,10 @@ export class AgentHookServer {
_resetConnectionTimestampWatermarksForTests(): void {
this.connectionTimestampWatermarkById.clear()
}
_resetInterruptHintsForTests(): void {
this.interruptHintsByPaneKey.clear()
}
}
export const agentHookServer = new AgentHookServer()
@@ -3248,5 +3273,6 @@ export const _internals = {
clearAllListenerCaches(agentHookServer._getStateForTests())
agentHookServer._resetPromptSentDedupeForTests()
agentHookServer._resetConnectionTimestampWatermarksForTests()
agentHookServer._resetInterruptHintsForTests()
}
}
@@ -504,7 +504,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
relay.notifyAgentHook(
makeEnvelope({
@@ -670,7 +670,24 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
).toBe(false)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
payload: {
state: 'done',
prompt: 'replayed prompt',
agentType: 'opencode'
}
})
)
await vi.waitFor(() =>
expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
interrupted: true
})
)
relay.notifyAgentHook(
makeEnvelope({
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_INTERRUPT_HINT_TTL_MS,
reconcileAgentInterruptHint,
type AgentInterruptHint
} from './agent-interrupt-hint'
const HINT: AgentInterruptHint = {
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
prompt: 'long task',
stateStartedAt: 900,
agentType: 'opencode',
recordedAt: 1_500
}
describe('reconcileAgentInterruptHint', () => {
it('expires the hint when the same turn is still working', () => {
expect(
reconcileAgentInterruptHint({
hint: HINT,
now: 1_600,
previousStateStartedAt: 900,
incoming: {
paneKey: HINT.paneKey,
hookEventName: 'PostToolUse',
payload: { state: 'working', prompt: 'long task', agentType: 'opencode' }
}
})
).toEqual({ hint: undefined, stampInterrupted: false })
})
it('stamps a lead done for agents that omit is_interrupt', () => {
expect(
reconcileAgentInterruptHint({
hint: HINT,
now: 1_501,
previousStateStartedAt: 900,
incoming: {
paneKey: HINT.paneKey,
payload: { state: 'done', prompt: 'long task', agentType: 'opencode' }
}
})
).toEqual({ hint: undefined, stampInterrupted: true })
})
it('does not override Claude Stop — is_interrupt absence is the hook contract', () => {
expect(
reconcileAgentInterruptHint({
hint: { ...HINT, agentType: 'claude' },
now: 1_501,
previousStateStartedAt: 900,
incoming: {
paneKey: HINT.paneKey,
hookEventName: 'Stop',
payload: { state: 'done', prompt: 'long task', agentType: 'claude' }
}
})
).toEqual({ hint: undefined, stampInterrupted: false })
})
it('does not stamp a child stop onto the lead row', () => {
expect(
reconcileAgentInterruptHint({
hint: HINT,
now: 1_501,
previousStateStartedAt: 900,
incoming: {
paneKey: HINT.paneKey,
hookEventName: 'SubagentStop',
toolAgentId: 'child-1',
payload: { state: 'done', prompt: 'long task', agentType: 'opencode' }
}
})
).toEqual({ hint: HINT, stampInterrupted: false })
})
it('lets a delayed completion expire instead of inheriting the keystroke', () => {
expect(
reconcileAgentInterruptHint({
hint: HINT,
now: HINT.recordedAt + AGENT_INTERRUPT_HINT_TTL_MS + 1,
previousStateStartedAt: 900,
incoming: {
paneKey: HINT.paneKey,
payload: { state: 'done', prompt: 'long task', agentType: 'opencode' }
}
})
).toEqual({ hint: undefined, stampInterrupted: false })
})
})
+91
View File
@@ -0,0 +1,91 @@
import { AGENT_INTERRUPT_SETTLE_MS } from './agent-interrupt-intent'
import type { AgentStatusState, AgentType } from './agent-status-types'
/** Same 500ms budget as settle — silence expires the hint; it never synthesizes `done`. */
export const AGENT_INTERRUPT_HINT_TTL_MS = AGENT_INTERRUPT_SETTLE_MS
const CHILD_LIFECYCLE_HOOK_EVENTS = new Set(['SubagentStart', 'SubagentStop', 'TeammateIdle'])
export type AgentInterruptHint = {
paneKey: string
prompt: string
stateStartedAt: number
agentType: AgentType | undefined
recordedAt: number
}
function equivalentHintAgentType(
actual: AgentType | undefined,
baseline: AgentType | undefined
): boolean {
const normalizedActual = actual === 'unknown' ? undefined : actual
const normalizedBaseline = baseline === 'unknown' ? undefined : baseline
return normalizedActual === normalizedBaseline
}
/** Claude/Kimi Stop already carry `is_interrupt`; absence of the flag is authoritative. */
export function isHookAuthoritativeInterruptAgent(agentType: AgentType | undefined): boolean {
return agentType === 'claude' || agentType === 'openclaude' || agentType === 'kimi'
}
export type ReconcileAgentInterruptHintResult = {
hint: AgentInterruptHint | undefined
stampInterrupted: boolean
}
export function reconcileAgentInterruptHint({
hint,
now,
incoming,
previousStateStartedAt
}: {
hint: AgentInterruptHint | undefined
now: number
incoming: {
paneKey: string
hookEventName?: string
toolAgentId?: string
payload: {
state: AgentStatusState
prompt: string
agentType?: AgentType
interrupted?: boolean
}
}
previousStateStartedAt?: number
}): ReconcileAgentInterruptHintResult {
if (!hint || hint.paneKey !== incoming.paneKey) {
return { hint, stampInterrupted: false }
}
const sameTurn =
hint.prompt === incoming.payload.prompt &&
equivalentHintAgentType(hint.agentType, incoming.payload.agentType) &&
(previousStateStartedAt === undefined || hint.stateStartedAt === previousStateStartedAt)
if (!sameTurn) {
return { hint: undefined, stampInterrupted: false }
}
const live = now - hint.recordedAt <= AGENT_INTERRUPT_HINT_TTL_MS
if (
incoming.payload.state === 'working' ||
incoming.payload.state === 'waiting' ||
incoming.payload.state === 'blocked'
) {
return { hint: undefined, stampInterrupted: false }
}
if (incoming.payload.state !== 'done') {
return { hint: live ? hint : undefined, stampInterrupted: false }
}
if (!live) {
return { hint: undefined, stampInterrupted: false }
}
if (isHookAuthoritativeInterruptAgent(incoming.payload.agentType)) {
return { hint: undefined, stampInterrupted: false }
}
if (incoming.toolAgentId || CHILD_LIFECYCLE_HOOK_EVENTS.has(incoming.hookEventName ?? '')) {
return { hint, stampInterrupted: false }
}
return { hint: undefined, stampInterrupted: true }
}
+2 -1
View File
@@ -1,6 +1,7 @@
// ─── Explicit agent status (reported via native agent hooks → IPC) ──────────
// Why: status comes from hooks (Claude, Codex, etc.) — never inferred from terminal titles;
// a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook.
// a keystroke may record an interrupt hint that stamps a later authoritative `done`, but it
// never retires a live `working` row.
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { WithAgentStatusObservation } from './agent-status-observation'