mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
fix(agent-hooks): trust Claude Stop hooks for Escape
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,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'
|
||||
|
||||
Reference in New Issue
Block a user