Fix Command Code smart-sort bump on new prompt while working (STA-350) (#5286)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-12 18:05:11 -07:00 committed by GitHub
parent d5627638ab
commit 3c87873dca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 443 additions and 14 deletions

View File

@ -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()

View File

@ -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,

View File

@ -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)

View File

@ -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',

View File

@ -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

View File

@ -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,

View File

@ -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<WebSessionTabsSyncState>
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<WebSessionTabsSyncState>
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(

View File

@ -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<AgentStatusEntry, 'updatedAt'>, 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<Record<string, string>> | undefined,
b: Readonly<Record<string, string>> | undefined

View File

@ -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', () => {

View File

@ -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<AppState, [], [], AgentStatusS
}
}
// Why: prefer main's authoritative stateStartedAt when provided — main's
// attachStatusTiming preserves it across same-state pings (server.ts) and
// persists it across restart. Fall back to existing.stateStartedAt only when
// main did not send timing (legacy callers / OSC fallback path), and to
// updatedAt for a brand-new pane.
const stateStartedAt =
timing?.stateStartedAt ??
(existing && existing.state === payload.state ? existing.stateStartedAt : updatedAt)
const identity = resolveAgentStatusIdentity({
existing: existing
? {
@ -1248,6 +1244,34 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
incoming: payload.agentType,
now: updatedAt
})
// Why: Command Code has no UserPromptSubmit; a fresh transcript prompt while
// still `working` is the smart-sort turn boundary.
const commandCodeNewTurn =
existing !== undefined &&
isCommandCodeNewTurnWhileWorking({
agentType: identity.agentType,
previousState: existing.state,
incomingState: payload.state,
previousPrompt: existing.prompt,
incomingPrompt: payload.prompt,
previousPromptInteractionKey: existing.promptInteractionKey,
incomingPromptInteractionKey: payload.promptInteractionKey
})
const promptInteractionKey =
payload.promptInteractionKey ??
(payload.prompt === existing?.prompt ? existing?.promptInteractionKey : undefined)
// Why: prefer main's authoritative stateStartedAt when provided — main's
// attachStatusTiming preserves it across same-state pings (server.ts) and
// persists it across restart. Fall back to existing.stateStartedAt only when
// main did not send timing (legacy callers / OSC fallback path), and to
// updatedAt for a brand-new pane.
const stateStartedAt =
timing?.stateStartedAt ??
(commandCodeNewTurn
? updatedAt
: existing && existing.state === payload.state
? existing.stateStartedAt
: updatedAt)
if (
existing &&
shouldSuppressInheritedTerminalStatus({
@ -1361,6 +1385,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
? existing?.subagents
: payload.subagents,
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
// Why: interrupted lives on `done` only. parseAgentStatusPayload
// already clamps it to `undefined` for non-done states, so writing
// the field through directly preserves truth for done and resets
@ -1394,7 +1419,24 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// stale.
const wasFresh =
!!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS)
const sortRelevantChange = !existing || existing.state !== payload.state || !wasFresh
// Why: main is authoritative on stateStartedAt and only advances it on a
// real turn boundary (state transition or a Command Code new turn). If the
// renderer-local `commandCodeNewTurn` misses it — e.g. a transcript-read
// failure left `existing.promptInteractionKey` undefined so the key-change
// is invisible here — main's reset still arrives via `timing.stateStartedAt`.
// Treat a same-state stateStartedAt advance as sort-relevant so smart sort
// never goes stale. Non-Command-Code agents never advance stateStartedAt
// while the state is unchanged, so this stays effectively CC-scoped.
const sameStateStateStartedAtChanged =
!!existing &&
existing.state === payload.state &&
entry.stateStartedAt !== existing.stateStartedAt
const sortRelevantChange =
!existing ||
existing.state !== payload.state ||
!wasFresh ||
commandCodeNewTurn ||
sameStateStateStartedAtChanged
const doneRetentionFieldsChanged =
existing?.state === 'done' &&
entry.state === 'done' &&

View File

@ -156,6 +156,8 @@ export type AgentStatusEntry = {
/** Provider-owned conversation/session id captured from hook payloads.
* Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */
providerSession?: AgentProviderSessionMetadata
/** Live-only Command Code turn boundary key; not persisted to last-status.json. */
promptInteractionKey?: string
}
export type MigrationUnsupportedPtyEntry = {
@ -222,6 +224,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
stateStartedAt: number
orchestration?: AgentStatusOrchestrationContext
providerSession?: AgentProviderSessionMetadata
/** Live-only Command Code turn boundary key; not persisted to last-status.json. */
promptInteractionKey?: string
}
/** Maximum character length for the toolName field. */

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { isCommandCodeNewTurnWhileWorking } from './command-code-turn-boundary'
describe('isCommandCodeNewTurnWhileWorking', () => {
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)
})
})

View File

@ -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
}