diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 9e9676c22..ba843ad98 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -3330,6 +3330,30 @@ describe('AgentHookServer prompt-sent telemetry', () => { expect(trackMock).toHaveBeenCalledTimes(2) }) + it('includes Command Code prompt interaction keys in the IPC snapshot', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-user-1', + payload: { state: 'working', prompt: 'rerun', agentType: 'command-code' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE, + promptInteractionKey: 'command-code-transcript-user-1', + state: 'working', + prompt: 'rerun', + agentType: 'command-code' + }) + }) + it('dedupes Command Code direct prompt hooks followed by transcript-backed stop hooks', () => { const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f85927730..329543440 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -59,6 +59,7 @@ import { import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import type { LegacyPaneKeyAliasEntry } from '../../shared/types' import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' +import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' export type { AgentHookSource } @@ -251,6 +252,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), + ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), ...entry.payload } } @@ -653,8 +655,22 @@ export class AgentHookServer { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined + const commandCodeNewTurn = + previous !== undefined && + isCommandCodeNewTurnWhileWorking({ + agentType: payload.payload.agentType, + previousState: previous.payload.state, + incomingState: payload.payload.state, + previousPrompt: previous.payload.prompt, + incomingPrompt: payload.payload.prompt, + hasExplicitPrompt: payload.hasExplicitPrompt, + previousPromptInteractionKey: previous.promptInteractionKey, + incomingPromptInteractionKey: payload.promptInteractionKey + }) const stateStartedAt = - previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now + previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn + ? previous.stateStartedAt + : now return { ...payload, receivedAt: now, diff --git a/src/main/index.ts b/src/main/index.ts index 85b62682f..16d3c870c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1022,6 +1022,7 @@ function openMainWindow(): BrowserWindow { stateStartedAt, launchToken, providerSession, + promptInteractionKey, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -1041,6 +1042,7 @@ function openMainWindow(): BrowserWindow { receivedAt, stateStartedAt, ...(providerSession ? { providerSession } : {}), + ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(orchestration ? { orchestration } : {}) }) recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 96b54f9a5..be04dd3e9 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -188,6 +188,21 @@ describe('resolveAttention', () => { }) }) + it('uses a reset stateStartedAt for Command Code new prompts while still working', () => { + const entry = makeEntry({ + paneKey: 't:1', + state: 'working', + agentType: 'command-code', + stateStartedAt: NOW - 2_000, + updatedAt: NOW - 500, + stateHistory: [makeHistory('done', NOW - 30 * 60_000)] + }) + expect(resolveAttention([hookPane(entry)], NOW)).toEqual({ + cls: 3, + attentionTimestamp: NOW - 2_000 + }) + }) + it('falls back to current stateStartedAt when working has no prior attention history', () => { const entry = makeEntry({ paneKey: 't:1', diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index ae0d7542d..8b47216cf 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -152,7 +152,18 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten // been working for an hour. Falls back to the current stateStartedAt // when stateHistory is empty (e.g. fresh after restart). const prior = mostRecentAttentionInHistory(entry.stateHistory) - ts = prior ?? entry.stateStartedAt + if (prior === null) { + ts = entry.stateStartedAt + } else if (entry.agentType === 'command-code') { + // Why: Command Code has no UserPromptSubmit hook, so a new prompt while + // still `working` only advances stateStartedAt (no new history row). It + // must beat the stale prior-attention timestamp. Other agents keep the + // prior-attention ordering — their real state transitions already mark + // the turn boundary, so scoping avoids reordering them. + ts = Math.max(prior, entry.stateStartedAt) + } else { + ts = prior + } } } else { // Title-heuristic fallback (no fresh hook entry for this pane). Hook diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index cd72db3a4..9db4c0d3b 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3029,6 +3029,9 @@ export function useIpcEvents(): void { const statusPayload = data.orchestration ? { ...resolvedPayload, orchestration: data.orchestration } : resolvedPayload + const statusPayloadWithTurnBoundary = data.promptInteractionKey + ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } + : statusPayload const existingStatus = store.agentStatusByPaneKey[data.paneKey] if (existingStatus && data.receivedAt < existingStatus.updatedAt) { // Why: the store rejects out-of-order status rows; keep notification and @@ -3075,7 +3078,7 @@ export function useIpcEvents(): void { const statusWorktreeId = data.worktreeId ?? owningWorktreeId store.setAgentStatus( data.paneKey, - statusPayload, + statusPayloadWithTurnBoundary, terminalTitle, { updatedAt: data.receivedAt, diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 7090ac68b..cab8071cf 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -1697,6 +1697,81 @@ describe('applyWebSessionTabsSnapshot', () => { }) }) + it('bumps sort epoch for mirrored Command Code same-state turn starts', () => { + const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) + const initialPatch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'Command Code', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'same prompt', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 1_000, + agentType: 'command-code', + paneKey: hostPaneKey, + terminalTitle: 'Command Code', + stateHistory: [], + promptInteractionKey: 'command-code-transcript-a' + } + } + ]), + ENV, + NOW + ) as Partial + const initialState = { ...makeState(), ...initialPatch } + const mirroredId = initialPatch.tabsByWorktree?.[WT]?.[0]?.id + const mirroredPaneKey = makePaneKey(mirroredId!, LEAF_ID) + + const patch = applyWebSessionTabsSnapshot( + initialState, + makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'Command Code', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'same prompt', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'command-code', + paneKey: hostPaneKey, + terminalTitle: 'Command Code', + stateHistory: [], + promptInteractionKey: 'command-code-transcript-b' + } + } + ], + { snapshotVersion: 2 } + ), + ENV, + NOW + ) as Partial + + expect(patch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({ + prompt: 'same prompt', + stateStartedAt: NOW, + promptInteractionKey: 'command-code-transcript-b' + }) + expect(patch.agentStatusEpoch).toBe((initialState.agentStatusEpoch ?? 0) + 1) + expect(patch.sortEpoch).toBe((initialState.sortEpoch ?? 0) + 1) + }) + it('hydrates multiple initial host snapshots in one merged patch', () => { const secondWorktree = 'repo::/other-worktree' const patch = applyWebSessionTabsSnapshots( diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index e28072d53..df81b9192 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -716,7 +716,8 @@ function buildMirroredAgentStatusPatch( sortRelevantChange || !existing || existing.state !== entry.state || - !isAgentStatusFresh(existing, now) + !isAgentStatusFresh(existing, now) || + isMirroredCommandCodeTurnBump(existing, entry) } if (!changed) { @@ -1297,6 +1298,7 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && a.interrupted === b.interrupted && + a.promptInteractionKey === b.promptInteractionKey && sameAgentStateHistory(a.stateHistory, b.stateHistory) ) } @@ -1305,6 +1307,19 @@ function isAgentStatusFresh(entry: Pick, now: num return now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS } +function isMirroredCommandCodeTurnBump( + existing: AgentStatusEntry | undefined, + entry: AgentStatusEntry +): boolean { + return ( + existing?.agentType === 'command-code' && + entry.agentType === 'command-code' && + existing.state === 'working' && + entry.state === 'working' && + entry.stateStartedAt > existing.stateStartedAt + ) +} + function sameStringRecord( a: Readonly> | undefined, b: Readonly> | undefined diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index cf3b7cd9b..1aa127012 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -664,6 +664,101 @@ describe('agent status tool + assistant fields', () => { expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1) expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) }) + + it('bumps sort epoch when Command Code starts a new prompt while still working', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'first task', agentType: 'command-code' }, + 'command-code', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + const firstSortEpoch = store.getState().sortEpoch + + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'second task', agentType: 'command-code' }, + 'command-code', + { updatedAt: 2_000, stateStartedAt: 2_000 } + ) + + const entry = store.getState().agentStatusByPaneKey['tab-1:1'] + expect(entry.prompt).toBe('second task') + expect(entry.stateStartedAt).toBe(2_000) + expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) + }) + + it('bumps sort epoch when Command Code reruns the same prompt with a new turn key', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus( + 'tab-1:1', + { + state: 'working', + prompt: 'same task', + agentType: 'command-code', + promptInteractionKey: 'command-code-transcript-a' + }, + 'command-code', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + const firstSortEpoch = store.getState().sortEpoch + + store.getState().setAgentStatus( + 'tab-1:1', + { + state: 'working', + prompt: 'same task', + agentType: 'command-code', + promptInteractionKey: 'command-code-transcript-b' + }, + 'command-code', + { updatedAt: 2_000, stateStartedAt: 2_000 } + ) + + const entry = store.getState().agentStatusByPaneKey['tab-1:1'] + expect(entry.prompt).toBe('same task') + expect(entry.promptInteractionKey).toBe('command-code-transcript-b') + expect(entry.stateStartedAt).toBe(2_000) + expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) + }) + + it('bumps sort epoch when main advances Command Code stateStartedAt without a renderer-visible key change', () => { + vi.useFakeTimers() + const store = createTestStore() + // First turn carries no interaction key (e.g. transcript read failed), so + // the renderer stores no promptInteractionKey to compare against. + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'same task', agentType: 'command-code' }, + 'command-code', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + const firstSortEpoch = store.getState().sortEpoch + + // Main detected a new turn via interaction-key change and reset stateStartedAt, + // but the renderer can't see the key change (no key, identical prompt text). + // The authoritative stateStartedAt advance must still re-sort. + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'same task', agentType: 'command-code' }, + 'command-code', + { updatedAt: 2_000, stateStartedAt: 2_000 } + ) + + const entry = store.getState().agentStatusByPaneKey['tab-1:1'] + expect(entry.stateStartedAt).toBe(2_000) + expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) + }) }) describe('agent status PR refresh handoff', () => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 99ae087f5..d135a5853 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -23,6 +23,7 @@ import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus } from '../../../../shared/agent-status-identity' +import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' import type { TerminalTab } from '../../../../shared/types' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { @@ -133,7 +134,10 @@ export type AgentStatusSlice = { /** Update or insert an agent status entry from a status payload. */ setAgentStatus: ( paneKey: string, - payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext }, + payload: ParsedAgentStatusPayload & { + orchestration?: AgentStatusOrchestrationContext + promptInteractionKey?: string + }, terminalTitle?: string, timing?: { updatedAt?: number; stateStartedAt?: number }, routing?: { tabId?: string; worktreeId?: string; terminalHandle?: string }, @@ -1229,14 +1233,6 @@ export const createAgentStatusSlice: StateCreator { + it('returns true when Command Code gets a new transcript prompt while still working', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'command-code', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'first task', + incomingPrompt: 'second task', + hasExplicitPrompt: true + }) + ).toBe(true) + }) + + it('returns true when the prompt interaction key changes', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'command-code', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'same text', + incomingPrompt: 'same text', + hasExplicitPrompt: true, + previousPromptInteractionKey: 'command-code-transcript-a', + incomingPromptInteractionKey: 'command-code-transcript-b' + }) + ).toBe(true) + }) + + it('returns false for same-turn tool pings that keep the same prompt', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'command-code', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'run pwd', + incomingPrompt: 'run pwd', + hasExplicitPrompt: true + }) + ).toBe(false) + }) + + it('returns false when main-process hooks explicitly deny prompt evidence', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'command-code', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'first', + incomingPrompt: 'second', + hasExplicitPrompt: false + }) + ).toBe(false) + }) + + it('returns false for non-Command Code agents', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'codex', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'first', + incomingPrompt: 'second', + hasExplicitPrompt: true + }) + ).toBe(false) + }) +}) diff --git a/src/shared/command-code-turn-boundary.ts b/src/shared/command-code-turn-boundary.ts new file mode 100644 index 000000000..6120f55a4 --- /dev/null +++ b/src/shared/command-code-turn-boundary.ts @@ -0,0 +1,56 @@ +import type { AgentType } from './agent-status-types' + +type CommandCodeTurnBoundaryInput = { + agentType: AgentType | undefined + previousState?: string + incomingState: string + previousPrompt?: string + incomingPrompt: string + hasExplicitPrompt?: boolean + previousPromptInteractionKey?: string + incomingPromptInteractionKey?: string +} + +/** Command Code has no UserPromptSubmit hook; a new transcript prompt is the turn boundary. */ +export function isCommandCodeNewTurnWhileWorking({ + agentType, + previousState, + incomingState, + previousPrompt, + incomingPrompt, + hasExplicitPrompt, + previousPromptInteractionKey, + incomingPromptInteractionKey +}: CommandCodeTurnBoundaryInput): boolean { + if (agentType !== 'command-code') { + return false + } + if (previousState !== 'working' || incomingState !== 'working') { + return false + } + + const nextPrompt = incomingPrompt.trim() + if (nextPrompt.length === 0) { + return false + } + + if ( + incomingPromptInteractionKey !== undefined && + previousPromptInteractionKey !== undefined && + incomingPromptInteractionKey !== previousPromptInteractionKey + ) { + return true + } + + if (nextPrompt === (previousPrompt ?? '').trim()) { + return false + } + + // Main-process hooks can require explicit prompt evidence; renderer IPC does + // not carry the flag yet, so a prompt text change is enough there. + if (hasExplicitPrompt === false) { + return false + } + + return true +}