diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 1a04818b673..32bbc7d64d3 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -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 }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 542a31c9c06..118d3524719 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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(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)) ) { diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 9044553173e..cf3b7cd9b12 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index df638d3f144..623bea5f5d5 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1167,6 +1167,16 @@ export const createAgentStatusSlice: StateCreator entry.paneKey === paneKey @@ -1226,6 +1236,7 @@ export const createAgentStatusSlice: StateCreator