mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Keep interrupted Claude turns from appearing active (#6452)
This commit is contained in:
@@ -434,6 +434,74 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let late Claude tool hooks with explicit prompt 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',
|
||||
hasExplicitPrompt: true,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'Do I have gpu acceleration on on my terminal?',
|
||||
agentType: 'claude'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'Do I have gpu acceleration on on my terminal?',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(2_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
hookEventName: 'PostToolUse',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'Do I have gpu acceleration on on my terminal?',
|
||||
agentType: 'claude',
|
||||
toolName: 'Read',
|
||||
toolInput: 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'Do I have gpu acceleration on on my terminal?',
|
||||
agentType: 'claude',
|
||||
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)
|
||||
@@ -487,6 +555,64 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a Claude follow-up prompt after an inferred interrupt to keep working', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working', prompt: 'first Claude turn', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'first Claude turn',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(2_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working', prompt: 'second queued Claude turn', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'second queued Claude turn',
|
||||
agentType: 'claude',
|
||||
interrupted: undefined,
|
||||
receivedAt: 2_000,
|
||||
stateStartedAt: 2_000
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows an immediate same-prompt retry after an inferred interrupt', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
@@ -542,7 +668,125 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a same-prompt working hook after the stale suppression window', () => {
|
||||
it('suppresses same-turn Claude tool progress after the stale suppression window', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working', prompt: 'repeat task', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'repeat task',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(16_501)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
hookEventName: 'PostToolUse',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'repeat task',
|
||||
agentType: 'claude',
|
||||
toolName: 'bash',
|
||||
toolInput: '/bin/sleep 90'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'repeat task',
|
||||
agentType: 'claude',
|
||||
interrupted: true,
|
||||
receivedAt: 1_500,
|
||||
stateStartedAt: 1_500
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows generic non-explicit same-prompt working after the stale suppression window', () => {
|
||||
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: 'repeat 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: 'repeat task',
|
||||
baselineAgentType: 'pi',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(16_501)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'repeat task',
|
||||
agentType: 'pi',
|
||||
interrupted: undefined,
|
||||
receivedAt: 16_501,
|
||||
stateStartedAt: 16_501
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows non-Claude tool-context working after the stale suppression window', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
@@ -592,6 +836,9 @@ describe('AgentHookServer listener replay', () => {
|
||||
state: 'working',
|
||||
prompt: 'repeat task',
|
||||
agentType: 'pi',
|
||||
interrupted: undefined,
|
||||
toolName: 'bash',
|
||||
toolInput: '/bin/sleep 90',
|
||||
receivedAt: 16_501,
|
||||
stateStartedAt: 16_501
|
||||
})
|
||||
|
||||
@@ -108,6 +108,7 @@ const LAST_STATUS_FILE_VERSION = 2
|
||||
// hook-server batching; quit-time uses flushStatusPersistSync() for the
|
||||
// guaranteed final flush.
|
||||
const STATUS_PERSIST_DEBOUNCE_MS = 250
|
||||
const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure'])
|
||||
const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
|
||||
|
||||
// Why: bound the on-disk file's growth across many sessions. PTY-teardown
|
||||
@@ -271,6 +272,18 @@ function trackEmptyPaneKeyHook(body: unknown): void {
|
||||
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
||||
}
|
||||
|
||||
function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean {
|
||||
if (next.payload.state !== 'working') {
|
||||
return false
|
||||
}
|
||||
if (next.payload.agentType !== 'claude') {
|
||||
return false
|
||||
}
|
||||
// Why: a same-prompt retry is another UserPromptSubmit, while late Claude
|
||||
// progress after Ctrl+C arrives as tool lifecycle work for the old turn.
|
||||
return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName)
|
||||
}
|
||||
|
||||
function paneCacheKeyTabId(key: string): string | null {
|
||||
const paneKey = key.split('\0', 1)[0] ?? key
|
||||
return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null
|
||||
@@ -737,6 +750,7 @@ export class AgentHookServer {
|
||||
previous.payload.agentType === effectivePayload.payload.agentType &&
|
||||
previous.payload.prompt === effectivePayload.payload.prompt &&
|
||||
(effectivePayload.isReplay === true ||
|
||||
isToolProgressWorkingAfterInterrupt(effectivePayload) ||
|
||||
(effectivePayload.hasExplicitPrompt !== true &&
|
||||
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
|
||||
) {
|
||||
|
||||
@@ -903,6 +903,62 @@ describe('agent status retention + prefix sweep', () => {
|
||||
expect(map['tab-10:0']).toBeDefined()
|
||||
})
|
||||
|
||||
it('setAgentStatus clears a retained snapshot for the same paneKey', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = createTestStore()
|
||||
const oldEntry: AgentStatusEntry = {
|
||||
state: 'done',
|
||||
prompt: 'old turn',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 1_000,
|
||||
paneKey: 'tab-a:0',
|
||||
stateHistory: [],
|
||||
agentType: 'claude'
|
||||
}
|
||||
const siblingEntry: AgentStatusEntry = {
|
||||
state: 'done',
|
||||
prompt: 'sibling turn',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 1_000,
|
||||
paneKey: 'tab-a:1',
|
||||
stateHistory: [],
|
||||
agentType: 'claude'
|
||||
}
|
||||
const retainedA: RetainedAgentEntry = {
|
||||
entry: oldEntry,
|
||||
worktreeId: 'wt-a',
|
||||
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
|
||||
agentType: 'claude',
|
||||
startedAt: 1_000
|
||||
}
|
||||
const retainedSibling: RetainedAgentEntry = {
|
||||
entry: siblingEntry,
|
||||
worktreeId: 'wt-a',
|
||||
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
|
||||
agentType: 'claude',
|
||||
startedAt: 1_000
|
||||
}
|
||||
|
||||
store.getState().retainAgents([retainedA, retainedSibling])
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
'tab-a:0',
|
||||
{ state: 'done', prompt: 'interrupted turn', agentType: 'claude', interrupted: true },
|
||||
'claude',
|
||||
{ updatedAt: 2_000, stateStartedAt: 2_000 }
|
||||
)
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.agentStatusByPaneKey['tab-a:0']).toMatchObject({
|
||||
state: 'done',
|
||||
prompt: 'interrupted turn',
|
||||
interrupted: true
|
||||
})
|
||||
expect(state.retainedAgentsByPaneKey['tab-a:0']).toBeUndefined()
|
||||
expect(state.retainedAgentsByPaneKey['tab-a:1']).toBe(retainedSibling)
|
||||
})
|
||||
|
||||
it('dismissRetainedAgentsByWorktree removes only entries for the given worktreeId', () => {
|
||||
const store = createTestStore()
|
||||
const now = Date.now()
|
||||
|
||||
@@ -1167,6 +1167,16 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys }
|
||||
delete nextRetentionSuppressedPaneKeys[paneKey]
|
||||
}
|
||||
// Why: pane keys are reused by the same terminal pane across turns.
|
||||
// Once a fresh live hook row arrives, any retained snapshot for that
|
||||
// pane is stale and must not render beside the live row in the sidebar.
|
||||
const hasRetainedSnapshot = paneKey in s.retainedAgentsByPaneKey
|
||||
const nextRetainedAgents = hasRetainedSnapshot
|
||||
? { ...s.retainedAgentsByPaneKey }
|
||||
: s.retainedAgentsByPaneKey
|
||||
if (hasRetainedSnapshot) {
|
||||
delete nextRetainedAgents[paneKey]
|
||||
}
|
||||
const migrationUnsupported = pruneMigrationUnsupportedEntries(
|
||||
s.migrationUnsupportedByPtyId,
|
||||
(entry) => entry.paneKey === paneKey
|
||||
@@ -1226,6 +1236,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
}
|
||||
return {
|
||||
agentStatusByPaneKey: { ...s.agentStatusByPaneKey, [paneKey]: entry },
|
||||
retainedAgentsByPaneKey: nextRetainedAgents,
|
||||
sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions,
|
||||
agentLaunchConfigByPaneKey: nextLaunchConfigs,
|
||||
migrationUnsupportedByPtyId: migrationUnsupported.next,
|
||||
|
||||
Reference in New Issue
Block a user