fix(agent-hooks): trust Claude Stop hooks for Escape

This commit is contained in:
Brennan Benson
2026-08-23 14:01:39 -07:00
parent c5d88b38f6
commit 857ae15908
9 changed files with 77 additions and 507 deletions
@@ -30,7 +30,7 @@ function ingestWorking(
server: AgentHookServer,
payload: {
prompt: string
agentType: 'claude' | 'codex' | 'opencode'
agentType: 'claude'
hookEventName?: string
}
): ReturnType<AgentHookServer['getStatusSnapshot']>[0] {
@@ -52,10 +52,6 @@ function ingestWorking(
return server.getStatusSnapshot()[0]!
}
// Why: take the snapshot row type itself rather than a hand-written structural
// shape - the payload's fields are optional, so a narrower literal type does not
// accept it and typecheck fails (tests are excluded from the mobile tsconfig but
// not from this one).
function inferEscape(
server: AgentHookServer,
baseline: ReturnType<AgentHookServer['getStatusSnapshot']>[0]
@@ -80,6 +76,8 @@ describe('interrupt inference yields to a live working hook row', () => {
vi.setSystemTime(1_500)
expect(inferEscape(server, baseline)).toBe(false)
vi.setSystemTime(1_600)
expect(inferEscape(server, baseline)).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'long tool call',
@@ -247,50 +245,4 @@ describe('interrupt inference yields to a live working hook row', () => {
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,26 +62,7 @@ describe('AgentHookServer listener replay', () => {
intent: 'plain-escape'
})
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(applied).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
@@ -90,8 +71,8 @@ describe('AgentHookServer listener replay', () => {
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' },
interrupted: true,
receivedAt: 1_501,
stateStartedAt: 1_501
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
expect(listener).toHaveBeenLastCalledWith(
@@ -348,8 +329,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
).toBe(true)
} finally {
vi.useRealTimers()
}
@@ -423,27 +403,7 @@ describe('AgentHookServer listener replay', () => {
intent: 'ctrl-c'
})
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(applied).toBe(true)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
interrupted: true,
@@ -483,8 +443,7 @@ describe('AgentHookServer listener replay', () => {
intent: 'plain-escape',
inputCount: 2
})
).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
).toBe(true)
vi.setSystemTime(1_501)
server.ingestRemote(
@@ -504,8 +463,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'long task',
agentType: 'opencode',
interrupted: true,
receivedAt: 1_501,
stateStartedAt: 1_501
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
expect(listener).toHaveBeenLastCalledWith(
@@ -637,20 +596,7 @@ describe('AgentHookServer listener replay', () => {
inputCount: 2
})
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(applied).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
@@ -53,18 +53,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).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'
)
).toBe(true)
vi.setSystemTime(6_000)
server.ingestRemote(
@@ -89,8 +78,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'long task',
agentType: 'pi',
interrupted: true,
receivedAt: 1_501,
stateStartedAt: 1_501
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
@@ -130,24 +119,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).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'
)
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -174,8 +146,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'Do I have gpu acceleration on on my terminal?',
agentType: 'claude',
interrupted: true,
receivedAt: 1_501,
stateStartedAt: 1_501
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
@@ -215,22 +187,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'codex',
intent: 'plain-escape'
})
).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'
)
).toBe(true)
vi.setSystemTime(6_000)
server.ingestRemote(
@@ -257,8 +214,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'Run sleep 30, then reply done.',
agentType: 'codex',
interrupted: true,
receivedAt: 1_501,
stateStartedAt: 1_501
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
@@ -292,7 +249,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(false)
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -311,7 +268,7 @@ describe('AgentHookServer listener replay', () => {
prompt: 'second task',
agentType: 'pi',
receivedAt: 2_000,
stateStartedAt: 1_000
stateStartedAt: 2_000
})
])
} finally {
@@ -347,7 +304,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(false)
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -369,7 +326,7 @@ describe('AgentHookServer listener replay', () => {
agentType: 'claude',
interrupted: undefined,
receivedAt: 2_000,
stateStartedAt: 1_000
stateStartedAt: 2_000
})
])
} finally {
@@ -404,7 +361,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(false)
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -424,7 +381,7 @@ describe('AgentHookServer listener replay', () => {
prompt: 'retryable task',
agentType: 'pi',
receivedAt: 2_000,
stateStartedAt: 1_000
stateStartedAt: 2_000
})
])
} finally {
@@ -460,24 +417,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).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'
)
).toBe(true)
vi.setSystemTime(16_501)
server.ingestRemote(
@@ -504,8 +444,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'repeat task',
agentType: 'claude',
interrupted: true,
receivedAt: 1_501,
stateStartedAt: 1_501
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
@@ -539,7 +479,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(false)
).toBe(true)
vi.setSystemTime(16_501)
server.ingestRemote(
@@ -559,7 +499,7 @@ describe('AgentHookServer listener replay', () => {
agentType: 'pi',
interrupted: undefined,
receivedAt: 16_501,
stateStartedAt: 1_000
stateStartedAt: 16_501
})
])
} finally {
@@ -593,7 +533,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(false)
).toBe(true)
vi.setSystemTime(16_501)
server.ingestRemote(
@@ -621,7 +561,7 @@ describe('AgentHookServer listener replay', () => {
toolName: 'bash',
toolInput: '/bin/sleep 90',
receivedAt: 16_501,
stateStartedAt: 1_000
stateStartedAt: 16_501
})
])
} finally {
@@ -127,7 +127,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(false)
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
@@ -153,7 +153,7 @@ describe('AgentHookServer listener replay', () => {
agentType: 'opencode',
lastAssistantMessage: 'partial answer',
receivedAt: 2_000,
stateStartedAt: 1_000
stateStartedAt: 2_000
})
])
} finally {
@@ -193,23 +193,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).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'
)
).toBe(true)
vi.setSystemTime(20_000)
server.ingestRemote(
@@ -235,8 +219,8 @@ describe('AgentHookServer listener replay', () => {
prompt: 'retryable task',
agentType: 'opencode',
interrupted: true,
receivedAt: 1_501,
stateStartedAt: 1_501
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
@@ -270,19 +254,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'unknown',
intent: 'ctrl-c'
})
).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'
)
).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
@@ -374,26 +346,7 @@ describe('AgentHookServer listener replay', () => {
baselineAgentType: 'custom-agent',
intent: 'ctrl-c'
})
).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'
)
).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
+33 -55
View File
@@ -19,6 +19,7 @@ import {
hasPendingAgentResultText,
HOOK_REQUEST_SLOWLORIS_MS,
isNewTurnEvent,
markClaudeLeadTurnInterrupted,
markCodexLeadTurnInterrupted,
MAX_PANE_KEY_LEN,
movePaneCacheState,
@@ -85,10 +86,6 @@ import {
isAgentInterruptInputIntent,
type AgentInterruptInferenceRequest
} from '../../shared/agent-interrupt-intent'
import {
reconcileAgentInterruptHint,
type AgentInterruptHint
} from '../../shared/agent-interrupt-hint'
import {
isAskUserQuestionTool,
type AgentQuestionAnsweredInferenceRequest
@@ -741,9 +738,6 @@ 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>()
@@ -946,6 +940,10 @@ export class AgentHookServer {
if (dismissesClaudeQuestion) {
return this.inferQuestionAnswered(request)
}
// Why: Claude owns Escape routing and its Stop hook reports whether the turn was interrupted.
if (agentType === 'claude' && request.intent === 'plain-escape') {
return false
}
// Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook.
if (
payload.state !== 'working' ||
@@ -970,15 +968,35 @@ export class AgentHookServer {
return false
}
// Why: silence is not an interrupt; stamp a later lead done only for agents that omit is_interrupt.
this.interruptHintsByPaneKey.set(existing.paneKey, {
// 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({
paneKey: existing.paneKey,
prompt: payload.prompt,
stateStartedAt: existing.stateStartedAt,
agentType,
recordedAt: Date.now()
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 } : {})
}
})
return false
console.debug('[agent-hooks] inferred interrupted agent status', {
paneKey: inferred.paneKey,
agentType,
intent: request.intent
})
return true
}
/** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */
@@ -1299,37 +1317,6 @@ 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,
@@ -1397,8 +1384,7 @@ 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 = this.applyInterruptHint(
previous,
const rootContextPreservingPayload =
preservedProviderSession || preservedRootModel
? {
...stateReconciledPayload,
@@ -1408,7 +1394,6 @@ export class AgentHookServer {
: stateReconciledPayload.payload
}
: stateReconciledPayload
)
const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary(
previous,
rootContextPreservingPayload
@@ -1885,7 +1870,6 @@ 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)
@@ -2675,7 +2659,6 @@ 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)
@@ -3251,10 +3234,6 @@ export class AgentHookServer {
_resetConnectionTimestampWatermarksForTests(): void {
this.connectionTimestampWatermarkById.clear()
}
_resetInterruptHintsForTests(): void {
this.interruptHintsByPaneKey.clear()
}
}
export const agentHookServer = new AgentHookServer()
@@ -3273,6 +3252,5 @@ 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(false)
).toBe(true)
relay.notifyAgentHook(
makeEnvelope({
@@ -670,24 +670,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).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
})
)
).toBe(true)
relay.notifyAgentHook(
makeEnvelope({
-90
View File
@@ -1,90 +0,0 @@
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
@@ -1,91 +0,0 @@
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 }
}
+1 -2
View File
@@ -1,7 +1,6 @@
// ─── Explicit agent status (reported via native agent hooks → IPC) ──────────
// Why: status comes from hooks (Claude, Codex, etc.) — never inferred from terminal titles;
// a keystroke may record an interrupt hint that stamps a later authoritative `done`, but it
// never retires a live `working` row.
// a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook.
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { WithAgentStatusObservation } from './agent-status-observation'