Skip to content
67 changes: 67 additions & 0 deletions src/main/agent-hooks/server-grok-background-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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(
Expand Down
84 changes: 84 additions & 0 deletions src/main/plugins/plugin-agent-status-event.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof projectPluginAgentStatusChangedPayload>[0]> = {}
): Parameters<typeof projectPluginAgentStatusChangedPayload>[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 })
})
})
35 changes: 35 additions & 0 deletions src/main/plugins/plugin-agent-status-event.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
: {})
}
}
13 changes: 4 additions & 9 deletions src/main/startup/main-process-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Loading
Loading