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:
parent
d5627638ab
commit
3c87873dca
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue