mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Handle agent-specific interrupt status edge cases
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AgentInterruptInferenceRequest = {
|
||||
baselinePrompt: string
|
||||
baselineAgentType: AgentType | undefined
|
||||
intent: AgentInterruptInputIntent
|
||||
inputCount?: number
|
||||
}
|
||||
|
||||
export function isAgentInterruptInputIntent(intent: unknown): intent is AgentInterruptInputIntent {
|
||||
|
||||
Reference in New Issue
Block a user