mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user