Fix visible agent status after interrupts

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-18 21:25:19 -07:00
co-authored by Orca
parent 9db7816938
commit 49b1dc2d57
2 changed files with 152 additions and 4 deletions
@@ -62,6 +62,7 @@ type StoreState = {
removeDeferredSshSessionId: ReturnType<typeof vi.fn>
consumePendingColdRestore: ReturnType<typeof vi.fn>
consumePendingSnapshot: ReturnType<typeof vi.fn>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
agentStatusByPaneKey: Record<string, unknown>
removeAgentStatus: ReturnType<typeof vi.fn>
dropAgentStatus: ReturnType<typeof vi.fn>
@@ -360,6 +361,7 @@ describe('connectPanePty', () => {
removeDeferredSshSessionId: vi.fn(),
consumePendingColdRestore: vi.fn(() => null),
consumePendingSnapshot: vi.fn(() => null),
runtimePaneTitlesByTabId: {},
agentStatusByPaneKey: {},
removeAgentStatus: vi.fn(),
dropAgentStatus: vi.fn()
@@ -538,6 +540,130 @@ describe('connectPanePty', () => {
})
})
it('clears stale working pane title after inferred interrupt applies', async () => {
const { connectPanePty } = await import('./pty-connection')
const { resolveWorktreeStatus } = await import('@/lib/worktree-status')
vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true)
const transport = createMockTransport()
transportFactoryQueue.push(transport)
vi.useFakeTimers()
vi.setSystemTime(1_100)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.runtimePaneTitlesByTabId = {
'tab-1': {
1: 'Codex working'
}
}
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'working',
prompt: 'stop visible spinner',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
paneKey,
terminalTitle: 'Codex working',
stateHistory: []
}
const terminalTarget = createKeyboardEventTarget()
const pane = createPane(1)
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)
const deps = createDeps({
setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => {
mockStoreState.runtimePaneTitlesByTabId = {
...mockStoreState.runtimePaneTitlesByTabId,
[tabId]: {
...mockStoreState.runtimePaneTitlesByTabId[tabId],
[paneId]: title
}
}
})
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
;(onDataHandler as unknown as (data: string) => void)('\x03')
await flushAsyncTicks()
vi.advanceTimersByTime(500)
await flushAsyncTicks()
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex')
expect(
resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'Codex working' }],
browserTabs: [],
ptyIdsByTabId: { 'tab-1': ['tab-pty'] },
runtimePaneTitlesByTabId: mockStoreState.runtimePaneTitlesByTabId,
hasPermission: false,
hasLiveWorking: false,
hasLiveDone: true,
hasRetainedDone: false
})
).toBe('done')
})
it('removes agent status and pane title on PTY exit after inferred interrupt', async () => {
const { connectPanePty } = await import('./pty-connection')
vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true)
const transport = createMockTransport('tab-pty')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
vi.setSystemTime(1_100)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.runtimePaneTitlesByTabId = {
'tab-1': {
1: 'Codex working'
}
}
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'working',
prompt: 'stop then exit',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
paneKey,
terminalTitle: 'Codex working',
stateHistory: []
}
const terminalTarget = createKeyboardEventTarget()
const pane = createPane(1)
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)
const deps = createDeps()
connectPanePty(pane as never, createManager(1) as never, deps as never)
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
;(onDataHandler as unknown as (data: string) => void)('\x03')
await flushAsyncTicks()
vi.advanceTimersByTime(500)
await flushAsyncTicks()
const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
if (!onPtyExit) {
throw new Error('expected onPtyExit callback to be registered')
}
onPtyExit('tab-pty')
expect(deps.clearRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1)
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'tab-pty')
expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, null)
expect(mockStoreState.removeAgentStatus).toHaveBeenCalledWith(paneKey)
})
it('ignores repeated and modified terminal interrupt keydowns', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -24,6 +24,7 @@ import {
waitForTerminalOutputParsed,
writeTerminalOutput
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { clearWorkingIndicators } from '../../../../shared/agent-detection'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
import { e2eConfig } from '@/lib/e2e-config'
@@ -194,14 +195,35 @@ export function connectPanePty(
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
const cacheKey = makePaneKey(deps.tabId, pane.leafId)
const pendingSpawnKey = cacheKey
const clearInferredInterruptWorkingTitle = (): void => {
const state = useAppStore.getState()
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle
const title = currentTitle ?? statusTitle
if (!title) {
return
}
// Why: inferred interrupts update the explicit hook row, but many CLIs leave
// their OSC title stuck on a working spinner. Clear that pane-local title so
// the sidebar/worktree status reflects the inferred done state users see.
deps.setRuntimePaneTitle(deps.tabId, pane.id, clearWorkingIndicators(title))
}
const interruptInference = createAgentInterruptInference({
paneKey: cacheKey,
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey],
inferInterrupt: (request) => {
return window.api.agentStatus.inferInterrupt(request).catch((err) => {
console.warn('[agent-interrupt] inferInterrupt failed:', err)
return false
})
return window.api.agentStatus
.inferInterrupt(request)
.then((applied) => {
if (applied) {
clearInferredInterruptWorkingTitle()
}
return applied
})
.catch((err) => {
console.warn('[agent-interrupt] inferInterrupt failed:', err)
return false
})
}
})
const dropCommandFinishedStatusIfUnchanged = (entry: AgentStatusEntry | undefined): void => {