diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4580a6e2385..1313b2e5266 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -784,6 +784,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts + tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index ceb75a66bfc..ab23c41d75a 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -159,6 +159,9 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/rpc/methods/session-tabs.ts', 'src/main/runtime/rpc/methods/structured-agent-session', 'src/main/runtime/rpc/methods/terminal', + 'src/main/runtime/runtime-worktree-agent-', + 'src/main/runtime/runtime-worktree-pty-agent-sources', + 'src/shared/runtime-worktree-contracts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' ] diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 06ef8e3879e..094dadf542e 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -350,6 +350,9 @@ describe('per-job path classification', () => { 'src/main/runtime/rpc/methods/structured-agent-session-hold.ts', 'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts', 'src/main/runtime/rpc/methods/terminal.ts', + 'src/main/runtime/runtime-worktree-agent-rows.ts', + 'src/main/runtime/runtime-worktree-pty-agent-sources.ts', + 'src/shared/runtime-worktree-contracts.ts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' ]) { expectClassification([file], { diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 4dff79d6666..b7ce4fa16dd 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -105,8 +105,15 @@ ingests the summary into the hook server as a status row: | `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | | prompt, tool, last message, model, provider session | the summary's fields | -Sessions with no persisted turn (`status === null`) produce no row, matching -what the chat shows. When the host revokes live ownership the row is re-set +Sessions with no request (`status === null`) produce no row. A request is a +turn record, an assistant message, a user message the provider journaled itself +(history, an older host), an accepted or unanswered send, or a send the agent or +its start refused; a send that was withdrawn, or left undelivered by a +restart or a close, fails nobody and makes nothing listable. +`summary.turnOutcome` is the latest request's verdict: its turn's outcome, or +`failure` for a send the agent or its start refused (a send that joined a running +turn is answered by that turn). The row also publishes `interrupted` from +`mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set without the flag; when the host closes or evicts the session the row is dropped. Both already exist as feed events (`revokeLive` and the roster filter in `liveSessionSummaries`); PR 1 turns them into store writes. @@ -218,6 +225,32 @@ sends no hook at all on a cancel and no `is_interrupt` on Stop; that flag on a turn boundary remains a secondary source for builds that send it, and `StopFailure` maps to `failure`. +Readers decode the verdict through one accessor, `agentMainAgentVerdict`, which +reads the main agent's own state, not the combined row's: `mainAgent.outcome` +while `mainAgent.state` is `done`, then the legacy `interrupted` flag as a +cancellation, which alone needs the combined `done`. So a main agent that +failed while its subagents still run has a verdict on a `working` row. Every +copy of a row (state-history entries, sleep records, `worktree ps` rows) takes +the verdict through `agentVerdictFields`, which carries `interrupted` and the +whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees +with the row and can date a failure by `mainAgent.stateStartedAt`. + +Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the +agent failed whatever the combined state, because it is news the user must see +even while subagents run; a stop marks it interrupted only on a `done` row, so +a stopped or finished main agent with live child work still reads working. +Each subagent keeps its own row and state. Container rollups (worktree card, +terminal tab, Cmd+J) rank a pending question first, then a failure, then live +work, then a stop, then done. On the worktree card, a failure retained after its +agent's pane went away has no expiry, so it ranks below live work and above a +stop. Lifecycle waiters keep reading the combined `state`. + +Policy splits the verdict two ways. Clean-finish policy (hibernation, pane +ownership, the star-nag value moment) treats a failure like a cancellation +(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky +retention, Cmd+J Recent) demotes only a turn the user stopped +(`agentTurnStoppedByUser`); a failure ranks like a completion. + Admission is one function, `normalizeAgentStatusPayload`, on the relay wire, IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts send none and readers fall back to `state`. Hook rows persist it inside the diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 1637296d900..80ef9d9d239 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -5,7 +5,7 @@ import type { AgentDotState } from '../worktree/agent-row-display' // Per-agent state indicator, 1:1 with desktop AgentStateDot // (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working', -// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral +// emerald for 'done', red for blocked/waiting/interrupted/failed (attention), neutral // for idle. Distinct from the worktree-level AgentSpinner, which collapses the // agent vocabulary into the 5-state rollup the sidebar dot uses. const DOT_COLORS: Record, string> = { @@ -13,6 +13,7 @@ const DOT_COLORS: Record, strin blocked: '#ef4444', waiting: '#ef4444', interrupted: '#ef4444', + failed: '#ef4444', idle: 'rgba(115,115,115,0.4)' } const WORKING_COLOR = '#eab308' diff --git a/mobile/src/components/WorktreeAgentRow.tsx b/mobile/src/components/WorktreeAgentRow.tsx index 8740d12eaed..cd8e10b3454 100644 --- a/mobile/src/components/WorktreeAgentRow.tsx +++ b/mobile/src/components/WorktreeAgentRow.tsx @@ -2,7 +2,12 @@ import { memo } from 'react' import { StyleSheet, Text, View } from 'react-native' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { colors, spacing } from '../theme/mobile-theme' -import { agentDisplayLabel, agentDotState, formatTimeAgo } from '../worktree/agent-row-display' +import { + agentDisplayLabel, + agentDotState, + agentRowTimeAt, + formatTimeAgo +} from '../worktree/agent-row-display' import { AgentStateDot } from './AgentStateDot' import { MobileAgentIcon } from './MobileAgentIcon' @@ -22,7 +27,7 @@ type Props = { function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) { const dotState = agentDotState(agent, now) const label = agentDisplayLabel(agent, now) - const ts = formatTimeAgo(agent.stateStartedAt, now) + const ts = formatTimeAgo(agentRowTimeAt(agent), now) return ( diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index ee872e40f26..47898017ff8 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -1,13 +1,26 @@ import { describe, expect, it } from 'vitest' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { + agentMainAgentVerdict, + agentVerdictDisplayMark +} from '../../../src/shared/agent-main-agent-verdict' +import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' import { AGENT_STATUS_STALE_AFTER_MS, agentDisplayLabel, agentDotState, agentIdentityLabel, + agentRowTimeAt, + agentRowVerdict, + agentRowVerdictMark, formatTimeAgo } from './agent-row-display' +type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] +const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({ + mainAgent: { state: 'done' as const, outcome, stateStartedAt } +}) + function row(overrides: Partial = {}): RuntimeWorktreeAgentRow { return { paneKey: 'p', @@ -37,8 +50,54 @@ describe('agentDotState', () => { expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle') }) - it('reports interrupted regardless of state', () => { + it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => { expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted') + expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed') + expect( + agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0) + ).toBe('interrupted') + expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done') + }) + + it('shows a main agent that failed while its subagents still run as failed', () => { + expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed') + expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed') + // Only a failure outranks live work; a success or a stop with live subagents reads working. + expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working') + expect( + agentDotState( + row({ state: 'working', ...mainAgentDone('cancellation'), interrupted: true }), + 0 + ) + ).toBe('working') + }) + + // The shared accessor cannot be imported by app code here, so this mirror must not drift from it. + it('agrees with the desktop verdict accessor on every row', () => { + const states = ['working', 'blocked', 'waiting', 'done'] as const + const mainAgents = [ + undefined, + ...states.flatMap((state) => + [undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({ + state, + ...(outcome ? { outcome } : {}), + stateStartedAt: 0 + })) + ) + ] + for (const state of states) { + for (const mainAgent of mainAgents) { + for (const interrupted of [false, true]) { + const agentRow = { state, interrupted, ...(mainAgent ? { mainAgent } : {}) } + expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe( + agentMainAgentVerdict(agentRow) + ) + expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe( + agentVerdictDisplayMark(agentRow) + ) + } + } + } }) it('decays a stale active state to idle, matching desktop', () => { @@ -51,14 +110,36 @@ describe('agentDotState', () => { expect( agentDotState(row({ state: 'working', updatedAt: 0 }), AGENT_STATUS_STALE_AFTER_MS) ).toBe('working') - // 'done' never decays; interrupted still wins. + // 'done' never decays, and neither does its verdict. expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done') - expect(agentDotState(row({ state: 'working', updatedAt: 0, interrupted: true }), stale)).toBe( + expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe( 'interrupted' ) }) }) +describe('agentRowTimeAt', () => { + it('dates a main agent that failed while its subagents run by its own failure', () => { + expect( + agentRowTimeAt( + row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('failure', 900) }) + ) + ).toBe(900) + }) + + it('dates every other row by when its state began', () => { + expect( + agentRowTimeAt( + row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('success', 900) }) + ) + ).toBe(100) + expect( + agentRowTimeAt(row({ state: 'done', stateStartedAt: 100, ...mainAgentDone('failure', 900) })) + ).toBe(100) + expect(agentRowTimeAt(row({ state: 'working', stateStartedAt: 100 }))).toBe(100) + }) +}) + describe('agentDisplayLabel', () => { it('prefers last message, then prompt, then state label', () => { expect(agentDisplayLabel(row({ lastAssistantMessage: 'hello there' }), 0)).toBe('hello there') diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index c678b522a15..da93aa39220 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -1,4 +1,5 @@ import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import type { AgentJournalTurnOutcome } from '../../../src/shared/agent-turn-outcome' // Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts: // 30 min). Defined locally rather than imported because a runtime-value import @@ -17,13 +18,39 @@ export type AgentDotState = | 'done' | 'idle' | 'interrupted' + | 'failed' + +type AgentRowVerdictSource = Pick + +// Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark +// (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is +// the main agent's own status, sent also while subagents hold the row working; an old host sends none. +export function agentRowVerdict(row: AgentRowVerdictSource): AgentJournalTurnOutcome | null { + if (row.mainAgent && row.mainAgent.state !== 'done') { + return null + } + return row.mainAgent?.outcome ?? (row.state === 'done' && row.interrupted ? 'cancellation' : null) +} + +// A failure outranks every state; a stop marks only a row that is itself done. +export function agentRowVerdictMark(row: AgentRowVerdictSource): 'failed' | 'interrupted' | null { + const verdict = agentRowVerdict(row) + if (verdict === 'failure') { + return 'failed' + } + return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null +} export function agentDotState( - row: Pick, + row: Pick< + RuntimeWorktreeAgentRow, + 'state' | 'workingMode' | 'interrupted' | 'mainAgent' | 'updatedAt' + >, now: number ): AgentDotState { - if (row.interrupted) { - return 'interrupted' + const mark = agentRowVerdictMark(row) + if (mark) { + return mark } switch (row.state) { case 'blocked': @@ -56,6 +83,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Waiting for input' case 'interrupted': return 'Interrupted' + case 'failed': + return 'Failed' case 'done': return 'Done' case 'idle': @@ -99,6 +128,17 @@ export function agentIdentityLabel(agentType: string | null): string { return known[normalized] ?? normalized.slice(0, 2).toUpperCase() } +// When the row's state began, except that a main agent that failed while its subagents run is +// dated by its own failure. Mirrors desktop lastEnteredDoneAt (agent-finished-timestamp.ts). +export function agentRowTimeAt( + row: Pick +): number { + if (row.state !== 'done' && row.mainAgent && agentRowVerdictMark(row) === 'failed') { + return row.mainAgent.stateStartedAt + } + return row.stateStartedAt +} + // Relative time, matching desktop formatTimeAgo thresholds (just now / Xm / Xh / Xd). export function formatTimeAgo(ts: number, now: number): string { const delta = now - ts diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index c7c5756160f..25c9ecbe852 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -21,6 +21,13 @@ function agent(overrides: Partial = {}): RuntimeWorktre } } +function done( + outcome: 'success' | 'failure', + stateStartedAt = 1 +): NonNullable { + return { state: 'done', outcome, stateStartedAt } +} + function worktree(overrides: Partial = {}): Worktree { const worktreePath = join('/tmp', 'orca', 'worktrees', 'manta') return { @@ -160,6 +167,31 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(first, second)).toBe(false) }) + it('detects a verdict change that leaves the interrupted flag as it was', () => { + const first = [worktree({ agents: [agent({ state: 'done', mainAgent: done('success') })] })] + const second = [worktree({ agents: [agent({ state: 'done', mainAgent: done('failure') })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects a main agent failing while its subagents keep the row working', () => { + const first = [worktree({ agents: [agent({ state: 'working' })] })] + const second = [worktree({ agents: [agent({ state: 'working', mainAgent: done('failure') })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects the main agent clock moving, which dates a failure', () => { + const at = (stateStartedAt: number) => [ + worktree({ + agents: [agent({ state: 'working', mainAgent: done('failure', stateStartedAt) })] + }) + ] + + expect(areWorktreeListsEqual(at(1), at(2))).toBe(false) + expect(areWorktreeListsEqual(at(1), at(1))).toBe(true) + }) + it('detects monitoring mode changes within working', () => { const first = [worktree({ agents: [agent({ state: 'working' })] })] const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index 8fa27c2a197..250c871493f 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -110,6 +110,7 @@ function areAgentRowsEqual( a.toolName !== b.toolName || a.toolInput !== b.toolInput || a.interrupted !== b.interrupted || + !areMainAgentsEqual(a.mainAgent, b.mainAgent) || a.stateStartedAt !== b.stateStartedAt || a.updatedAt !== b.updatedAt ) { @@ -118,3 +119,20 @@ function areAgentRowsEqual( } return true } + +function areMainAgentsEqual( + left: RuntimeWorktreeAgentRow['mainAgent'], + right: RuntimeWorktreeAgentRow['mainAgent'] +): boolean { + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.state === right.state && + left.outcome === right.outcome && + left.stateStartedAt === right.stateStartedAt + ) +} diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 5a5e2ce5ecf..19ee70e88af 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -11,7 +11,8 @@ import { } from '../../../shared/structured-agent-session-projection' import { continueMainAgentStatus, - isAgentStatusHeldOpenByChildWork + isAgentStatusHeldOpenByChildWork, + mainAgentTurnInterrupted } from '../../../shared/agent-lead-status-fold' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { @@ -73,6 +74,8 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng state, ...(workingMode ? { workingMode } : {}), mainAgent, + // Readers that predate `mainAgent` read a cancellation off this flag, as the hook lanes publish it. + interrupted: mainAgentTurnInterrupted(mainAgent), prompt: summary.latestPrompt, agentType: summary.agent, ...(summary.model ? { model: summary.model } : {}), diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index a19f6044a46..24d43545e13 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -76,7 +76,10 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s if (args.agentState === 'working') { return translateMain('notifications.agentStatus.working', 'working') } - return args.agentState === 'done' && args.agentInterrupted + if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') { + return translateMain('notifications.agentStatus.failed', 'failed') + } + return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation' ? translateMain('notifications.agentStatus.stopped', 'stopped') : translateMain('notifications.agentStatus.finished', 'finished') } @@ -104,7 +107,7 @@ function hasAgentNotificationSnapshot(args: NotificationDispatchRequest): boolea args.agentToolName || args.agentToolInput || args.agentLastAssistantMessage || - args.agentInterrupted + args.agentTurnOutcome !== undefined ) } diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index abf41bfae3c..3d519293f3c 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -217,7 +217,7 @@ describe('registerNotificationHandlers', () => { worktreeLabel: 'feat/notis', agentType: 'claude', agentState: 'done', - agentInterrupted: true, + agentTurnOutcome: 'cancellation', agentLastAssistantMessage: 'Stopped by user.' } ) @@ -316,7 +316,12 @@ describe('registerNotificationHandlers', () => { ) }) - it('reports an interrupted finish as stopped', async () => { + it.each([ + { agentTurnOutcome: 'cancellation', word: 'stopped' }, + { agentTurnOutcome: 'failure', word: 'failed' }, + { agentTurnOutcome: 'success', word: 'finished' }, + { agentTurnOutcome: undefined, word: 'finished' } + ] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -336,14 +341,40 @@ describe('registerNotificationHandlers', () => { worktreeLabel: 'feat/notis', agentType: 'claude', agentState: 'done', - agentInterrupted: true + ...(agentTurnOutcome ? { agentTurnOutcome } : {}) } ) expect(notificationCtorMock).toHaveBeenCalledWith( expectedNativeNotificationOptions({ - title: 'feat/notis - Claude stopped', - body: 'Claude stopped.' + title: `feat/notis - Claude ${word}`, + body: `Claude ${word}.` + }) + ) + }) + + it('counts a success verdict alone as an agent snapshot', async () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + await handler( + {}, + { source: 'agent-task-complete', worktreeLabel: 'feat/notis', agentTurnOutcome: 'success' } + ) + + expect(notificationCtorMock).toHaveBeenCalledWith( + expectedNativeNotificationOptions({ + title: 'feat/notis - Agent finished', + body: 'Agent finished.' }) ) }) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 16999de0363..4647ba1e0e3 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -18,6 +18,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' export type ProviderHistoryItem = { /** The provider's own id for this item. Used to claim it at most once; the @@ -55,7 +56,7 @@ export type SubmissionReconciliation = | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } -export type SubmissionRejectionReason = 'not_delivered' +export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED export type SubmissionUnknownReason = | 'history_boundary_inconsistent' @@ -197,6 +198,6 @@ function resolveOne( return { clientMessageId: submission.clientMessageId, outcome: 'rejected', - reason: 'not_delivered' + reason: DISPATCH_REJECTED_NOT_DELIVERED } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 186127847a6..b9e1b49c213 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -8,7 +8,11 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentSessionSubscribeEvent, + AgentSessionTurnCompletionEvent +} from '../../../shared/agent-session-wire' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_HOST_RESTARTED, @@ -318,6 +322,28 @@ describe('a start the chat needed and did not get', () => { expect(errorRows()).toHaveLength(1) }) + it('notifies failed once for the queued messages one start failure refused', async () => { + await host.close(SESSION) + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const completions: AgentSessionTurnCompletionEvent[] = [] + host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) + await accept('first') + const second = await accept('second') + + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + await host.flushAllStreamedEvents() + expect(completions).toEqual([ + { + type: 'completion', + completion: expect.objectContaining({ + sessionId: SESSION, + turnId: agentJournalSubmissionKey(second), + outcome: 'failure' + }) + } + ]) + }) + it.each([ [ 'eligibility', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 4e15b23d4d3..4e6390c46d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -8,6 +8,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -117,6 +118,19 @@ describe('attachJournal restart reconciliation', () => { expect(dispatch).not.toHaveBeenCalled() }) + it('gives a send the provider never received no verdict and no listing', async () => { + await crashedJournal() + const { adapter } = adapterWith(async () => window()) + + const attached = await attach(adapter) + + // Nobody failed: the crash stranded it, so the chat must not read Failed or be listed by it. + const { items, submissions } = attached.journal.snapshot() + expect( + projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence) + ).toMatchObject({ summary: { status: null }, latestRequest: null }) + }) + it('still reports a submission unconfirmed when the window cannot decide it', async () => { await crashedJournal() const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index aa3f298a077..24ab5cad012 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -31,7 +31,11 @@ export class StructuredAgentSessionClientDelivery { private readonly onJournalActivity?: (sessionId: string) => void ) { this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps }) - this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now }) + this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now, + readStatusState: (sessionId, journal) => this.statusFeed.statusState(sessionId, journal) + }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.requireJournal(sessionId) ) @@ -81,7 +85,8 @@ export class StructuredAgentSessionClientDelivery { this.statusFeed.publish(sessionId, journal) this.sendSettlement.publish(sessionId, journal) // Derived here rather than per-subscriber: this edge runs whether or not anyone is - // subscribed, which is the whole reason a backgrounded chat can complete at all. + // subscribed, which is the whole reason a backgrounded chat can complete at all. After the + // status publish, so it reads the projection that publish cached. this.turnCompletionFeed.observe(sessionId, journal) this.onJournalActivity?.(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 72a908a3361..a2be1610327 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -88,7 +88,11 @@ async function sessionWithRunningTurn() { } } }) - const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock }) + const completions = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now: () => clock, + readStatusState: (sessionId, source) => feed.statusState(sessionId, source) + }) const completionEvents: AgentSessionTurnCompletionEvent[] = [] completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) }) // Both feeds have seen the turn running, so its settlement is a transition they must judge. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 884fba78a4e..524d8943fac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -95,7 +95,12 @@ async function restartWithPersistedTurn(): Promise { const host = createHost(store) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') - await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + if (!sent.ok) { + throw new Error('send was refused') + } + // Delivered, not just accepted: a message still queued at the restart was never a request. + await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) await host.flushAllStreamedEvents() return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index e5da2eece97..a26cd324217 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -191,7 +191,8 @@ describe('StructuredAgentSessionStatusFeed', () => { expect(events.at(-1)).toMatchObject({ session: { status: 'working' } }) record.lease.runtimeFence = 2 feed.publish(SESSION) - expect(events.at(-1)).toMatchObject({ session: { status: 'idle' } }) + // Its only send outlived the host that sent it and became no turn: nothing left to list. + expect(events.at(-1)).toMatchObject({ session: { status: null } }) }) it('publishes working from the pending submission, before the provider replays the turn', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 6e572f0366d..c301cb32233 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -22,7 +22,7 @@ import { type AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' -import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' @@ -34,6 +34,10 @@ import { export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership' +export type StructuredAgentSessionStatusState = ReturnType< + typeof projectStructuredAgentSessionStatusState +> + export type StructuredAgentSessionStatusSubscriber = { id: string emit: (event: AgentSessionStatusEvent) => void @@ -142,7 +146,7 @@ export class StructuredAgentSessionStatusFeed { sequence: number readOnly: boolean fence: number | undefined - summary: ReturnType + state: StructuredAgentSessionStatusState } >() @@ -204,6 +208,17 @@ export class StructuredAgentSessionStatusFeed { }) } + /** The projection behind the session's row and the latest request it read, cached per commit, + * so the completion feed follows the same request without snapshotting the journal again. */ + statusState( + sessionId: string, + journal?: AgentSessionJournal + ): StructuredAgentSessionStatusState | null { + const session = this.deps.sessions.get(sessionId) + const source = journal ?? session?.journal + return source ? this.projectionFor(source, this.deps.getRecord(sessionId)) : null + } + /** Re-projects one session after its journal changed; equal projections are not re-sent. */ publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void { const session = this.deps.sessions.get(sessionId) @@ -234,35 +249,8 @@ export class StructuredAgentSessionStatusFeed { session: StatusFeedSession, journal: AgentSessionJournal ): AgentSessionStatusSummary { - // An unreadable journal projects as "no turn": the chat itself shows the reset. - const cursor = journal.cursor() - const readOnly = journal.isReadOnly const record = this.deps.getRecord(sessionId) - // The conversation's fence, which a child's end moves: its unanswered sends stop counting. - const fence = record?.lease.runtimeFence - let projection = this.journalProjections.get(journal) - if ( - !projection || - projection.epoch !== cursor.epoch || - projection.sequence !== cursor.sequence || - projection.readOnly !== readOnly || - projection.fence !== fence - ) { - // A journalled submission bumps `lastSequence`, so the send-time working - // signal reaches the cache; the lease fence does not, hence the extra key. - const snapshot = readOnly ? null : journal.snapshot() - projection = { - ...cursor, - readOnly, - fence, - summary: projectStructuredAgentSessionStatusSummary( - snapshot?.items ?? [], - snapshot?.submissions ?? [], - fence - ) - } - this.journalProjections.set(journal, projection) - } + const { summary: projected } = this.projectionFor(journal, record) const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. @@ -280,7 +268,7 @@ export class StructuredAgentSessionStatusFeed { ...(session.child ? { hostExecutionOwned: true as const, hostExecutionPhase: session.child.phase } : {}), - ...projection.summary, + ...projected, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), @@ -304,6 +292,41 @@ export class StructuredAgentSessionStatusFeed { } } + private projectionFor( + journal: AgentSessionJournal, + record: AgentSessionRecord | null + ): StructuredAgentSessionStatusState { + // An unreadable journal projects as "no turn": the chat itself shows the reset. + const cursor = journal.cursor() + const readOnly = journal.isReadOnly + // The conversation's fence, which a child's end moves: its unanswered sends stop counting. + const fence = record?.lease.runtimeFence + let projection = this.journalProjections.get(journal) + if ( + !projection || + projection.epoch !== cursor.epoch || + projection.sequence !== cursor.sequence || + projection.readOnly !== readOnly || + projection.fence !== fence + ) { + // A journalled submission bumps `lastSequence`, so the send-time working + // signal reaches the cache; the lease fence does not, hence the extra key. + const snapshot = readOnly ? null : journal.snapshot() + projection = { + ...cursor, + readOnly, + fence, + state: projectStructuredAgentSessionStatusState( + snapshot?.items ?? [], + snapshot?.submissions ?? [], + fence + ) + } + this.journalProjections.set(journal, projection) + } + return projection.state + } + /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index c0e652724af..93d4a54cdc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_NOT_DELIVERED, + DISPATCH_REJECTED_PROVIDER_CLOSED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' const LOCATION = { @@ -10,6 +22,8 @@ const LOCATION = { workspaceKind: 'git-worktree' } as const +const START_FAILURE = 'Claude is not signed in.' + function turn( turnId: string, state: AgentJournalTurnLifecycle['state'], @@ -18,33 +32,102 @@ function turn( return { turnId, state, ...(outcome ? { outcome } : {}) } } +function turnItem(lifecycle: AgentJournalTurnLifecycle, sequence: number): AgentJournalRenderItem { + return { + itemId: `codex:turn:${lifecycle.turnId}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'turn', ...lifecycle } + } +} + +function userEntry(clientMessageId: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(clientMessageId), + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + } +} + +function sent( + clientMessageId: string, + fields: Partial & Pick +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 20, + handoverRecorded: true, + ...fields + } +} + +const pending = (clientMessageId: string, fence = 1) => + sent(clientMessageId, { dispatchState: 'pending', fence, handedOverAt: 11, resolvedAt: null }) +const refused = (clientMessageId: string, reason = START_FAILURE) => + sent(clientMessageId, { dispatchState: 'rejected', reason }) + function harness(): { feed: StructuredAgentSessionTurnCompletionFeed setTurn: (next: AgentJournalTurnLifecycle | null) => void + setJournal: ( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + fence?: number + ) => void setCursor: (next: { epoch: string; sequence: number }) => void observe: () => void events: AgentSessionTurnCompletionEvent[] + outcomes: () => [string, string][] + /** Whether each completion said the user is being asked something. */ + awaitingUser: () => boolean[] listen: () => () => void } { - let current: AgentJournalTurnLifecycle | null = null + let items: AgentJournalRenderItem[] = [] + let submissions: AgentJournalSubmission[] = [] + let fence: number | undefined let cursor = { epoch: 'epoch-1', sequence: 0 } - const journal = { - newestTurn: () => current, - cursor: () => cursor - } + const journal = { cursor: () => cursor } const sessions = new Map([['session-1', { journal, params: { location: LOCATION } }]]) - const feed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => 1_700 }) + const feed = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now: () => 1_700, + // The status feed's projection, computed as it computes it. + readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence) + }) const events: AgentSessionTurnCompletionEvent[] = [] return { feed, setTurn: (next) => { - current = next + items = next ? [turnItem(next, 1)] : [] + submissions = [] + }, + setJournal: (nextItems, nextSubmissions, nextFence) => { + items = nextItems + submissions = nextSubmissions + fence = nextFence + cursor = { ...cursor, sequence: cursor.sequence + 1 } }, setCursor: (next) => { cursor = next }, observe: () => feed.observe('session-1'), events, + outcomes: () => + events.flatMap((event): [string, string][] => + event.type === 'completion' ? [[event.completion.turnId, event.completion.outcome]] : [] + ), + awaitingUser: () => + events.flatMap((event) => + event.type === 'completion' ? [event.completion.awaitingUser === true] : [] + ), listen: () => feed.subscribe({ id: 'sub', emit: (event) => events.push(event) }) } } @@ -59,7 +142,8 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { h.setTurn(turn('turn-1', 'completed', 'success')) h.setCursor({ epoch: 'epoch-1', sequence: 2 }) h.observe() - expect(h.events).toEqual([ + // Strict: an idle settle omits `awaitingUser` rather than sending it undefined. + expect(h.events).toStrictEqual([ { type: 'completion', completion: { @@ -261,3 +345,318 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { expect(emit).not.toHaveBeenCalled() }) }) + +describe('a request the agent or its start refused', () => { + const M1 = agentJournalSubmissionKey('m1') + const M2 = agentJournalSubmissionKey('m2') + const M3 = agentJournalSubmissionKey('m3') + const settledTurn = turnItem(turn('t1', 'completed', 'success'), 2) + + /** A session whose first turn succeeded, as the feed saw it happen. */ + function afterSuccessfulTurn() { + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1), turnItem(turn('t1', 'running'), 2)], []) + h.observe() + h.setJournal([userEntry('m1', 1), settledTurn], [sent('m1', { dispatchState: 'accepted' })]) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + return h + } + + it('notifies failed once when the only send fails to start, named by its item key', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [pending('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.observe() + expect(h.events).toEqual([ + { + type: 'completion', + completion: { + scope: LOCATION, + sessionId: 'session-1', + turnId: M1, + outcome: 'failure', + completedAt: 1_700 + } + } + ]) + }) + + it('stays silent on a first observation of a send that had already failed', () => { + // A restart, reopen or re-attach: the failure is history, not news. + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.observe() + expect(h.events).toEqual([]) + }) + + it('re-baselines an epoch replacement that surfaces an older failure', () => { + const h = afterSuccessfulTurn() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.setCursor({ epoch: 'epoch-2', sequence: 1 }) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it.each([ + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_NOT_DELIVERED + ])('never notifies a send %s, alone or after a turn', (reason) => { + const alone = harness() + alone.listen() + alone.observe() + alone.setJournal([userEntry('m1', 1)], [pending('m1')]) + alone.observe() + alone.setJournal([userEntry('m1', 1)], [refused('m1', reason)]) + alone.observe() + expect(alone.events).toEqual([]) + + // The latest request falls back to the turn already announced, which must not announce again. + const h = afterSuccessfulTurn() + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal([userEntry('m1', 1), settledTurn, userEntry('m2', 3)], [accepted, pending('m2')]) + h.observe() + h.setJournal( + [userEntry('m1', 1), settledTurn, userEntry('m2', 3)], + [accepted, refused('m2', reason)] + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('never notifies a crash-stranded send that restart reconciliation finds undelivered', () => { + const h = afterSuccessfulTurn() + const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(items, [accepted, sent('m2', { dispatchState: 'unknown', recovered: true })], 2) + h.observe() + h.setJournal( + items, + [ + accepted, + sent('m2', { + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_NOT_DELIVERED, + fence: 2, + recovered: true + }) + ], + 2 + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('notifies failed, then success, when a failed start is retried and the retry succeeds', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')]) + h.observe() + const accepted = sent('m2', { dispatchState: 'accepted' }) + h.setJournal( + [userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'running'), 3)], + [refused('m1'), accepted] + ) + h.observe() + h.setJournal( + [userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'completed', 'success'), 3)], + [refused('m1'), accepted] + ) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + ['t2', 'success'] + ]) + }) + + it('notifies each failed start that follows another', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), refused('m2')]) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + [M2, 'failure'] + ]) + }) + + it.each([ + ['in one commit', [['m2', 'm3']]], + ['oldest first, across commits', [['m2'], ['m3']]], + ['newest first, across commits', [['m3'], ['m2']]] + ])('notifies once for queued sends one start failure refused %s', (_name, batches) => { + const h = afterSuccessfulTurn() + const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3), userEntry('m3', 4)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + const queued = (id: string) => sent(id, { dispatchState: 'pending', resolvedAt: null }) + const answered = new Set() + const submissions = () => [ + accepted, + ...['m2', 'm3'].map((id) => (answered.has(id) ? refused(id) : queued(id))) + ] + h.setJournal(items, submissions()) + h.observe() + for (const batch of batches) { + batch.forEach((id) => answered.add(id)) + h.setJournal(items, submissions()) + h.observe() + } + expect(h.outcomes()).toEqual([ + ['t1', 'success'], + [M3, 'failure'] + ]) + }) + + it('does not wait on a send left pending at an older fence', () => { + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), pending('m2', 2)], 2) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), refused('m2')], 2) + h.observe() + expect(h.outcomes()).toEqual([[M2, 'failure']]) + }) +}) + +describe('a request that settles while the user is asked something', () => { + const M1 = agentJournalSubmissionKey('m1') + + /** An approval the user has not answered; `agentId` makes it a subagent's. */ + function approval( + itemId: string, + sequence: number, + state: 'pending' | 'resolved', + agentId?: string + ): AgentJournalRenderItem { + return { + itemId, + revision: state === 'pending' ? 1 : 2, + sequence, + observedAt: sequence, + ...(agentId ? { agentId } : {}), + body: { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } + } + + it('notifies once when the main turn settles while a subagent waits on an approval', () => { + const h = harness() + h.listen() + const user = userEntry('m1', 1) + const accepted = [sent('m1', { dispatchState: 'accepted' })] + h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted) + h.observe() + h.setJournal( + [user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending', 'child-1')], + accepted + ) + h.observe() + h.setJournal( + [ + user, + turnItem(turn('t1', 'completed', 'success'), 2), + approval('a1', 3, 'pending', 'child-1') + ], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + expect(h.awaitingUser()).toEqual([true]) + + // Answering the prompt settles the session idle on the request already announced. + h.setJournal( + [ + user, + turnItem(turn('t1', 'completed', 'success'), 2), + approval('a1', 3, 'resolved', 'child-1') + ], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('notifies a refused send once while a prompt is pending', () => { + const h = harness() + h.listen() + const prompt = approval('a1', 1, 'pending', 'child-1') + h.setJournal([prompt, userEntry('m1', 2)], [pending('m1')]) + h.observe() + h.setJournal([prompt, userEntry('m1', 2)], [refused('m1')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + expect(h.awaitingUser()).toEqual([true]) + h.setJournal([approval('a1', 1, 'resolved', 'child-1'), userEntry('m1', 2)], [refused('m1')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + }) + + it('sends nothing while the main turn asks for permission, and one event when it settles', () => { + const h = harness() + h.listen() + const user = userEntry('m1', 1) + const accepted = [sent('m1', { dispatchState: 'accepted' })] + h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted) + h.observe() + h.setJournal([user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending')], accepted) + h.observe() + expect(h.events).toEqual([]) + h.setJournal( + [user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'resolved')], + accepted + ) + h.observe() + h.setJournal( + [user, turnItem(turn('t1', 'completed', 'success'), 2), approval('a1', 3, 'resolved')], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + // Idle when it settles: the prompt was already answered. + expect(h.awaitingUser()).toEqual([false]) + }) + + it('still waits on a queued send the prompt hides, so the queue notifies once', () => { + const h = harness() + h.listen() + const prompt = approval('a1', 3, 'pending', 'child-1') + const items = [userEntry('m1', 1), turnItem(turn('t1', 'running'), 2), prompt] + const queued = sent('m2', { dispatchState: 'pending', resolvedAt: null }) + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal([...items, userEntry('m2', 4)], [accepted, queued]) + h.observe() + h.setJournal( + [ + userEntry('m1', 1), + turnItem(turn('t1', 'completed', 'success'), 2), + prompt, + userEntry('m2', 4) + ], + [accepted, queued] + ) + h.observe() + expect(h.events).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index 1281fbec49b..ca9f64c0573 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -1,4 +1,5 @@ -// The host's answer to "a turn just finished", derived once per journal commit. +// The host's answer to "a request just finished", derived once per journal commit. A request is +// the one the status row reports: a turn, or a send the agent or its start refused. // // WHY THE HOST DERIVES IT: a structured session runs on the execution host and keeps journalling // whether or not any renderer has a reader mounted. A client that derived completions itself would @@ -10,41 +11,45 @@ // needs, a completion is an edge that has already passed. Keeping a queue would create a durable // obligation with nothing to retire it. -import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { readAgentJournalTurnOutcome } from '../../../shared/agent-session-turn-record' import type { AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionLatestRequest } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed' export type StructuredAgentSessionTurnCompletionSubscriber = { id: string emit: (event: AgentSessionTurnCompletionEvent) => void } -/** Only the newest-turn reader and cursor are needed here; asking for the whole journal would overstate it. */ type CompletionFeedCursor = { epoch: string; sequence: number } -type CompletionFeedJournal = Pick - type CompletionFeedSession = { - journal: CompletionFeedJournal + journal: Pick params: { location: AgentSessionRecord['location'] } } export type StructuredAgentSessionTurnCompletionFeedDeps = { sessions: ReadonlyMap now: () => number + /** The status feed's projection for this commit, so the event follows the request its row reports. */ + readStatusState: ( + sessionId: string, + journal?: AgentSessionJournal + ) => StructuredAgentSessionStatusState | null } -/** Per-session baseline. `settledTurnId` is the last settled turn this feed has accounted for; - * absence of the whole entry — not a null field — is what makes the first observation silent. */ -type SessionBaseline = CompletionFeedCursor & { settledTurnId: string | null } +type RequestMark = Pick -function isSettled(turn: AgentJournalTurnLifecycle | null): turn is AgentJournalTurnLifecycle { - return turn !== null && turn.state !== 'running' +/** Per-session baseline. `settled` is the last settled request this feed has accounted for; + * absence of the whole entry — not a null field — is what makes the first observation silent. */ +type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } + +function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { + return request && !request.running ? { kind: request.kind, id: request.id } : null } export class StructuredAgentSessionTurnCompletionFeed { @@ -80,29 +85,24 @@ export class StructuredAgentSessionTurnCompletionFeed { /** * One journal publication. Emits at most one completion, and only on the transition into a - * settled turn this feed has not already accounted for. + * settled request this feed has not already accounted for. * * The first observation of a session only records where it is, so restore, restart, rewind and - * a re-read of history all pass through silently. An already-settled turn republished by an - * in-place revision carries the same turn id and so cannot fire twice. + * a re-read of history all pass through silently. An already-settled request republished by an + * in-place revision carries the same identity and so cannot fire twice. */ - observe(sessionId: string, journal?: CompletionFeedJournal): void { + observe(sessionId: string, journal?: AgentSessionJournal): void { const session = this.deps.sessions.get(sessionId) - if (!session) { + const state = session ? this.deps.readStatusState(sessionId, journal) : null + if (!session || !state) { return } - const source = journal ?? session.journal - const cursor = source.cursor() - const turn = source.newestTurn() - const settled = isSettled(turn) ? turn : null + const cursor = (journal ?? session.journal).cursor() + const request = state.latestRequest const baseline = this.baselines.get(sessionId) if (!baseline) { // Baseline only. Whatever the session was already holding is history, not news. - this.baselines.set(sessionId, { - epoch: cursor.epoch, - sequence: cursor.sequence, - settledTurnId: settled?.turnId ?? null - }) + this.baselines.set(sessionId, { ...cursor, settled: settledMark(request) }) return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { @@ -111,24 +111,31 @@ export class StructuredAgentSessionTurnCompletionFeed { // newest settled row as a fresh completion. baseline.epoch = cursor.epoch baseline.sequence = cursor.sequence - baseline.settledTurnId = settled?.turnId ?? null + baseline.settled = settledMark(request) return } baseline.sequence = cursor.sequence - if (!settled) { + if (request?.running) { // A running turn clears the mark, so this detector fires on each running → settled // transition rather than on an id it happens not to have seen. - baseline.settledTurnId = null + baseline.settled = null return } - if (baseline.settledTurnId === settled.turnId) { + // Owed work waits, so sends refused one commit at a time announce once, when the last is + // answered. A pending prompt does not wait (structured chat has no other attention producer): + // the event says so itself, and answering it keeps the same identity. + // A withdrawn send leaves the older request latest. + if ( + state.owesWork || + !request || + (baseline.settled?.kind === request.kind && baseline.settled.id === request.id) + ) { return } - baseline.settledTurnId = settled.turnId + baseline.settled = settledMark(request) // ABSENT OUTCOME IS UNKNOWN: a turn the host only saw stop carries no verdict and gets no // event. Inferring success here is the one mistake that would light the dot on a failure. - const outcome = readAgentJournalTurnOutcome(settled) - if (!outcome) { + if (!request.outcome) { return } this.broadcast({ @@ -136,9 +143,11 @@ export class StructuredAgentSessionTurnCompletionFeed { completion: { scope: session.params.location, sessionId, - turnId: settled.turnId, - outcome, - completedAt: this.deps.now() + turnId: request.id, + outcome: request.outcome, + completedAt: this.deps.now(), + // Stated here, not joined from the status stream: remote clients receive the two unordered. + ...(state.summary.status === 'attention' ? { awaitingUser: true } : {}) } }) } diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts new file mode 100644 index 00000000000..75cbf2e6231 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -0,0 +1,213 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../shared/agent-session-wire' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +// A request that failed reads as failed on every surface the host feeds: the journal's verdict +// travels the real feed, the status-store ingest and `worktree ps`, never just the projection. +const SESSION = 'verdict-session' +const WORKSPACE_ID = 'workspace-1' +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + SESSION +) +const TURN_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + _internals.resetCachesForTests() + root = await mkdtemp(join(tmpdir(), 'orca-verdict-rows-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function openJournal(): Promise { + return journals.open({ + identity: { + sessionId: SESSION, + workspaceId: WORKSPACE_ID, + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) +} + +/** What the host's real feed publishes for this journal. */ +function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSummary { + const session = indexedStatusFeedSession({ journal }) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, session]]), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list', emit: (event) => events.push(event) }) + const snapshot = events.find((event) => event.type === 'snapshot') + const summary = snapshot?.type === 'snapshot' ? snapshot.sessions[0] : undefined + if (!summary) { + throw new Error('the feed published no session') + } + return summary +} + +function ingest(summary: AgentSessionStatusSummary) { + const store = new AgentHookServer() + store.ingestStructuredStatus(summary, SUBJECT) + const hookSnapshots = store.getStatusSnapshot() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attaching agent rows reads and writes only `worktreeId`, `status`, `hasHostSidebarActivity` and `agents`. + const row = { + worktreeId: WORKSPACE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } as unknown as RuntimeWorktreePsSummary + attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKSPACE_ID, row]]), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `getSummary` below resolves every row by id, so the path index is never read. + pathIndex: { byPath: new Map(), byRealPath: new Map() } as never, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, + hookSnapshots + }), + orchestrationByPaneKey: null, + getSummary: (map, _p, _m, id) => map.get(id) ?? null + }) + return { status: hookSnapshots[0], ps: row.agents[0] } +} + +describe('a request that failed reads as failed through the feed, the ingest and worktree ps', () => { + it('reads a chat whose only send the agent start refused as failed, not interrupted', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'first', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.rejectQueuedSubmissions(1, 'Claude is not signed in.') + + const summary = publishedSummary(journal) + expect(summary).toMatchObject({ status: 'idle', turnOutcome: 'failure', latestPrompt: 'hello' }) + const { status, ps } = ingest(summary) + expect(status).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' } + }) + expect(status?.interrupted).not.toBe(true) + expect(ps).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' }, + interrupted: false + }) + }) + + it('reads a cancelled structured turn as interrupted for readers that predate the verdict', async () => { + const journal = await openJournal() + await journal.appendItem( + TURN_IDENTITY, + { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + completedAt: 5 + }, + { fence: 1 } + ) + + const { status, ps } = ingest(publishedSummary(journal)) + expect(status).toMatchObject({ state: 'done', interrupted: true }) + expect(ps).toMatchObject({ + mainAgent: { state: 'done', outcome: 'cancellation' }, + interrupted: true + }) + }) + + it('publishes a main agent that failed while its subagent runs, on the row that still works', async () => { + const journal = await openJournal() + await journal.appendItem( + TURN_IDENTITY, + { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, + { fence: 1 } + ) + const summary: AgentSessionStatusSummary = { + ...publishedSummary(journal), + backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] + } + + const { status, ps } = ingest(summary) + expect(status).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'failure' } + }) + // The row carries the main agent's own clock, which dates the failure apart from the working row. + expect(ps).toMatchObject({ + state: 'working', + mainAgent: { + state: 'done', + outcome: 'failure', + stateStartedAt: status?.mainAgent?.stateStartedAt + }, + interrupted: false + }) + }) + + it('lists nothing for a chat whose only send the user withdrew', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'first', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.rejectQueuedSubmissions(1, 'provider_cancelled_before_start') + + expect(publishedSummary(journal)).toMatchObject({ status: null }) + expect(ingest(publishedSummary(journal)).ps).toBeUndefined() + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 20c17f9b01a..310b0f6bc52 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -60,6 +60,7 @@ export function attachRuntimeWorktreeAgentRows(args: { toolName: source.toolName, toolInput: source.toolInput, interrupted: source.interrupted, + ...(source.mainAgent ? { mainAgent: source.mainAgent } : {}), stateStartedAt: source.stateStartedAt, updatedAt: source.updatedAt, ...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}) diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 984f20e0955..fd98d7914b1 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -1,5 +1,6 @@ import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import type { AgentMainAgentStatus } from '../../shared/main-agent-status' export type RuntimeWorktreeAgentSource = { paneKey: string @@ -15,6 +16,7 @@ export type RuntimeWorktreeAgentSource = { toolName: string | null toolInput: string | null interrupted: boolean + mainAgent?: AgentMainAgentStatus stateStartedAt: number updatedAt: number /** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */ diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 058d378df11..e03f458134f 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -4,6 +4,7 @@ import { type ParsedAgentStatusPayload } from '../../shared/agent-status-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { agentVerdictFields } from '../../shared/agent-main-agent-verdict' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' @@ -47,7 +48,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { lastAssistantMessage: entry.lastAssistantMessage ?? null, toolName: entry.toolName ?? null, toolInput: entry.toolInput ?? null, - interrupted: entry.interrupted ?? false, + interrupted: false, + ...agentVerdictFields(entry), stateStartedAt: entry.stateStartedAt, // A replay advances delivery order, not the age of the evidence shown by worktree.ps. updatedAt: entry.evidenceObservedAt ?? entry.receivedAt, diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts index e0cee5aa9d8..79191ce3252 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts @@ -61,6 +61,7 @@ import { isClearableActivityThread, planClearCompletedActivity } from './activity-clear-completed' +import { activityThreadStatusId } from './activity-thread-presentation' function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { return { @@ -81,7 +82,7 @@ function makeThread(paneKey: string, overrides: Partial = {}): } } -function doneEvent(interrupted: boolean): ActivityEvent { +function doneEvent(interrupted: boolean, outcome?: 'failure'): ActivityEvent { return { id: 'evt', state: 'done', @@ -89,7 +90,16 @@ function doneEvent(interrupted: boolean): ActivityEvent { observedAt: 5_000, worktree: makeWorktree(), repo: null, - entry: { interrupted } as ActivityEvent['entry'], + entry: { + paneKey: 'evt-pane', + state: 'done', + prompt: '', + updatedAt: 5_000, + stateStartedAt: 5_000, + stateHistory: [], + interrupted, + ...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 5_000 } } : {}) + }, tab: makeTab(), agentType: 'claude', agentAlive: false, @@ -102,6 +112,7 @@ const blockedThread = makeThread('t-blocked:1', { currentAgentState: 'blocked' } const waitingThread = makeThread('t-waiting:1', { currentAgentState: 'waiting' }) const doneThread = makeThread('t-done:1', { latestEvent: doneEvent(false) }) const interruptedThread = makeThread('t-interrupted:1', { latestEvent: doneEvent(true) }) +const failedThread = makeThread('t-failed:1', { latestEvent: doneEvent(false, 'failure') }) function makeRetained(paneKey: string): RetainedAgentEntry { return { @@ -125,10 +136,34 @@ describe('isClearableActivityThread', () => { it('clears only completed and interrupted threads', () => { expect(isClearableActivityThread(doneThread)).toBe(true) expect(isClearableActivityThread(interruptedThread)).toBe(true) + expect(activityThreadStatusId(failedThread)).toBe('failed') + expect(isClearableActivityThread(failedThread)).toBe(true) expect(isClearableActivityThread(workingThread)).toBe(false) expect(isClearableActivityThread(blockedThread)).toBe(false) expect(isClearableActivityThread(waitingThread)).toBe(false) }) + + it('reads a live thread whose main agent failed as failed, but keeps it while subagents run', () => { + const heldEntry = { + ...doneEvent(false).entry, + state: 'working' as const, + mainAgent: { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5_000 } + } + const held = makeThread('t-held:1', { + currentAgentState: 'working', + currentAgentEntry: heldEntry + }) + expect(activityThreadStatusId(held)).toBe('failed') + expect(isClearableActivityThread(held)).toBe(false) + const succeeded = makeThread('t-ok:1', { + currentAgentState: 'working', + currentAgentEntry: { + ...heldEntry, + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 5_000 } + } + }) + expect(activityThreadStatusId(succeeded)).toBe('working') + }) }) describe('clearCompletedActivity', () => { diff --git a/src/renderer/src/components/activity/activity-clear-completed.ts b/src/renderer/src/components/activity/activity-clear-completed.ts index 2bb39fdff43..e365152c9bb 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.ts @@ -18,11 +18,15 @@ export type ClearCompletedActivityPlan = { clearedThreadCount: number } -/** A thread is clearable when it needs nothing from the user: completed or interrupted, +/** A thread is clearable when it needs nothing from the user: completed, failed or interrupted, * with no fresh live working/monitoring/blocked/waiting state. */ export function isClearableActivityThread(thread: AgentPaneThread): boolean { const id = activityThreadStatusId(thread) - return id === 'done' || id === 'interrupted' + // Why: a failed main agent reads failed while its subagents still run; that thread is still live. + if (thread.currentAgentState) { + return false + } + return id === 'done' || id === 'failed' || id === 'interrupted' } export function planClearCompletedActivity( diff --git a/src/renderer/src/components/activity/activity-pane-events.ts b/src/renderer/src/components/activity/activity-pane-events.ts index 6a147beac81..0ee287af187 100644 --- a/src/renderer/src/components/activity/activity-pane-events.ts +++ b/src/renderer/src/components/activity/activity-pane-events.ts @@ -27,7 +27,9 @@ function historyEntrySnapshot( toolName: undefined, toolInput: undefined, lastAssistantMessage: undefined, - interrupted: history.interrupted + interrupted: history.interrupted, + // The live row's main agent belongs to its current state, not to this snapshot. + mainAgent: history.mainAgent } } diff --git a/src/renderer/src/components/activity/activity-thread-grouping.ts b/src/renderer/src/components/activity/activity-thread-grouping.ts index cab4fbcf8c3..9bf99cc67cf 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.ts @@ -21,11 +21,11 @@ const ACTIVITY_STATUS_GROUP_RANK: Record = { waiting: 0, blocked: 1, permission: 2, - interrupted: 3, - working: 4, - monitoring: 5, - unverifiable: 6, - failed: 7, + failed: 3, + interrupted: 4, + working: 5, + monitoring: 6, + unverifiable: 7, done: 8, idle: 9 } diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 5541b3da493..e38c802f867 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -2,6 +2,10 @@ import type { AgentDotState } from '@/components/AgentStateDot' import { formatAgentTypeLabel } from '@/lib/agent-status' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' +import { + agentMainAgentVerdict, + agentVerdictDisplayMark +} from '../../../../shared/agent-main-agent-verdict' import { getActivityThreadTaskTitle, getActivityThreadWorkspaceTitle, @@ -68,7 +72,12 @@ export function agentTitle(event: ActivityEvent): string { return 'Agent working' } if (event.state === 'done') { - return event.entry.interrupted ? 'Agent interrupted' : 'Agent finished' + const verdict = agentMainAgentVerdict(event.entry) + return verdict === 'failure' + ? 'Agent failed' + : verdict === 'cancellation' + ? 'Agent interrupted' + : 'Agent finished' } return event.state === 'waiting' ? 'Agent waiting for input' : 'Agent needs input' } @@ -91,7 +100,12 @@ export function agentMeta(event: ActivityEvent): string { return `${agent} ${event.state}` } if (event.state === 'done') { - return event.entry.interrupted ? `${agent} interrupted` : `${agent} completed` + const verdict = agentMainAgentVerdict(event.entry) + return verdict === 'failure' + ? `${agent} failed` + : verdict === 'cancellation' + ? `${agent} interrupted` + : `${agent} completed` } return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked` } @@ -120,13 +134,18 @@ export function statusPreviewForEntry( export type ActivityThreadStatusId = AgentDotState /** Single classifier behind grouping, labels, and clear-completed; the only place the - * interrupted predicate is spelled. */ + * verdict predicate is spelled. */ export function activityThreadStatusId(thread: AgentPaneThread): ActivityThreadStatusId { + // Why: a failed main agent outranks the subagent work still holding its row live. + if (thread.currentAgentEntry && agentVerdictDisplayMark(thread.currentAgentEntry) === 'failed') { + return 'failed' + } const paneEntry = paneActivityEntry(thread) const state = threadCurrentState(thread) ?? 'done' - const interrupted = paneEntry ? paneEntry.interrupted : thread.latestEvent?.entry.interrupted - if (!thread.currentAgentState && state === 'done' && interrupted) { - return 'interrupted' + const verdictEntry = paneEntry ?? thread.latestEvent?.entry + const verdictDot = verdictEntry ? agentVerdictDisplayMark(verdictEntry) : null + if (!thread.currentAgentState && state === 'done' && verdictDot) { + return verdictDot } return state } @@ -149,7 +168,7 @@ function threadCurrentState( ) } -// Interrupted rows deliberately keep the done glyph (#2569). +// Interrupted rows deliberately keep the done glyph (#2569); a failure is a fault and does not. export function threadAgentState(thread: AgentPaneThread): AgentDotState { const id = activityThreadStatusId(thread) return id === 'interrupted' ? 'done' : id diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index fb80712e65f..4b38d8d1b0d 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -11,6 +11,7 @@ import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' +import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' @@ -29,7 +30,7 @@ function stateDotTooltipLabel( dotState: AgentDotState, now: number ): string { - if (agent.entry.interrupted === true) { + if (dotState === 'interrupted') { return 'Interrupted by user' } // Why: report the observation, not a verdict on the agent — the elapsed gap is what @@ -141,7 +142,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - const isInterrupted = agent.entry.interrupted === true + const verdictDotState = agentVerdictDisplayMark(agent.entry) + const isInterrupted = verdictDotState === 'interrupted' const lineage = agent.lineage const isLineageChild = lineage?.depth === 1 const lineageChildCount = lineage?.childCount ?? 0 @@ -152,10 +154,10 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ lineageChildCount === 1 ? 'agent' : 'agents' }` : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') - // Why: interrupted is a terminal outcome, so surface it in the leading state dot. - const dotState: AgentDotState = isInterrupted - ? 'interrupted' - : asDotState(agent.state, agent.entry.workingMode) + // Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a + // failure does so even while subagents still run. + const dotState: AgentDotState = + verdictDotState ?? asDotState(agent.state, agent.entry.workingMode) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now) // Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the // row's own timestamp slot rather than hiding in a hover tooltip. diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts index 91b445dd78f..b5ee4ae239c 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -51,6 +51,47 @@ describe('lastEnteredDoneAt shares the Smart Sort completion clock', () => { expect(lastEnteredDoneAt(row(entry))).toBe(2_000) expect(agentEntryCompletionAt(entry)).toBeNull() }) + + it('dates a failed turn as a completion, and a stopped one only for display', () => { + const verdictDone = (outcome: 'failure' | 'cancellation') => + doneEntry({ + stateStartedAt: 2_000, + mainAgent: { state: 'done', outcome, stateStartedAt: 2_000 } + }) + expect(agentEntryCompletionAt(verdictDone('failure'))).toBe(2_000) + expect(lastEnteredDoneAt(row(verdictDone('failure')))).toBe(2_000) + expect(agentEntryCompletionAt(verdictDone('cancellation'))).toBeNull() + expect(lastEnteredDoneAt(row(verdictDone('cancellation')))).toBe(2_000) + }) + + it('dates a main agent that failed while its subagents run by when it failed', () => { + const held = (outcome: 'failure' | 'cancellation') => + doneEntry({ + state: 'working', + stateStartedAt: 3_000, + mainAgent: { state: 'done', outcome, stateStartedAt: 2_500 } + }) + expect(agentEntryCompletionAt(held('failure'))).toBeNull() + expect(lastEnteredDoneAt(row(held('failure')))).toBe(2_500) + expect(lastEnteredDoneAt(row(held('cancellation')))).toBeNull() + }) + + it('reads the verdict history carries when a boundary displaced the completion', () => { + const history = { state: 'done' as const, prompt: '', startedAt: 1_500 } + const boundary = (outcome?: 'failure' | 'cancellation') => + doneEntry({ + sessionBoundary: true, + stateHistory: [ + { + ...history, + ...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 1_500 } } : {}) + } + ] + }) + expect(agentEntryCompletionAt(boundary())).toBe(1_500) + expect(agentEntryCompletionAt(boundary('failure'))).toBe(1_500) + expect(agentEntryCompletionAt(boundary('cancellation'))).toBeNull() + }) }) describe('lastEnteredDoneAt subagent rows', () => { diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index fe101862957..28d7a2572bb 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -1,4 +1,8 @@ import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' +import { + agentTurnStoppedByUser, + agentVerdictDisplayMark +} from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow } from './useDashboardData' /** @@ -22,10 +26,14 @@ export function lastEnteredDoneAt( if (completedAt !== null) { return completedAt } - // Why: display is looser than ranking — an interrupted turn still shows when it stopped. - if (entry.state === 'done' && entry.interrupted === true && entry.sessionBoundary !== true) { + // Why: display is looser than ranking — a stopped turn still shows when it ended. + if (entry.state === 'done' && agentTurnStoppedByUser(entry) && entry.sessionBoundary !== true) { return entry.stateStartedAt } + // Why: a failed main agent reads failed while its subagents run, so it shows when it failed. + if (entry.state !== 'done' && entry.mainAgent && agentVerdictDisplayMark(entry) === 'failed') { + return entry.mainAgent.stateStartedAt + } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { if (entry.stateHistory[i].state === 'done') { return entry.stateHistory[i].startedAt diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts index d1440f4e5ef..97640f2c2ae 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts @@ -68,7 +68,12 @@ function makeTab(overrides: Partial & { id: string }): TerminalTab } } -function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrupted?: boolean }) { +function makeAgentRow(args: { + paneKey: string + state: AgentStatusState + interrupted?: boolean + mainAgent?: AgentStatusEntry['mainAgent'] +}) { const entry: AgentStatusEntry = { state: args.state, prompt: 'Fix it', @@ -78,7 +83,8 @@ function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrup terminalTitle: 'Claude', stateHistory: [], agentType: 'claude', - interrupted: args.interrupted + interrupted: args.interrupted, + mainAgent: args.mainAgent } return { @@ -134,6 +140,34 @@ describe('collectRetainedAgentsOnDisappear', () => { expect(result.toRetain).toEqual([]) }) + it('retains a failed done row so the failure stays visible, but not a cancelled one', () => { + const retainedFor = (outcome: 'failure' | 'cancellation') => + collectRetainedAgentsOnDisappear({ + previousAgents: new Map([ + [ + 'tab-1:1', + { + row: makeAgentRow({ + paneKey: 'tab-1:1', + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: 100 } + }), + worktreeId: 'wt-1' + } + ] + ]), + currentAgents: new Map(), + retainedAgentsByPaneKey: {}, + retentionSuppressedPaneKeys: {}, + recentlyClosedAgentStatusTabIds: {}, + recentlyRetiredAgentStatusPaneKeys: {} + }).toRetain + + expect(retainedFor('failure')).toHaveLength(1) + expect(retainedFor('failure')[0]?.entry.mainAgent?.outcome).toBe('failure') + expect(retainedFor('cancellation')).toEqual([]) + }) + it('refreshes the retained snapshot when a reused paneKey starts a newer run', () => { // Why: a reused paneKey (same tab+pane, fresh agent start after a prior // retained run) produces a newer startedAt. Without the freshness check diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index f0ab639e2ba..f26baaafc37 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -15,6 +15,7 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict' import { createWorktreeTabBucketProjection, @@ -299,13 +300,12 @@ export function collectRetainedAgentsOnDisappear(args: { if (args.recentlyClosedAgentStatusTabIds[ownerTabId]) { continue } - // Why: only keep a sticky snapshot when the agent finished cleanly - // (state === 'done' and not interrupted). Explicit teardown paths mark + // Why: only keep a sticky snapshot when the agent finished and the user did not + // stop it; a failure is kept so it stays visible. Explicit teardown paths mark // pane keys as suppression candidates, so a close/quit/crash cannot // resurrect a stale `done` row on the next sync. const lastState = prev.row.state - const wasInterrupted = prev.row.entry.interrupted === true - if (lastState !== 'done' || wasInterrupted) { + if (lastState !== 'done' || agentTurnStoppedByUser(prev.row.entry)) { continue } toRetain.push({ diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index ba210d74994..4b31d0b0357 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -10,6 +10,7 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { AgentJournalTurnOutcome } from '../../../../shared/agent-session-journal-types' import type { + AgentSessionStatusEvent, AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../../shared/agent-session-wire' @@ -27,7 +28,9 @@ type TestStore = { type BridgeMocks = { store: TestStore | null emitters: ((event: AgentSessionTurnCompletionEvent) => void)[] + statusEmitters: ((event: AgentSessionStatusEvent) => void)[] subscribeCompletions: Mock + subscribeStatus: Mock supportsCapability: Mock unsubscribe: Mock } @@ -35,7 +38,9 @@ type BridgeMocks = { const mocks = vi.hoisted(() => ({ store: null, emitters: [], + statusEmitters: [], subscribeCompletions: vi.fn(), + subscribeStatus: vi.fn(), supportsCapability: vi.fn(), unsubscribe: vi.fn() })) @@ -58,11 +63,16 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - subscribeStructuredAgentSessionTurnCompletions: mocks.subscribeCompletions + subscribeStructuredAgentSessionTurnCompletions: mocks.subscribeCompletions, + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus })) import { StructuredAgentSessionAttentionBridge } from './StructuredAgentSessionAttentionBridge' import { resetStructuredAgentSessionTurnCompletionFeedsForTests } from '@/runtime/structured-agent-session-turn-completion-feed' +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from '@/runtime/structured-agent-session-status-feed' import { makeTabGroup, makeUnifiedTab, @@ -176,7 +186,15 @@ describe('StructuredAgentSessionAttentionBridge', () => { } }) resetStructuredAgentSessionTurnCompletionFeedsForTests() + resetStructuredAgentSessionStatusFeedsForTests() mocks.emitters.length = 0 + mocks.statusEmitters.length = 0 + mocks.subscribeStatus.mockImplementation( + (_target: unknown, emit: (event: AgentSessionStatusEvent) => void) => { + mocks.statusEmitters.push(emit) + return Promise.resolve({ unsubscribe: vi.fn() }) + } + ) mocks.subscribeCompletions.mockImplementation( (_target: unknown, emit: (event: AgentSessionTurnCompletionEvent) => void) => { mocks.emitters.push(emit) @@ -215,6 +233,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { cleanup() vi.unstubAllGlobals() resetStructuredAgentSessionTurnCompletionFeedsForTests() + resetStructuredAgentSessionStatusFeedsForTests() }) it('lights the unread indicators when the host reports a successful turn', async () => { @@ -235,14 +254,14 @@ describe('StructuredAgentSessionAttentionBridge', () => { worktreeId: WORKSPACE, paneKey: CHAT_SUBJECT, agentState: 'done', - agentInterrupted: false + agentTurnOutcome: 'success' }) }) // A settled turn is news whichever way it settled, exactly as the CLI lane treats one. The - // difference is wording, and it rides the notification flag that already says "stopped". + // difference is wording, which main picks from the verdict. it.each(['failure', 'cancellation'] as const)( - 'lights the indicators and says stopped for a %s the host reports', + 'lights the indicators and hands main the %s the host reports', async (outcome) => { render() await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) @@ -254,7 +273,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { paneDot: 'agent-completion', tabDot: 'agent-completion' }) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: true }) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: outcome }) } ) @@ -299,6 +318,48 @@ describe('StructuredAgentSessionAttentionBridge', () => { } ) + // Remote clients receive the status and completion streams over separate sockets, unordered, so + // the wording must come from the completion alone. The mirror is set to disagree in each case. + function mirrorStatus(status: 'idle' | 'attention'): void { + mocks.statusEmitters[0]?.({ + type: 'status', + session: { + sessionId: SESSION, + workspaceId: 'host-side-workspace', + agent: 'claude', + status, + latestPrompt: 'Ship it', + updatedAt: 1 + } + }) + } + + it.each([ + { awaitingUser: true, mirror: 'idle', agentState: 'blocked' }, + { awaitingUser: undefined, mirror: 'attention', agentState: 'done' } + ] as const)( + 'words awaitingUser=$awaitingUser as $agentState whatever the status mirror says ($mirror)', + async ({ awaitingUser, mirror, agentState }) => { + const stopStatus = getStructuredAgentSessionStatusFeed({ kind: 'local' }).activate() + render() + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const completion = turnCompletion() + + act(() => { + mirrorStatus(mirror) + hostStream()({ + type: 'completion', + completion: awaitingUser ? { ...completion, awaitingUser } : completion + }) + }) + + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState, agentTurnOutcome: 'success' }) + stopStatus() + } + ) + it('lights nothing for a turn whose outcome the host never stated', async () => { render() await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 92d3128d082..b0c546b3f9b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -8,7 +8,8 @@ import { } from '../../../../shared/agent-status-child-work-projection' import { continueMainAgentStatus, - isAgentStatusHeldOpenByChildWork + isAgentStatusHeldOpenByChildWork, + mainAgentTurnInterrupted } from '../../../../shared/agent-lead-status-fold' import { mainAgentStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -98,6 +99,8 @@ function projectStatus( state: agentStatus.state, ...(agentStatus.workingMode ? { workingMode: agentStatus.workingMode } : {}), mainAgent, + // Derived from `mainAgent`, so the equality below needs no second check of it. + interrupted: mainAgentTurnInterrupted(mainAgent), prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, // The host projects these from the journal so the row reads like a hook-reported one: diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts index 573eca9d455..76a49ff8e3b 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AgentSessionTurnCompletion } from '../../../../shared/agent-session-wire' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { createTestStore, @@ -230,7 +231,7 @@ describe('dispatchStructuredTurnCompletionAttention', () => { expect(indicators().paneDot).toBe('agent-completion') }) - it('words a successful turn as finished and a stopped one through the shipped interrupted flag', () => { + it('hands main the host verdict, which picks finished, failed or stopped', () => { dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) // 'done' is the host's report that the turn settled, not a reading of the status row: main // words a 'working' state as "working", which would announce a finished turn as unfinished. @@ -238,16 +239,39 @@ describe('dispatchStructuredTurnCompletionAttention', () => { source: 'agent-task-complete', surface: 'agent-session', agentState: 'done', - agentInterrupted: false + agentTurnOutcome: 'success' }) + for (const [outcome, turnId] of [ + ['cancellation', 'turn-2'], + ['failure', 'turn-3'] + ] as const) { + dispatched.length = 0 + seed() + dispatchStructuredTurnCompletionAttention(structuredTab(), completion({ outcome, turnId })) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: outcome }) + } + }) + + it('calls back a send refused before any turn, named by its journal item key, as failed', () => { + dispatchStructuredTurnCompletionAttention( + structuredTab(), + completion({ outcome: 'failure', turnId: agentJournalSubmissionKey('m1') }) + ) + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: 'failure' }) + }) + + it('asks for input when the host settled a request while a prompt waits on the user', () => { + // e.g. a subagent's approval is unanswered. + dispatchStructuredTurnCompletionAttention(structuredTab(), completion({ awaitingUser: true })) + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState: 'blocked', agentTurnOutcome: 'success' }) + dispatched.length = 0 seed() - dispatchStructuredTurnCompletionAttention( - structuredTab(), - completion({ outcome: 'cancellation', turnId: 'turn-2' }) - ) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: true }) + dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) + expect(onlyDispatch()).toMatchObject({ agentState: 'done' }) }) it('says done even while the status row still reads working, because the host settled the turn', () => { @@ -270,7 +294,7 @@ describe('dispatchStructuredTurnCompletionAttention', () => { } }) dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: false }) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: 'success' }) }) it('delivers an id the acknowledgement round trip dismisses when the user reads the chat', () => { diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.ts index f0afca80e13..4553d79b0a5 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.ts @@ -13,10 +13,11 @@ * adapter, and the same delivery tail — so suppression, acknowledgement, addressing, the success * sound and the blocked-permission fallback all have exactly one implementation. * - * EVERY SETTLED TURN NOTIFIES, matching the CLI lane: success says "finished", and failure and - * cancellation say "stopped" through the shipped `agentInterrupted` flag rather than a second - * vocabulary. A turn with no outcome is UNKNOWN — the host sends no event for one, and nothing - * here may turn that absence into success. + * EVERY SETTLED TURN NOTIFIES, matching the CLI lane: the outcome picks the wording — "finished", + * "failed" or "stopped" — exactly as the hook lane's verdict does. A turn with no outcome is UNKNOWN — the host sends no event for one, and nothing + * here may turn that absence into success. A request that settles while a prompt (a subagent's + * approval, say) waits on the user is worded "needs input" instead, as the hook lane words a + * blocked row. * * Unread and delivery come out of ONE `resolveAgentAttention` decision. "Do not alert me about * something I am watching" is already answered by focus, in the surface adapter's viewed gates and @@ -107,10 +108,10 @@ export function dispatchStructuredTurnCompletionAttention( ...(row?.agentType ? { agentType: row.agentType } : {}), // 'done' is what the host told us, not an inference from the row — the row's own state // can still read 'working' when the completion outruns the status re-projection, and - // main words a 'working' notification as "working". The outcome picks the wording from - // there: interrupted covers failure and cancellation alike. - agentState: 'done', - agentInterrupted: completion.outcome !== 'success', + // main words a 'working' notification as "working". The outcome picks the wording from there. + // `awaitingUser` is the row's 'blocked': the user has a prompt to answer. + agentState: completion.awaitingUser ? 'blocked' : 'done', + agentTurnOutcome: completion.outcome, ...(row?.prompt ? { agentPrompt: row.prompt } : {}), ...(row?.lastAssistantMessage ? { agentLastAssistantMessage: row.lastAssistantMessage } diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index fdce0b62f83..10f65234c6f 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -25,6 +25,7 @@ const AGENT_STATUS_TOOLTIP_STATUSES = new Set([ 'working', 'monitoring', 'permission', + 'failed', 'interrupted', 'done' ]) @@ -58,7 +59,7 @@ const StatusIndicator = React.memo(function StatusIndicator({