Handle agent-specific interrupt status edge cases

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-18 16:04:44 -07:00
co-authored by Orca
parent e77ac881a2
commit 03b4f86759
5 changed files with 367 additions and 4 deletions
+193
View File
@@ -167,6 +167,199 @@ describe('AgentHookServer listener replay', () => {
}
})
it('rejects single plain Escape inference for OpenCode', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'opencode' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'opencode',
intent: 'plain-escape'
})
expect(applied).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'long task',
agentType: 'opencode'
})
])
} finally {
vi.useRealTimers()
}
})
it('accepts double plain Escape inference for OpenCode', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'opencode' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'opencode',
intent: 'plain-escape',
inputCount: 2
})
expect(applied).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'long task',
agentType: 'opencode',
interrupted: true
})
])
} finally {
vi.useRealTimers()
}
})
it('does not let late same-turn working hooks resurrect an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(6_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'long task',
agentType: 'pi',
toolName: 'bash',
toolInput: '/bin/sleep 90'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'long task',
agentType: 'pi',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
vi.useRealTimers()
}
})
it('allows a new prompt after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'first task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'first task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'second task', agentType: 'pi' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'second task',
agentType: 'pi',
receivedAt: 2_000,
stateStartedAt: 2_000
})
])
} finally {
vi.useRealTimers()
}
})
it('rejects inferred interrupts for stale and non-working rows', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
+21
View File
@@ -84,6 +84,7 @@ type PaneKeyAliasEntry = {
const LAST_STATUS_FILE_NAME = 'last-status.json'
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
const ASSISTANT_MESSAGE_RETRY_MS = 50
const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch
// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a
@@ -300,6 +301,11 @@ export class AgentHookServer {
}
const payload = existing.payload
const agentType: AgentType | undefined = payload.agentType
// Why: OpenCode's running-turn interrupt is double Escape. A single Escape
// is only a TUI/editor cancel and can leave the turn running.
if (agentType === 'opencode' && request.intent === 'plain-escape' && request.inputCount !== 2) {
return false
}
// Why: input-intent inference is a fallback for a missing final hook. A strict
// baseline match keeps a delayed timer from overwriting any newer hook,
// including same-millisecond prompt or agent identity changes.
@@ -374,6 +380,21 @@ export class AgentHookServer {
}
private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
// Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already
// stopped the turn. Do not let that stale same-turn event resurrect the row.
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
payload.payload.state === 'working' &&
previous.payload.agentType === payload.payload.agentType &&
previous.payload.prompt === payload.payload.prompt &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS
) {
return previous
}
if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(payload.paneKey)
}
@@ -84,6 +84,114 @@ describe('agent interrupt inference', () => {
entry = undefined
})
it('requires double Escape on the same turn for OpenCode', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
vi.runOnlyPendingTimers()
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.observeInputIntent('plain-escape')
vi.advanceTimersByTime(500)
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: 'opencode',
intent: 'plain-escape',
inputCount: 2
})
tracker.dispose()
entry = undefined
})
it('does not count an OpenCode Escape across a new turn', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
entry = makeEntry({ agentType: 'opencode', prompt: 'second task', stateStartedAt: 1_050 })
tracker.observeInputIntent('plain-escape')
vi.runOnlyPendingTimers()
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
entry = undefined
})
it('does not emit again for a third OpenCode Escape after the row is already done', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
const inferInterrupt = vi.fn((request) => {
entry = makeEntry({
state: 'done',
agentType: request.baselineAgentType,
prompt: request.baselinePrompt,
updatedAt: 1_500,
stateStartedAt: 1_500
})
})
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
tracker.observeInputIntent('plain-escape')
vi.advanceTimersByTime(500)
tracker.observeInputIntent('plain-escape')
vi.runOnlyPendingTimers()
expect(inferInterrupt).toHaveBeenCalledTimes(1)
tracker.dispose()
entry = undefined
})
it('still infers Ctrl+C for OpenCode', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('ctrl-c')
vi.advanceTimersByTime(500)
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
tracker.dispose()
entry = undefined
})
it('does not emit for non-working states', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
@@ -29,6 +29,25 @@ type CapturedInterruptBaseline = {
prompt: string
agentType: AgentStatusEntry['agentType']
intent: AgentInterruptInputIntent
inputCount?: number
}
function requiresDoubleEscapeForAgent(
agentType: AgentStatusEntry['agentType'],
intent: AgentInterruptInputIntent
): boolean {
return agentType === 'opencode' && intent === 'plain-escape'
}
function isSameTurnBaseline(
left: CapturedInterruptBaseline,
right: CapturedInterruptBaseline
): boolean {
return (
left.agentType === right.agentType &&
left.prompt === right.prompt &&
left.stateStartedAt === right.stateStartedAt
)
}
export function isPlainEscapeKeyEvent(
@@ -67,8 +86,9 @@ export function createAgentInterruptInference({
}: AgentInterruptInferenceDeps): AgentInterruptInference {
let pendingTimer: ReturnType<typeof setTimeout> | null = null
let pendingBaseline: CapturedInterruptBaseline | null = null
let openCodeEscapeBaseline: CapturedInterruptBaseline | null = null
const clearPending = (): void => {
const clearPendingTimer = (): void => {
if (pendingTimer !== null) {
clearTimer(pendingTimer)
pendingTimer = null
@@ -76,6 +96,11 @@ export function createAgentInterruptInference({
pendingBaseline = null
}
const clearPending = (): void => {
clearPendingTimer()
openCodeEscapeBaseline = null
}
const captureBaseline = (
entry: AgentStatusEntry,
intent: AgentInterruptInputIntent
@@ -123,7 +148,8 @@ export function createAgentInterruptInference({
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: baseline.prompt,
baselineAgentType: baseline.agentType,
intent: baseline.intent
intent: baseline.intent,
...(baseline.inputCount !== undefined ? { inputCount: baseline.inputCount } : {})
})
}
@@ -134,12 +160,26 @@ export function createAgentInterruptInference({
clearPending()
return
}
const baseline = captureBaseline(entry, intent)
let baseline = captureBaseline(entry, intent)
if (!baseline) {
clearPending()
return
}
clearPending()
if (requiresDoubleEscapeForAgent(baseline.agentType, intent)) {
const isSecondEscape =
openCodeEscapeBaseline !== null && isSameTurnBaseline(openCodeEscapeBaseline, baseline)
openCodeEscapeBaseline = baseline
clearPendingTimer()
if (!isSecondEscape) {
return
}
// Why: OpenCode uses the first Escape as a TUI/editor cancel. The
// second Escape on the same turn is the actual running-turn interrupt.
baseline = { ...baseline, inputCount: 2 }
} else {
openCodeEscapeBaseline = null
clearPendingTimer()
}
pendingBaseline = baseline
pendingTimer = setTimer(flushPending, AGENT_INTERRUPT_SETTLE_MS)
},
+1
View File
@@ -11,6 +11,7 @@ export type AgentInterruptInferenceRequest = {
baselinePrompt: string
baselineAgentType: AgentType | undefined
intent: AgentInterruptInputIntent
inputCount?: number
}
export function isAgentInterruptInputIntent(intent: unknown): intent is AgentInterruptInputIntent {