Keep interrupted Claude turns from appearing active (#6452)

This commit is contained in:
Brennan Benson
2026-06-26 15:01:55 -07:00
committed by GitHub
parent 85d626b998
commit ccb09db440
4 changed files with 329 additions and 1 deletions
+248 -1
View File
@@ -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
})
+14
View File
@@ -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,