diff --git a/src/main/agent-hooks/server-grok-background-status.test.ts b/src/main/agent-hooks/server-grok-background-status.test.ts index 452eb7882b2d..33d2415ebe8e 100644 --- a/src/main/agent-hooks/server-grok-background-status.test.ts +++ b/src/main/agent-hooks/server-grok-background-status.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' +import { AgentSessionTransitionRecorder } from '../stats/agent-session-transition-recorder' import { buildBody, PANE } from './server.test-fixtures' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ @@ -90,6 +91,72 @@ describe('Grok background status ownership', () => { } }) + it.each([ + { + label: 'a background subagent', + task: { id: 'task-1', type: 'subagent', status: 'running', agentType: 'general-purpose' }, + workingMode: undefined, + countsAfterMainAgentStops: true + }, + { + label: 'a background shell', + task: { id: 'task-1', type: 'shell', status: 'running', command: 'sleep 30' }, + workingMode: 'monitoring', + countsAfterMainAgentStops: false + } + ])( + 'counts agent time after the main agent stops only for $label', + async ({ task, workingMode, countsAfterMainAgentStops }) => { + const server = new AgentHookServer() + const sink = { onAgentStart: vi.fn(), onAgentStop: vi.fn() } + const recorder = new AgentSessionTransitionRecorder(sink) + await server.start({ env: 'production' }) + server.subscribeEnrichedStatus((enriched) => recorder.onStatus(enriched)) + try { + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + sessionId: 'session-1', + promptId: 'prompt-1', + prompt: 'start background work' + }) + await postGrokHook(server, { + hookEventName: 'stop', + sessionId: 'session-1', + promptId: 'prompt-1', + reason: 'end_turn', + stopHookActive: false, + backgroundTasks: [task] + }) + + expect(sink.onAgentStart).toHaveBeenCalledTimes(1) + expect(sink.onAgentStop).toHaveBeenCalledTimes(countsAfterMainAgentStops ? 0 : 1) + const [row] = server.getStatusSnapshot() + expect(row).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + expect(row?.workingMode).toBe(workingMode) + + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + sessionId: 'session-1', + promptId: 'task-completed-task-1', + prompt: 'the background task completed' + }) + await postGrokHook(server, { + hookEventName: 'stop', + sessionId: 'session-1', + promptId: 'task-completed-task-1', + reason: 'end_turn', + stopHookActive: false, + backgroundTasks: [] + }) + // The subagent's span stays one session; the shell's wake-up turn is a second one. + expect(sink.onAgentStart).toHaveBeenCalledTimes(countsAfterMainAgentStops ? 1 : 2) + expect(sink.onAgentStop).toHaveBeenCalledTimes(countsAfterMainAgentStops ? 1 : 2) + } finally { + server.stop() + } + } + ) + it('rejects a delayed remote cancellation from the turn replaced by a newer prompt', () => { const server = new AgentHookServer() server.ingestRemote( diff --git a/src/main/plugins/plugin-agent-status-event.test.ts b/src/main/plugins/plugin-agent-status-event.test.ts new file mode 100644 index 000000000000..89f7930d65fc --- /dev/null +++ b/src/main/plugins/plugin-agent-status-event.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { agentStatusChangedPayloadSchema } from '../../shared/plugins/plugin-events' +import { projectPluginAgentStatusChangedPayload } from './plugin-agent-status-event' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function row( + overrides: Partial[0]> = {} +): Parameters[0] { + return { + paneKey: PANE, + worktreeId: 'wt-1', + receivedAt: 1_700_000_000_000, + payload: { state: 'working', prompt: 'ship it', agentType: 'claude' }, + ...overrides + } +} + +describe('projectPluginAgentStatusChangedPayload', () => { + it('publishes the main agent fact beside the combined state, and the schema admits it', () => { + const projected = projectPluginAgentStatusChangedPayload( + row({ + payload: { + state: 'working', + prompt: 'ship it', + agentType: 'claude', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } + } + }) + ) + expect(projected).toEqual({ + worktreeId: 'wt-1', + paneKey: PANE, + state: 'working', + receivedAt: 1_700_000_000_000, + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } + }) + // The bus validates before delivery; a field the schema strips never reaches a plugin. + expect(agentStatusChangedPayloadSchema.parse(projected)).toEqual(projected) + }) + + it("leaves `mainAgent` absent for a row that carries none, so an old host's rows look as they did", () => { + const projected = projectPluginAgentStatusChangedPayload(row()) + expect(projected).toEqual({ + worktreeId: 'wt-1', + paneKey: PANE, + state: 'working', + receivedAt: 1_700_000_000_000 + }) + expect(projected).not.toHaveProperty('mainAgent') + }) + + it('projects a restored row to nothing, even when its main agent reads working', () => { + expect( + projectPluginAgentStatusChangedPayload( + row({ + restoredUnconfirmed: true, + payload: { + state: 'working', + prompt: 'ship it', + agentType: 'claude', + mainAgent: { state: 'working', stateStartedAt: 1 } + } + }) + ) + ).toBeNull() + }) + + it('keeps a missing worktree as null and never invents a verdict on a live main agent', () => { + const projected = projectPluginAgentStatusChangedPayload( + row({ + worktreeId: undefined, + payload: { + state: 'working', + prompt: '', + agentType: 'codex', + mainAgent: { state: 'working', stateStartedAt: 7 } + } + }) + ) + expect(projected?.worktreeId).toBeNull() + expect(projected?.mainAgent).toEqual({ state: 'working', stateStartedAt: 7 }) + }) +}) diff --git a/src/main/plugins/plugin-agent-status-event.ts b/src/main/plugins/plugin-agent-status-event.ts new file mode 100644 index 000000000000..ae75b1846a38 --- /dev/null +++ b/src/main/plugins/plugin-agent-status-event.ts @@ -0,0 +1,35 @@ +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' +import type { PluginAgentStatusChangedPayload } from '../../shared/plugins/plugin-events' + +/** + * The bounded `agent.status.changed` projection of one status-store row. `state` stays the + * combined status; `mainAgent` rides beside it as the same optional fact the row carries. Restored + * rows project to nothing: plugins may automate on `working`, and a hydrated row is a historical + * claim, not fresh activity — its `mainAgent` no less than its `state`. + */ +export function projectPluginAgentStatusChangedPayload( + enriched: Pick< + EnrichedAgentHookEventPayload, + 'worktreeId' | 'paneKey' | 'receivedAt' | 'restoredUnconfirmed' | 'payload' + > +): PluginAgentStatusChangedPayload | null { + if (enriched.restoredUnconfirmed) { + return null + } + const mainAgent = enriched.payload.mainAgent + return { + worktreeId: enriched.worktreeId ?? null, + paneKey: enriched.paneKey, + state: enriched.payload.state, + receivedAt: enriched.receivedAt, + ...(mainAgent + ? { + mainAgent: { + state: mainAgent.state, + ...(mainAgent.outcome ? { outcome: mainAgent.outcome } : {}), + stateStartedAt: mainAgent.stateStartedAt + } + } + : {}) + } +} diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts index 8c3aeeed10c6..5c34cfbc556d 100644 --- a/src/main/startup/main-process-plugins.ts +++ b/src/main/startup/main-process-plugins.ts @@ -14,6 +14,7 @@ import { normalizePluginConsents, normalizePluginIdList } from '../../shared/plugins/plugin-consent-state' +import { projectPluginAgentStatusChangedPayload } from '../plugins/plugin-agent-status-event' import { setMainPluginLanguagePacks, setMainUiLanguage } from '../i18n/main-i18n' import { rebuildAppMenu } from '../menu/register-app-menu' import { logStartupMilestone } from './startup-diagnostics' @@ -153,16 +154,10 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): // v0 plugin event seams: agent status (hook pipeline tap) + worktree // lifecycle (runtime tap). Server-side filtered per plugin subscription. agentHookServer.subscribeEnrichedStatus((enriched) => { - // Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity. - if (enriched.restoredUnconfirmed) { - return + const payload = projectPluginAgentStatusChangedPayload(enriched) + if (payload) { + state.pluginService?.emitEvent('agent.status.changed', payload) } - state.pluginService?.emitEvent('agent.status.changed', { - worktreeId: enriched.worktreeId ?? null, - paneKey: enriched.paneKey, - state: enriched.payload.state, - receivedAt: enriched.receivedAt - }) }) runtime.onWorktreeLifecycle(emitPluginWorktreeLifecycle) } diff --git a/src/main/stats/agent-session-transition-recorder.test.ts b/src/main/stats/agent-session-transition-recorder.test.ts index bb2fc0c03346..74c74273d878 100644 --- a/src/main/stats/agent-session-transition-recorder.test.ts +++ b/src/main/stats/agent-session-transition-recorder.test.ts @@ -10,12 +10,16 @@ import { } from './agent-session-transition-recorder' import type { AgentSessionSink, AgentSessionStatusEvent } from './agent-session-transition-recorder' import { StatsCollector } from './collector' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { PANE as STORE_PANE } from '../agent-hooks/server.test-fixtures' let userDataDir: string vi.mock('electron', () => ({ app: { getPath: () => userDataDir } })) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) const T = 1_700_000_000_000 const PANE = 'tab-1:pane-1' @@ -39,11 +43,27 @@ function hook( paneKey: PANE, connectionId: null, stateStartedAt, + receivedAt: stateStartedAt, payload: { state }, ...extra } } +/** A row from a host that publishes the main agent fact beside the combined state. The recorder + * reads the combined row only; the fact rides along as it does on the real enriched payload. */ +function mainAgentHook( + row: { + state: AgentSessionStatusEvent['payload']['state'] + workingMode?: 'monitoring' + mainAgent: { state: AgentSessionStatusEvent['payload']['state']; stateStartedAt: number } + }, + stateStartedAt: number, + extra: Partial = {} +): AgentSessionStatusEvent { + const payload = { state: row.state, workingMode: row.workingMode, mainAgent: row.mainAgent } + return hook(row.state, stateStartedAt, { payload, ...extra }) +} + function sink(): AgentSessionSink & { onAgentStart: Mock onAgentStop: Mock @@ -211,12 +231,305 @@ describe('AgentSessionTransitionRecorder', () => { }) }) +describe('AgentSessionTransitionRecorder on rows that also carry the main agent fact', () => { + // The stats ask "was an agent executing": the main agent's own turn, or a subagent still running + // after the main agent settled. A background shell the settled main agent left behind is neither. + const MAIN_AGENT_WORKING = { state: 'working' as const, stateStartedAt: T } + + it('stops the session when the main agent settles and only a watch loop holds the row working', () => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + // The Stop hook: the row stays `working` (same clock) in monitoring mode; the main agent is done. + recorder.onStatus( + mainAgentHook( + { + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', stateStartedAt: T + 20_000 } + }, + T, + { receivedAt: T + 20_000 } + ) + ) + // Hours of dev server later, the shell exits and the row settles. + recorder.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 20_000 } }, + T + 7_200_000 + ) + ) + + expect(stats.getSummary().totalAgentsSpawned).toBe(1) + expect(stats.getSummary().totalAgentTimeMs).toBe(20_000) + }) + + it('keeps the session open while a subagent outlives the main agent, and closes it when the child settles', () => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + recorder.onStatus( + mainAgentHook( + { state: 'working', mainAgent: { state: 'done', stateStartedAt: T + 10_000 } }, + T + ) + ) + recorder.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 10_000 } }, + T + 90_000 + ) + ) + + expect(stats.getSummary().totalAgentsSpawned).toBe(1) + expect(stats.getSummary().totalAgentTimeMs).toBe(90_000) + }) + + it('dates the stop by the evidence when a shell outlives the last subagent', () => { + // Main agent done at +10s, its subagent finishes at +60s, a shell keeps the row in monitoring: + // neither state clock moves at +60s, so the evidence clock is the edge. + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + const mainAgentDone = { state: 'done' as const, stateStartedAt: T + 10_000 } + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: mainAgentDone }, T)) + recorder.onStatus( + mainAgentHook({ state: 'working', workingMode: 'monitoring', mainAgent: mainAgentDone }, T, { + receivedAt: T + 60_000 + }) + ) + + expect(stats.getSummary().totalAgentTimeMs).toBe(60_000) + }) + + it('dates a subagent reopening a monitoring row by the evidence, not the pinned row clock', () => { + // The hook lane keeps the row's clock across a watch-loop mode change, so it still reads T, + // the start of the main agent's turn; dating the reopen by it would bill the monitoring window. + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + const mainAgentDone = { state: 'done' as const, stateStartedAt: T + 5_000 } + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + recorder.onStatus( + mainAgentHook({ state: 'working', workingMode: 'monitoring', mainAgent: mainAgentDone }, T, { + receivedAt: T + 5_000 + }) + ) + recorder.onStatus( + mainAgentHook({ state: 'working', mainAgent: mainAgentDone }, T, { + receivedAt: T + 600_000 + }) + ) + recorder.onStatus( + mainAgentHook({ state: 'done', mainAgent: mainAgentDone }, T + 630_000, { + receivedAt: T + 630_000 + }) + ) + + expect(stats.getSummary().totalAgentTimeMs).toBe(35_000) + }) + + it('opens a new session dated by the observation when the main agent resumes after monitoring', () => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + recorder.onStatus( + mainAgentHook( + { + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', stateStartedAt: T + 5_000 } + }, + T, + { receivedAt: T + 5_000 } + ) + ) + // A task notification resumes the main agent; the row's own clock never moved off T. + recorder.onStatus( + mainAgentHook( + { state: 'working', mainAgent: { state: 'working', stateStartedAt: T + 300_000 } }, + T, + { receivedAt: T + 300_000 } + ) + ) + recorder.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 312_000 } }, + T + 312_000 + ) + ) + + expect(stats.getSummary().totalAgentsSpawned).toBe(2) + expect(stats.getSummary().totalAgentTimeMs).toBe(17_000) + }) + + it("never dates an edge by an SSH host's main agent clock", () => { + // An SSH host stamps `mainAgent.stateStartedAt` with its own clock (an hour behind here), while + // every other edge is dated by this host; mixing them would add the skew to the span. + const skewed = new StatsCollector() + const remote = new AgentSessionTransitionRecorder(skewed) + remote.onStatus( + mainAgentHook( + { state: 'working', mainAgent: { state: 'working', stateStartedAt: T - 3_600_000 } }, + T + ) + ) + remote.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T - 3_540_000 } }, + T + 60_000 + ) + ) + expect(skewed.getSummary().totalAgentTimeMs).toBe(60_000) + }) + + it('never dates a reopen before the close a fact-less OSC repaint caused', () => { + // An OSC repaint to a different state carries no main agent fact and closes the span; the next + // hook row restores the fact with its unchanged clock, which predates that close. + const osc = new StatsCollector() + const local = new AgentSessionTransitionRecorder(osc) + local.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + local.onStatus(hook('done', T + 10_000)) + local.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T + 12_000)) + local.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 20_000 } }, + T + 20_000 + ) + ) + expect(osc.getSummary().totalAgentTimeMs).toBe(18_000) + }) + + it.each([ + ['its own prompt', { state: 'waiting' as const, stateStartedAt: T + 30_000 }], + // Codex/Claude: a child's approval prompt parks the row while the displaced main agent reads working. + ["a child's approval prompt", MAIN_AGENT_WORKING] + ])( + 'pauses the clock while the row waits on %s, dated by the row clock', + (_, waitingMainAgent) => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + // A reconnect replays the missed wait: the row clock is restamped at the edge, while the + // evidence clock keeps the last observation before the gap. + recorder.onStatus( + mainAgentHook({ state: 'waiting', mainAgent: waitingMainAgent }, T + 30_000, { + isReplay: true, + receivedAt: T + 30_000, + evidenceObservedAt: T + 20_000 + }) + ) + recorder.onStatus( + mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T + 45_000) + ) + recorder.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 60_000 } }, + T + 60_000 + ) + ) + + expect(stats.getSummary().totalAgentsSpawned).toBe(2) + expect(stats.getSummary().totalAgentTimeMs).toBe(45_000) + } + ) + + it('never opens a session from a restored row whose main agent reads working', () => { + const restored = sink() + new AgentSessionTransitionRecorder(restored).onStatus( + mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T, { + restoredUnconfirmed: true + }) + ) + expect(restored.onAgentStart).not.toHaveBeenCalled() + + const replayed = sink() + new AgentSessionTransitionRecorder(replayed).onStatus( + mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T, { isReplay: true }) + ) + expect(replayed.onAgentStart).not.toHaveBeenCalled() + }) + + it("reads an old host's monitoring row like a new host's: the watch loop stops the clock", () => { + // Hosts that predate `mainAgent` already published `monitoring` only for a settled main agent. + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(hook('working', T)) + recorder.onStatus( + hook('working', T, { + receivedAt: T + 20_000, + payload: { state: 'working', workingMode: 'monitoring' } + }) + ) + // The main agent resumes: the row's clock is still pinned to T. + recorder.onStatus(hook('working', T, { receivedAt: T + 300_000 })) + recorder.onStatus(hook('done', T + 310_000)) + + expect(stats.getSummary().totalAgentsSpawned).toBe(2) + expect(stats.getSummary().totalAgentTimeMs).toBe(30_000) + }) + + it('dates the first live row after a restored one by the evidence, not the restored clock', () => { + // A live hook that repeats a hydrated `working` keeps the row clock from the earlier runtime. + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(hook('working', T - 3_600_000, { receivedAt: T })) + recorder.onStatus(hook('done', T + 60_000)) + + expect(stats.getSummary().totalAgentTimeMs).toBe(60_000) + }) +}) + +describe('AgentSessionTransitionRecorder fed by the status store', () => { + it('dates a live start on a hydrated row by this runtime, not the persisted state clock', async () => { + // An OSC row carries no main agent fact; its live repeat keeps the persisted `stateStartedAt`. + vi.useRealTimers() + _internals.resetCachesForTests() + const hookDataDir = mkdtempSync(join(tmpdir(), 'orca-stats-recorder-hooks-')) + const osc = { + paneKey: STORE_PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: { state: 'working' as const, prompt: 'build', agentType: 'claude' as const } + } + const now = vi.spyOn(Date, 'now').mockReturnValue(T) + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath: hookDataDir }) + first.ingestTerminalStatus(osc) + first.flushStatusPersistSync() + first.stop() + + now.mockReturnValue(T + 3_600_000) + const server = new AgentHookServer() + const starts = sink() + const recorder = new AgentSessionTransitionRecorder(starts) + try { + await server.start({ env: 'production', userDataPath: hookDataDir }) + server.subscribeEnrichedStatus((enriched) => recorder.onStatus(enriched)) + server.ingestTerminalStatus({ ...osc, payload: { ...osc.payload, toolName: 'Bash' } }) + expect(starts.onAgentStart).toHaveBeenCalledWith(STORE_PANE, T + 3_600_000, undefined, 'wt-1') + } finally { + server.stop() + now.mockRestore() + rmSync(hookDataDir, { recursive: true, force: true }) + } + }) +}) + describe('classifyAgentSessionTransition', () => { - it('treats an unchanged state as a snapshot, never a transition', () => { + it('treats an unchanged answer as a snapshot, never a transition', () => { expect( - classifyAgentSessionTransition({ state: 'working', open: true }, hook('working', T)) + classifyAgentSessionTransition({ executing: true, open: true }, hook('working', T)) ).toBe('none') - expect(classifyAgentSessionTransition({ state: 'done', open: false }, hook('done', T))).toBe( + expect(classifyAgentSessionTransition({ executing: false, open: false }, hook('done', T))).toBe( 'none' ) }) @@ -229,10 +542,10 @@ describe('classifyAgentSessionTransition', () => { }) it('closes only a session it opened', () => { - expect(classifyAgentSessionTransition({ state: 'working', open: true }, hook('done', T))).toBe( + expect(classifyAgentSessionTransition({ executing: true, open: true }, hook('done', T))).toBe( 'stop' ) - expect(classifyAgentSessionTransition({ state: 'working', open: false }, hook('done', T))).toBe( + expect(classifyAgentSessionTransition({ executing: true, open: false }, hook('done', T))).toBe( 'none' ) }) diff --git a/src/main/stats/agent-session-transition-recorder.ts b/src/main/stats/agent-session-transition-recorder.ts index a8bab586eb51..edf3355bdc07 100644 --- a/src/main/stats/agent-session-transition-recorder.ts +++ b/src/main/stats/agent-session-transition-recorder.ts @@ -4,7 +4,8 @@ // agent. Hook status is the same truth the sidebar, dashboard, and mobile rows // read, so stats now agree with what the user sees. -import type { AgentStatusState } from '../../shared/agent-status-types' +import { isAgentTimeAccruing } from '../../shared/agent-lead-status-fold' +import type { AgentStatusPayload } from '../../shared/agent-status-types' /** Structural subset of the agent-hook enriched payload this module needs. */ export type AgentSessionStatusEvent = { @@ -17,9 +18,15 @@ export type AgentSessionStatusEvent = { isReplay?: boolean /** Nonterminal state backed only by child state restored from disk. */ restoredUnconfirmed?: true - /** When the current state first appeared; preserved across same-state replays. */ + /** When the current combined state first appeared; preserved across same-state replays. */ stateStartedAt: number - payload: { state: AgentStatusState } + /** When this event arrived; a replay restamps it. */ + receivedAt: number + /** When this evidence was first observed; survives a replay. Absent means `receivedAt`. */ + evidenceObservedAt?: number + /** The combined row state and its watch-loop mode: a settled main agent's background shell holds + * the row `working`, and the stats must not count it. */ + payload: Pick } /** Ordinary pane teardown, or a stamped batch clear for one dropped connection. */ @@ -42,7 +49,8 @@ export type AgentSessionTransition = 'start' | 'stop' | 'none' export const AGENT_SESSION_MIRROR_LIMIT = 1000 type MirroredSession = { - state: AgentStatusState + /** Whether the row last said an agent was executing (the main agent or live agent child work). */ + executing: boolean connectionId: string | null /** True while this recorder has an unmatched onAgentStart out to the sink. */ open: boolean @@ -52,35 +60,48 @@ type MirroredSession = { * Pure transition classifier — the whole idempotency contract lives here. * * Rules: - * - Same state as the mirror is a SNAPSHOT, not a transition. Reconnect replay, + * - Same answer as the mirror is a SNAPSHOT, not a transition. Reconnect replay, * disk hydration, and mid-turn tool-progress events all re-emit the current * state; counting those is what makes every reconnect inflate the totals. * - Only a LIVE event may open a session. A replayed or disk-restored `working` * describes work that began in some earlier runtime, so crediting it would * mint a phantom spawn (see #14610: replays carrying an unchanged state used * to re-arm live timing, and cached replays re-fire completion side effects). + * A restored row's `mainAgent` is as historical as its `state`; neither opens. * - Any event may CLOSE a session this recorder opened. A replayed `done` is how * a client learns about a completion it missed while disconnected; refusing it * would strand the session open until the quit flush. */ export function classifyAgentSessionTransition( - previous: Pick | undefined, + previous: Pick | undefined, event: AgentSessionStatusEvent ): AgentSessionTransition { if (event.providerSessionOnly) { return 'none' } - const next = event.payload.state - if (previous && previous.state === next) { + const executing = isAgentTimeAccruing(event.payload) + if (previous && previous.executing === executing) { return 'none' } - if (next === 'working') { + if (executing) { const live = event.isReplay !== true && event.restoredUnconfirmed !== true return live ? 'start' : 'none' } return previous?.open ? 'stop' : 'none' } +/** + * When an execution edge happened, on this host's clocks. An edge that leaves `working` is a state + * change, which the row's own clock dates. An edge inside `working` (a watch loop starting or giving + * way to agent work, or the first live row after a restored one) leaves that clock on an older state + * start, so the evidence clock dates it; on a live row that just turned `working` the two agree. + */ +export function agentExecutionEdgeAt(event: AgentSessionStatusEvent): number { + return event.payload.state === 'working' + ? (event.evidenceObservedAt ?? event.receivedAt) + : event.stateStartedAt +} + /** * Mirrors the last hook state per pane and forwards start/stop edges to a sink. * @@ -99,7 +120,8 @@ export class AgentSessionTransitionRecorder { } const previous = this.sessions.get(event.paneKey) const transition = classifyAgentSessionTransition(previous, event) - if (transition === 'none' && previous?.state === event.payload.state) { + const executing = isAgentTimeAccruing(event.payload) + if (transition === 'none' && previous?.executing === executing) { // Refresh recency without touching session state so a long-running pane // isn't evicted ahead of an idle one. this.touch(event.paneKey, previous) @@ -108,15 +130,20 @@ export class AgentSessionTransitionRecorder { let open = previous?.open ?? false if (transition === 'start') { - this.sink.onAgentStart(event.paneKey, event.stateStartedAt, undefined, event.worktreeId) + this.sink.onAgentStart( + event.paneKey, + agentExecutionEdgeAt(event), + undefined, + event.worktreeId + ) open = true } else if (transition === 'stop') { - this.sink.onAgentStop(event.paneKey, event.stateStartedAt) + this.sink.onAgentStop(event.paneKey, agentExecutionEdgeAt(event)) open = false } this.touch(event.paneKey, { - state: event.payload.state, + executing, connectionId: event.connectionId, open }) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts index b2ab75e95649..51b7027758e1 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -165,3 +165,48 @@ describe('countActivityUnread working turns', () => { expect(countActivityUnread(makeSource({ ...entry, restoredUnconfirmed: true }), 2_000)).toBe(0) }) }) + +describe('countActivityUnread reads the combined state, not the main agent fact', () => { + // The badge mirrors the Activity feed, which builds its rows from the combined state: a settled + // main agent whose subagent still runs is one live working row there, and no done event exists for + // the main agent's own finish until the row itself settles. + const NOW = AGENT_STATUS_STALE_AFTER_MS + + it('counts a fresh row held working by a subagent as one live working turn', () => { + const source = makeSource( + makeEntry({ + state: 'working', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 5_000, + mainAgent: { state: 'done', stateStartedAt: NOW - 2_000 } + }) + ) + expect(countActivityUnread(source, NOW)).toBe(1) + }) + + it('does not count a settled main agent with only a watch loop, matching the feed', () => { + const source = makeSource( + makeEntry({ + state: 'working', + workingMode: 'monitoring', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 5_000, + mainAgent: { state: 'done', stateStartedAt: NOW - 2_000 } + }) + ) + expect(countActivityUnread(source, NOW)).toBe(0) + }) + + it('does not count a restored row as a live turn, whatever its main agent says', () => { + const source = makeSource( + makeEntry({ + state: 'working', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 5_000, + restoredUnconfirmed: true, + mainAgent: { state: 'working', stateStartedAt: NOW - 5_000 } + }) + ) + expect(countActivityUnread(source, NOW)).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 246670379b4f..e8bcfad610ab 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -63,7 +63,9 @@ function makeEntry(overrides: Partial & { paneKey: string }): stateHistory: overrides.stateHistory ?? [], interrupted: overrides.interrupted, sessionBoundary: overrides.sessionBoundary, - restoredUnconfirmed: overrides.restoredUnconfirmed + restoredUnconfirmed: overrides.restoredUnconfirmed, + workingMode: overrides.workingMode, + mainAgent: overrides.mainAgent } } @@ -783,3 +785,46 @@ describe('buildAttentionByWorktree', () => { expect(map.get(w.id)).toEqual(IDLE) }) }) + +describe('resolveAttention reads the combined state, not the main agent fact', () => { + // The classes name what the sidebar row shows (Needs you / Done / Working), so the sort must + // rank by the same combined state the row displays. A settled main agent whose subagent still runs + // shows Working; ranking it as Done would file a working row among the finished ones. + const key = paneKey('tab-1', LEAF_1) + + it('ranks a row held working by a subagent as Working, not Done', () => { + const entry = makeEntry({ + paneKey: key, + state: 'working', + stateStartedAt: NOW - 60_000, + mainAgent: { state: 'done', stateStartedAt: NOW - 30_000 } + }) + expect(resolveAttention(hookPanes([entry]), NOW)).toEqual({ + cls: 3, + attentionTimestamp: NOW - 60_000 + }) + }) + + it('ranks a settled main agent with a background watch loop as Working, like the row it shows', () => { + const entry = makeEntry({ + paneKey: key, + state: 'working', + workingMode: 'monitoring', + stateStartedAt: NOW - 60_000, + mainAgent: { state: 'done', stateStartedAt: NOW - 30_000 } + }) + expect(resolveAttention(hookPanes([entry]), NOW).cls).toBe(3) + }) + + it('never treats a restored row as live, whatever its main agent says', () => { + const entry = makeEntry({ + paneKey: key, + state: 'working', + restoredUnconfirmed: true, + mainAgent: { state: 'working', stateStartedAt: NOW - 30_000 } + }) + // Even with a live PTY behind it: a hydrated row has no "how long since we heard" to report. + expect(resolveAttention([hookPane(entry, true)], NOW)).toEqual(IDLE) + expect(resolveAttention([hookPane(entry, false)], NOW)).toEqual(IDLE) + }) +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts index 24daf5391786..dd7073c3a8c4 100644 --- a/src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts @@ -242,6 +242,41 @@ describe('Grok hook completion notifications', () => { expect(notifications).toHaveLength(1) }) + it('stays silent while a background subagent outlives the main agent, then announces once', async () => { + const turn = (timestamp: string, promptId: string, backgroundTasks: unknown[]) => [ + { hookEventName: 'UserPromptSubmit', timestamp, sessionId: 'session-1', promptId }, + { + hookEventName: 'Stop', + timestamp: timestamp.replace(':00.000Z', ':01.000Z'), + sessionId: 'session-1', + promptId, + reason: 'end_turn', + stopHookActive: false, + backgroundTasks + } + ] + const early = await play( + turn('2026-09-12T03:05:00.000Z', 'prompt-1', [ + { id: 'task-1', type: 'subagent', status: 'running', agentType: 'general-purpose' } + ]) + ) + vi.advanceTimersByTime(1_500) + expect(early).toHaveLength(0) + + const notifications = await play([ + ...turn('2026-09-12T03:06:00.000Z', 'prompt-1', [ + { id: 'task-1', type: 'subagent', status: 'running', agentType: 'general-purpose' } + ]), + ...turn('2026-09-12T03:07:00.000Z', 'task-completed-task-1', []) + ]) + vi.advanceTimersByTime(1_500) + expect(notifications).toHaveLength(1) + expect(notifications[0]).toMatchObject({ + at: Date.parse('2026-09-12T03:07:02.500Z'), + snapshot: { state: 'done', agentType: 'grok' } + }) + }) + it('does not announce a delayed cancellation after the next prompt starts', async () => { const notifications = await play([ { diff --git a/src/shared/agent-hook-listener-grok-completion.test.ts b/src/shared/agent-hook-listener-grok-completion.test.ts index e82d1e73900d..c62222510d0d 100644 --- a/src/shared/agent-hook-listener-grok-completion.test.ts +++ b/src/shared/agent-hook-listener-grok-completion.test.ts @@ -212,11 +212,75 @@ describe('Grok completion observations', () => { expect( normalize({ hookEventName: 'Stop', - backgroundTasks: [{ id: 'task-1', type: 'subagent', status: 'starting' }] + backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'starting' }] }) ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) }) + // Real Grok 1.0.41 Stop entry for a background `spawn_subagent`, identifiers dropped. + const backgroundSubagent = { + id: 'task-1', + type: 'subagent', + status: 'running', + agentType: 'general-purpose', + description: 'sleep then reply' + } + const backgroundShell = { id: 'task-2', type: 'shell', status: 'running', command: 'sleep 30' } + + it.each([ + { label: 'alone', backgroundTasks: [backgroundSubagent], stopHookActive: false }, + { + label: 'beside a shell', + backgroundTasks: [backgroundShell, backgroundSubagent], + stopHookActive: false + }, + { + label: 'under an active stop hook', + backgroundTasks: [backgroundSubagent], + stopHookActive: true + } + ])( + 'keeps a background subagent that outlives the main agent working, not monitoring ($label)', + ({ backgroundTasks, stopHookActive }) => { + const row = normalize({ + hookEventName: 'Stop', + reason: 'end_turn', + stopHookActive, + backgroundTasks + }) + expect(row).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + expect(row?.workingMode).toBeUndefined() + } + ) + + it('reads the snake_case inventory the same way', () => { + const row = normalize({ + hookEventName: 'Stop', + reason: 'end_turn', + stop_hook_active: false, + background_tasks: [{ id: 'task-1', type: 'subagent', status: 'running' }] + }) + expect(row).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + expect(row?.workingMode).toBeUndefined() + }) + + it('settles a cancel or a session boundary even while a background subagent runs', () => { + expect( + normalize({ + hookEventName: 'StopCancelled', + reason: 'user_interrupt', + backgroundTasks: [backgroundSubagent] + }) + ).toMatchObject({ state: 'done', interrupted: true }) + expect( + normalize({ + hookEventName: 'Stop', + reason: 'shutdown', + backgroundTasks: [backgroundSubagent] + }) + ).toMatchObject({ state: 'done', sessionBoundary: true }) + }) + it('uses only idle_prompt, not task_complete text, as the session-idle backstop', () => { const taskComplete = normalize( capturedHook((hook) => hook.notificationType === 'task_complete') diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 4b384a173d13..4ba6c58d17ff 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -4,7 +4,12 @@ import { } from '../../agent-status-types' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { continueMainAgentStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' -import type { AgentChildWorkLiveness } from '../../agent-status-child-work-liveness' +import type { AgentChildWorkKind } from '../../agent-status-child-work' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness, + type AgentChildWorkLivenessCandidate +} from '../../agent-status-child-work-liveness' import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' import { normalizeGrokPromptId } from '../listener-limits' import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' @@ -92,29 +97,40 @@ function grokTurnEndApplies( ) } -function grokHasRunningFiniteTask(hookPayload: Record): boolean { +/** A finite `backgroundTasks[]` entry as child work. Grok lists only in-flight tasks, so none + * carries a settled state. Monitors are left out: they can run indefinitely and would hold the + * pane (and silence its completion) forever. */ +function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null { + if (!isRecord(task)) { + return null + } + return task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : null +} + +function grokRunningFiniteTasks( + hookPayload: Record +): AgentChildWorkLivenessCandidate[] { const backgroundTasks = aliasedField(hookPayload, 'backgroundTasks', 'background_tasks') if (!backgroundTasks.present || !Array.isArray(backgroundTasks.value)) { - return false + return [] } - return backgroundTasks.value.some((task) => { - if (!isRecord(task)) { - return false - } - return task.type === 'shell' || task.type === 'subagent' + return backgroundTasks.value.flatMap((task) => { + const kind = grokFiniteTaskKind(task) + return kind ? [{ kind }] : [] }) } -/** What a plain `stop` leaves running behind the main agent. Grok reports its finite tasks without a - * kind the roster could classify as agent work, and a still-active stop hook holds the turn the - * same way, so both read as watch work: the pane stays `working` in monitoring mode. */ +/** What a plain `stop` leaves running behind the main agent. A background subagent is agent work + * and keeps the pane `working`; a shell, or a still-active stop hook holding the turn, is watch + * work and reads as monitoring. */ function grokChildWorkLivenessAfterStop( hookPayload: Record ): AgentChildWorkLiveness { const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') - return stopHookActive.value === true || grokHasRunningFiniteTask(hookPayload) - ? 'monitoring' - : null + return ( + agentChildWorkLiveness(grokRunningFiniteTasks(hookPayload)) ?? + (stopHookActive.value === true ? 'monitoring' : null) + ) } function isGrokSessionBoundary(eventName: unknown, hookPayload: Record): boolean { diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index 252205888f88..aba8033f45fa 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -3,6 +3,7 @@ import { continueMainAgentStatus, mainAgentTurnInterrupted, foldAgentLeadStatus, + isAgentTimeAccruing, isAgentStatusHeldOpenByChildWork } from './agent-lead-status-fold' @@ -94,6 +95,45 @@ describe('isAgentStatusHeldOpenByChildWork', () => { }) }) +describe('isAgentTimeAccruing', () => { + it('accrues while the row works for the main agent or its live agent child work', () => { + expect(isAgentTimeAccruing({ state: 'working' })).toBe(true) + }) + + it('pauses while the row waits on the user, whoever raised the prompt', () => { + expect(isAgentTimeAccruing({ state: 'waiting' })).toBe(false) + expect(isAgentTimeAccruing({ state: 'blocked' })).toBe(false) + }) + + it('does not accrue for a watch loop or a settled row', () => { + expect(isAgentTimeAccruing({ state: 'working', workingMode: 'monitoring' })).toBe(false) + expect(isAgentTimeAccruing({ state: 'done' })).toBe(false) + }) + + it('relies on the fold emitting monitoring only for a settled main agent', () => { + // Every lane folds through here (Codex never emits monitoring), so a monitoring row can never + // hide a running main agent turn from the stats. + const leadStates = ['working', 'waiting', 'blocked', 'done'] as const + const liveness = ['waiting', 'working', 'monitoring', null] as const + for (const leadState of leadStates) { + for (const interrupted of [false, true]) { + for (const childWorkLiveness of liveness) { + const folded = foldAgentLeadStatus({ leadState, interrupted, childWorkLiveness }) + const accrues = isAgentTimeAccruing({ + state: folded.stateName, + workingMode: folded.workingMode + }) + // A child waiting on a human pauses the row, whatever the main agent is doing. + expect(accrues).toBe( + childWorkLiveness !== 'waiting' && + (leadState === 'working' || (leadState === 'done' && childWorkLiveness === 'working')) + ) + } + } + } + }) +}) + describe('continueMainAgentStatus', () => { it('keeps the clock across an unchanged state and restarts it on a change', () => { const first = continueMainAgentStatus(undefined, { state: 'working' }, 10) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 416fb23a37a1..f2a38b11b563 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -60,6 +60,20 @@ export function isAgentStatusHeldOpenByChildWork(row: { return row.mainAgent?.state === 'done' && row.state !== 'done' } +/** + * The stats question, "does this row accrue agent time": a `working` row accrues unless it is a watch + * loop. The fold emits `monitoring` only for a settled main agent, so this is the main agent's turn or + * its live agent child work; a row waiting on the user accrues nothing, whoever raised the prompt. + * Reads only the combined row, so an old host that publishes no `mainAgent` is read the same way. + * Not a liveness gate: a watch loop is still live work that lifecycle gates must keep honoring. + */ +export function isAgentTimeAccruing(row: { + state: AgentStatusState + workingMode?: AgentWorkingMode +}): boolean { + return row.state === 'working' && row.workingMode !== 'monitoring' +} + /** The main agent's clock follows the same continuity rule as the row's: an unchanged main agent state * keeps the instant it first appeared, a changed one starts at `now`. A caller that knows * the real instant (a restored stash, a journal record) passes it and wins. */ diff --git a/src/shared/main-agent-status-parity.test.ts b/src/shared/main-agent-status-parity.test.ts index c08b5e9f6f7f..35792d210ec0 100644 --- a/src/shared/main-agent-status-parity.test.ts +++ b/src/shared/main-agent-status-parity.test.ts @@ -123,9 +123,7 @@ const STORIES: Story[] = [ { hookEventName: 'user_prompt_submit', prompt: 'go' }, { hookEventName: 'stop', reason: 'end_turn', backgroundTasks: [RUNNING_AGENT] } ], - // Grok reports no task kind the roster can classify as agent work, so its live subagent - // reads as watch work. Today's label, kept on purpose; a Grok-specific follow-up. - expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } + expect: { state: 'working', mainAgent: { state: 'done' } } }, codex: { // A root Stop with no transcript-tracked children clears the roster (Codex 0.144 could omit @@ -434,14 +432,23 @@ describe('mainAgent status parity across lanes', () => { const payload = drive('grok', lane.events) const row = published(payload) expect(row).toEqual(lane.expect) - // Grok's child evidence lives only on its final plain `stop`: a finite task or an active - // stop hook is watch work, and nothing else ever holds the pane. + // Grok's child evidence lives only on its final plain `stop`: a listed subagent is agent + // work, a shell or an active stop hook is watch work, and nothing else ever holds the pane. const last = lane.events.at(-1) ?? {} - const tasks = Array.isArray(last.backgroundTasks) ? last.backgroundTasks : [] + const tasks: unknown[] = Array.isArray(last.backgroundTasks) ? last.backgroundTasks : [] + const hasType = (type: string) => + tasks.some( + (task) => + typeof task === 'object' && task !== null && 'type' in task && task.type === type + ) const liveness: AgentChildWorkLiveness = - last.hookEventName === 'stop' && (tasks.length > 0 || last.stopHookActive === true) - ? 'monitoring' - : null + last.hookEventName !== 'stop' + ? null + : hasType('subagent') + ? 'working' + : hasType('shell') || last.stopHookActive === true + ? 'monitoring' + : null expect(row).toEqual(refold(row.mainAgent, liveness)) }) }) diff --git a/src/shared/plugins/plugin-events.ts b/src/shared/plugins/plugin-events.ts index 118d0e376cc4..19537a2c2061 100644 --- a/src/shared/plugins/plugin-events.ts +++ b/src/shared/plugins/plugin-events.ts @@ -28,8 +28,22 @@ export const worktreeRemovedPayloadSchema = z.object({ export const agentStatusChangedPayloadSchema = z.object({ worktreeId: z.string().min(1).max(2048).nullable(), paneKey: z.string().min(1).max(2048), + /** The combined status the user sees: `working` while the main agent or any live child works. */ state: z.string().min(1).max(256), - receivedAt: z.number().finite().positive() + receivedAt: z.number().finite().positive(), + /** The main agent's own state beside the combined one, so a plugin can tell "the main agent is + * still working" from "a subagent still runs after the main agent finished". Absent from hosts that + * predate it and from rows with no main agent fact; `state` keeps its meaning either way. */ + mainAgent: z + .object({ + state: z.string().min(1).max(256), + /** The provider's verdict on the main agent's last finished turn; present only while `state` is done. */ + outcome: z.string().min(1).max(256).optional(), + // Why: the same bound the row normalizer applies; a stricter one here would reject the whole event. + /** Stamped by the host the agent runs on (an SSH host's own clock), unlike `receivedAt`. */ + stateStartedAt: z.number().finite() + }) + .optional() }) export const PLUGIN_EVENT_PAYLOAD_SCHEMAS: Record = {