feat(agent-status): reconcile provider turn evidence

This commit is contained in:
Brennan Benson
2026-09-15 11:04:48 -07:00
parent ca060f44e0
commit d519e856ba
37 changed files with 1964 additions and 25 deletions
@@ -0,0 +1,288 @@
import { describe, expect, it } from 'vitest'
import type { AgentTurnOwner } from '../../shared/agent-turn-lifecycle'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
const PANE = makePaneKey('tab-c2', '11111111-1111-4111-8111-111111111111')
const owner: AgentTurnOwner = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-c2',
workspaceKind: 'folder',
runId: 'run-c2',
attachment: { executionId: 'execution-c2' },
provider: 'codex'
}
function ingest(
server: AgentHookServer,
hookEventName: string,
providerTurnId: string | undefined,
payload: {
state: 'working' | 'done'
prompt?: string
interrupted?: boolean
toolAgentId?: string
}
): void {
server.ingestRemote(
{
paneKey: PANE,
source: 'codex',
hookEventName,
...(providerTurnId ? { providerTurnId } : {}),
...(payload.toolAgentId ? { toolAgentId: payload.toolAgentId } : {}),
payload: {
state: payload.state,
prompt: payload.prompt ?? 'ship it',
agentType: 'codex',
...(payload.interrupted ? { interrupted: true } : {})
}
},
'conn-c2'
)
}
describe('AgentHookServer turn lifecycle integration', () => {
it('reduces attributable provider completion and keeps a stable completion identity', () => {
const server = new AgentHookServer()
const changes: string[] = []
server.registerAgentTurnOwner(PANE, owner)
server.subscribeAgentTurnLifecycle(({ reduction }) => {
changes.push(reduction.disposition)
})
ingest(server, 'UserPromptSubmit', 'turn-1', { state: 'working' })
ingest(server, 'Stop', 'turn-1', { state: 'done' })
ingest(server, 'Stop', 'turn-1', { state: 'done' })
const snapshot = server.getAgentTurnLifecycleSnapshot(PANE)
expect(snapshot?.turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-1', phase: 'settled', outcome: 'completed' })
)
expect(changes).toContain('duplicate')
})
it('remembers stale provider evidence so it does not replay indefinitely', () => {
const server = new AgentHookServer()
const dispositions: string[] = []
server.registerAgentTurnOwner(PANE, owner)
server.subscribeAgentTurnLifecycle(({ reduction }) => {
dispositions.push(reduction.disposition)
})
ingest(server, 'Stop', 'orphan-turn', { state: 'done' })
ingest(server, 'Stop', 'orphan-turn', { state: 'done' })
expect(dispositions).toEqual(['ignored', 'duplicate'])
})
it('keeps an autonomous next turn separate from the prior unresolved turn', () => {
const server = new AgentHookServer()
server.registerAgentTurnOwner(PANE, owner)
ingest(server, 'UserPromptSubmit', 'turn-first', { state: 'working' })
ingest(server, 'UserPromptSubmit', 'turn-next', { state: 'working' })
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.currentTurn).toMatchObject({
turnId: 'turn-next',
phase: 'active'
})
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-first', phase: 'unresolved', outcome: null })
)
})
it('records interrupt request and input without settling until provider acknowledgement', () => {
const server = new AgentHookServer()
server.registerAgentTurnOwner(PANE, owner)
ingest(server, 'UserPromptSubmit', 'turn-2', { state: 'working' })
const row = server.getStatusSnapshotForPane(PANE)[0]
if (!row) {
throw new Error('expected working status row')
}
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: row.receivedAt,
baselineStateStartedAt: row.stateStartedAt,
baselinePrompt: 'ship it',
baselineAgentType: 'codex',
intent: 'ctrl-c'
})
).toBe(true)
expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('working')
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.currentTurn).toMatchObject({
phase: 'active',
interrupt: 'requested',
interruptInputWrittenAt: expect.any(Number)
})
ingest(server, 'StopCancelled', 'turn-2', { state: 'done', interrupted: true })
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-2', phase: 'settled', outcome: 'interrupted' })
)
})
it('recovers a matching terminal record but refuses another run attachment', () => {
const server = new AgentHookServer()
server.registerAgentTurnOwner(PANE, owner)
expect(
server.ingestProviderTerminalTurnRecord(PANE, {
record: {
runId: owner.runId,
executionId: owner.attachment.executionId,
turnId: 'turn-record',
outcome: 'completed'
},
observedAt: 20
})?.disposition
).toBe('accepted')
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-record', outcome: 'completed' })
)
expect(
server.ingestProviderTerminalTurnRecord(PANE, {
record: {
runId: 'run-other',
executionId: owner.attachment.executionId,
turnId: 'turn-other',
outcome: 'completed'
}
})
).toBeNull()
})
it('marks an active turn unresolved on certified exit and ignores late provider delivery', () => {
const server = new AgentHookServer()
server.registerAgentTurnOwner(PANE, owner)
ingest(server, 'UserPromptSubmit', 'turn-exit', { state: 'working' })
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1)
expect(server.getAgentTurnLifecycleSnapshot(PANE)).toMatchObject({ executionVerdict: 'exited' })
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-exit', phase: 'unresolved', outcome: null })
)
ingest(server, 'Stop', 'turn-exit', { state: 'done' })
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-exit', phase: 'unresolved', outcome: null })
)
expect(
server.ingestProviderTerminalTurnRecord(PANE, {
record: {
runId: owner.runId,
executionId: owner.attachment.executionId,
turnId: 'late-turn',
outcome: 'completed'
}
})
).toBeNull()
})
it('binds child hooks without a provider turn id to the active root turn', () => {
const server = new AgentHookServer()
server.registerAgentTurnOwner(PANE, owner)
ingest(server, 'UserPromptSubmit', 'turn-child', { state: 'working' })
ingest(server, 'SubagentStart', undefined, {
state: 'working',
toolAgentId: 'child-1'
})
ingest(server, 'Stop', undefined, {
state: 'done',
toolAgentId: 'child-1'
})
const snapshot = server.getAgentTurnLifecycleSnapshot(PANE)
expect(snapshot?.currentTurn).toMatchObject({ phase: 'active', turnId: 'turn-child' })
expect(snapshot?.joinedChildren).toContainEqual(
expect.objectContaining({
turnId: 'turn-child',
workId: 'child-1',
phase: 'settled',
outcome: 'completed'
})
)
})
it('bounds recovery custody and still accepts a later attributable terminal record', () => {
const server = new AgentHookServer()
server.registerAgentTurnOwner(PANE, owner)
ingest(server, 'UserPromptSubmit', 'turn-recovery', { state: 'working' })
expect(server.startAgentTurnRecovery(PANE, 'custody-1', 100, 10)?.disposition).toBe('accepted')
expect(server.expireAgentTurnRecovery(PANE, 'turn-recovery', 'custody-1', 99)?.reason).toBe(
'stale'
)
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.currentTurn).toMatchObject({
phase: 'recovering'
})
expect(
server.expireAgentTurnRecovery(PANE, 'turn-recovery', 'custody-1', 100)?.disposition
).toBe('accepted')
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.currentTurnId).toBeNull()
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-recovery', phase: 'unresolved', outcome: null })
)
expect(
server.ingestProviderTerminalTurnRecord(PANE, {
record: {
runId: owner.runId,
executionId: owner.attachment.executionId,
turnId: 'turn-recovery',
outcome: 'completed'
},
observedAt: 101
})?.disposition
).toBe('accepted')
expect(
server.ingestProviderTerminalTurnRecord(PANE, {
record: {
runId: owner.runId,
executionId: owner.attachment.executionId,
turnId: 'turn-recovery',
outcome: 'completed'
},
observedAt: 102
})?.disposition
).toBe('duplicate')
})
it('abandons recovery without allowing a late terminal record to fabricate success', () => {
const server = new AgentHookServer()
server.registerAgentTurnOwner(PANE, owner)
ingest(server, 'UserPromptSubmit', 'turn-abandon', { state: 'working' })
expect(server.startAgentTurnRecovery(PANE, 'custody-2', 200, 20)?.disposition).toBe('accepted')
expect(
server.abandonAgentTurnRecovery(PANE, 'turn-abandon', 'custody-2', 21)?.disposition
).toBe('accepted')
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-abandon', phase: 'abandoned', outcome: null })
)
expect(
server.ingestProviderTerminalTurnRecord(PANE, {
record: {
runId: owner.runId,
executionId: owner.attachment.executionId,
turnId: 'turn-abandon',
outcome: 'completed'
},
observedAt: 22
})?.reason
).toBe('conflict')
})
it('replaces the lifecycle state when a pane receives a new committed owner', () => {
const server = new AgentHookServer()
server.registerAgentTurnOwner(PANE, owner)
ingest(server, 'UserPromptSubmit', 'old-turn', { state: 'working' })
const replacement: AgentTurnOwner = {
...owner,
runId: 'run-replacement',
attachment: { executionId: 'execution-replacement' }
}
expect(server.registerAgentTurnOwner(PANE, replacement)).toBe(true)
expect(server.getAgentTurnLifecycleSnapshot(PANE)?.turns).toHaveLength(0)
})
})
+2
View File
@@ -16,6 +16,7 @@ export type {
AgentHookStatusFreshnessObservation,
EnrichedAgentHookEventPayload
} from './server/server-types'
export type { AgentTurnLifecycleChange } from './server/server-turn-lifecycle'
export type { AgentHookSource }
export {
CLOSED_AGENT_STATUS_TAB_IDS_MAX,
@@ -45,6 +46,7 @@ export const _internals = {
agentHookServer._resetRowOwnershipForTests()
agentHookServer._resetPromptSentDedupeForTests()
agentHookServer._resetConnectionTimestampWatermarksForTests()
agentHookServer._resetAgentTurnLifecycleForTests()
}
}
@@ -139,6 +139,9 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
// A certified process exit is an execution verdict, not a successful turn completion. Feed
// it to the bound lifecycle before retiring the pane's legacy projection.
this.observeAgentExecutionVerdict(resolvedPaneKey, 'exited')
this.clearPaneState(resolvedPaneKey, { emitStatusRowMutation: false })
if (retained) {
admitLegacyAgentStatus(
@@ -17,6 +17,10 @@ import {
import { launchTokenHash } from '../../../shared/agent-hook-spool'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import {
normalizeProviderTurnId,
readProviderTurnEvidence
} from '../../../shared/agent-hook-listener/provider-turn-evidence'
import {
AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES,
canAdmitLegacyAgentStatus,
@@ -40,6 +44,8 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
hookEventName?: string
source?: unknown
providerPromptId?: unknown
providerTurnId?: unknown
providerTurnTerminal?: unknown
grokPromptBoundary?: unknown
compactTrigger?: unknown
toolUseId?: string
@@ -129,6 +135,8 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
: source === 'grok'
? normalizeGrokPromptId(envelope.providerPromptId)
: undefined
const providerTurnId = normalizeProviderTurnId(envelope.providerTurnId)
const providerTurnTerminal = envelope.providerTurnTerminal === true
const grokPromptBoundary =
source === 'grok' && envelope.grokPromptBoundary === true ? true : undefined
const compactTrigger =
@@ -266,7 +274,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
env: envelope.env,
expectedEnv: this.env
})
const event: AgentHookEventPayload = {
const eventWithoutEvidence: AgentHookEventPayload = {
paneKey,
source,
launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken,
@@ -277,6 +285,8 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
promptInteractionKey,
hookEventName,
providerPromptId,
providerTurnId,
...(providerTurnTerminal ? { providerTurnTerminal: true } : {}),
grokPromptBoundary,
compactTrigger,
toolUseId,
@@ -292,6 +302,11 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
: undefined,
payload: normalizedPayload
}
const providerEvidence = readProviderTurnEvidence({ event: eventWithoutEvidence }).evidence
const event: AgentHookEventPayload =
providerEvidence.length > 0
? { ...eventWithoutEvidence, providerTurnEvidence: providerEvidence }
: eventWithoutEvidence
this.recordCurrentAuthorityObservation(event)
this.applyNormalizedStatus(
event,
@@ -207,6 +207,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.connectionTimestampWatermarkById.clear()
this.evidenceObservedAtByPaneKey.clear()
this.activeHookTurnCompletedAtByPaneKey.clear()
this.agentTurnLifecycleByPaneKey.clear()
this.legacyPaneKeyAliases.clear()
this.paneKeyAliasPersistenceListener = null
this.ownerStateInitialized = false
@@ -14,9 +14,10 @@ import type {
StatusDropListener
} from './server-types'
import { toAgentStatusIpcPayload } from './server-status-identity'
import { AgentHookServerState } from './server-state'
import { AgentHookServerTurnLifecycle } from './server-turn-lifecycle'
export abstract class AgentHookServerListeners extends AgentHookServerState {
/** Status/listener fanout built on top of the host-local turn lifecycle adapter. */
export abstract class AgentHookServerListeners extends AgentHookServerTurnLifecycle {
/**
* Notified once per process when repeated hook POSTs are cut off mid-body (#11217).
* Why: the listener fails open on every request error, so without this the only symptom is
@@ -128,8 +129,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
}
/** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener`
* (a single slot the main window owns and drops on close) this survives window
* teardown and exists at all under headless serve, which never opens one. */
* (a single slot the main window owns and drops on close) this survives window teardown
* and exists at all under headless serve, which never opens one. */
subscribePaneStatusClear(listener: (clear: AgentStatusClearIpcPayload) => void): () => void {
this.paneStatusClearListeners.add(listener)
return () => {
@@ -151,7 +152,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
}
/** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the
* dashboard catches up on hook events that fired during startup. */
* dashboard catches up on hook events that fired during startup. */
getStatusSnapshot(): AgentStatusIpcPayload[] {
return Array.from(this.state.lastStatusByPaneKey.values(), (entry) =>
toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)
@@ -36,6 +36,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio
const {
claudeRunningNonAgentTask: _claudeRunningNonAgentTask,
promptInteractionKey: _promptInteractionKey,
providerTurnEvidence: _providerTurnEvidence,
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
restoredUnconfirmed: _restoredUnconfirmed,
// Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload).
@@ -42,6 +42,7 @@ import type {
StatusFreshnessListener,
StatusRowMutationListener
} from './server-types'
import type { AgentTurnLifecycleState, AgentTurnOwner } from '../../../shared/agent-turn-lifecycle'
/** Shared mutable state for the layered hook-server implementation. */
export abstract class AgentHookServerState {
@@ -119,6 +120,12 @@ export abstract class AgentHookServerState {
createAgentStatusAuthorityId('main-agent-hooks')
)
/** Host-local C1 lifecycle projections keyed by the pane they are attached to. */
protected agentTurnLifecycleByPaneKey = new Map<
string,
{ owner: AgentTurnOwner; state: AgentTurnLifecycleState }
>()
protected abstract withdrawReplayObservation(paneKey: string): void
protected abstract ingestSpoolRecord(record: SpoolRecord): void
protected abstract emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void
@@ -88,6 +88,20 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO
) {
return false
}
const lifecycleSnapshot = this.getAgentTurnLifecycleSnapshot(existing.paneKey)
if (lifecycleSnapshot?.currentTurnId) {
// The keypress is delivery evidence only. Keep the turn active until the provider
// acknowledges interruption or bounded recovery settles it; input acceptance is not proof.
const observedAt = Date.now()
this.requestAgentTurnInterrupt(existing.paneKey, observedAt)
this.recordAgentTurnInterruptInputWritten(existing.paneKey, observedAt)
console.debug('[agent-hooks] recorded interrupt input for agent turn', {
paneKey: existing.paneKey,
agentType,
intent: request.intent
})
return true
}
// Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane.
if (agentType === 'claude') {
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
@@ -30,6 +30,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
observedAt?: number,
mutationBefore?: EnrichedAgentHookEventPayload
): EnrichedAgentHookEventPayload {
// Provider evidence is reduced independently of the legacy row projection. A terminal record
// or interrupt acknowledgement must not be lost merely because a presentation guard rejects
// the accompanying status payload.
this.applyProviderTurnEvidence(payload)
if (payload.hookEventName === 'UserPromptSubmit') {
// Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp.
this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey)
@@ -0,0 +1,19 @@
import type { AgentStatusRunVerdict } from '../../../shared/agent-status-run'
import type { AgentTurnLifecycleEvent, AgentTurnOwner } from '../../../shared/agent-turn-lifecycle'
export function agentExecutionVerdictEvent(
owner: AgentTurnOwner,
verdict: AgentStatusRunVerdict,
observedAt: number
): AgentTurnLifecycleEvent {
return {
kind: 'execution-verdict-observed',
owner,
verdict,
evidence: {
eventId: `execution-verdict:${verdict}:${observedAt}`,
producerId: 'orc:execution-host',
observedAt
}
}
}
@@ -0,0 +1,331 @@
import {
createAgentTurnLifecycleState,
isAgentTurnOwner,
readAgentTurnLifecycleSnapshot,
reduceAgentTurnLifecycle,
type AgentTurnLifecycleEvent,
type AgentTurnLifecycleReduction,
type AgentTurnLifecycleSnapshot,
type AgentTurnLifecycleState,
type AgentTurnOwner
} from '../../../shared/agent-turn-lifecycle'
import { agentTurnOwnersEqual } from '../../../shared/agent-turn-lifecycle-state'
import { boundedAgentTurnEvidenceId } from '../../../shared/agent-turn-evidence-id'
import type { AgentStatusRunVerdict } from '../../../shared/agent-status-run'
import { providerEvidenceToLifecycleEvents } from '../../../shared/agent-hook-listener/provider-turn-lifecycle'
import {
readProviderTerminalTurnRecord,
type ProviderTurnEvidence
} from '../../../shared/agent-hook-listener/provider-turn-evidence'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import { isValidPaneKey } from './server-status-identity'
import { AgentHookServerState } from './server-state'
import { agentExecutionVerdictEvent } from './server-turn-lifecycle-event'
export type AgentTurnLifecycleChange = {
paneKey: string
snapshot: AgentTurnLifecycleSnapshot
reduction: AgentTurnLifecycleReduction
}
/** Host-local adapter that feeds provider facts into C1's canonical reducer. */
export abstract class AgentHookServerTurnLifecycle extends AgentHookServerState {
private readonly agentTurnLifecycleListeners = new Set<
(change: AgentTurnLifecycleChange) => void
>()
/** Bind one committed/adopted C5 execution owner to its concrete pane attachment. */
registerAgentTurnOwner(paneKey: string, owner: AgentTurnOwner): boolean {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey.trim())
if (!isValidPaneKey(resolvedPaneKey) || !isAgentTurnOwner(owner)) {
return false
}
const existing = this.agentTurnLifecycleByPaneKey.get(resolvedPaneKey)
if (existing && agentTurnOwnersEqual(existing.owner, owner)) {
return true
}
this.agentTurnLifecycleByPaneKey.set(resolvedPaneKey, {
owner,
state: createAgentTurnLifecycleState(owner)
})
return true
}
/** Remove an owner binding only when the caller still holds that exact binding. */
unregisterAgentTurnOwner(paneKey: string, owner?: AgentTurnOwner): boolean {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey.trim())
const existing = this.agentTurnLifecycleByPaneKey.get(resolvedPaneKey)
if (!existing || (owner && !agentTurnOwnersEqual(existing.owner, owner))) {
return false
}
this.agentTurnLifecycleByPaneKey.delete(resolvedPaneKey)
return true
}
getAgentTurnLifecycleSnapshot(paneKey: string): AgentTurnLifecycleSnapshot | null {
const registration = this.agentTurnLifecycleByPaneKey.get(
this.resolvePaneKeyAlias(paneKey.trim())
)
return registration ? readAgentTurnLifecycleSnapshot(registration.state) : null
}
subscribeAgentTurnLifecycle(listener: (change: AgentTurnLifecycleChange) => void): () => void {
this.agentTurnLifecycleListeners.add(listener)
return () => this.agentTurnLifecycleListeners.delete(listener)
}
_resetAgentTurnLifecycleForTests(): void {
this.agentTurnLifecycleByPaneKey.clear()
}
/** Apply provider facts through the canonical reducer; adapters never own semantic state. */
protected applyProviderTurnEvidence(payload: AgentHookEventPayload): void {
if (!payload.providerTurnEvidence || payload.providerTurnEvidence.length === 0) {
return
}
const paneKey = this.resolvePaneKeyAlias(payload.paneKey)
const registration = this.agentTurnLifecycleByPaneKey.get(paneKey)
if (!registration || (payload.source && payload.source !== registration.owner.provider)) {
return
}
// A certified exit ends this attachment. A replacement must register a new owner before
// late provider delivery can be considered again.
if (registration.state.executionVerdict === 'exited') {
return
}
for (const evidence of payload.providerTurnEvidence) {
this.applyProviderEvidence(paneKey, registration.owner, evidence)
}
}
protected applyProviderEvidence(
paneKey: string,
owner: AgentTurnOwner,
evidence: ProviderTurnEvidence
): AgentTurnLifecycleReduction | null {
const registration = this.agentTurnLifecycleByPaneKey.get(paneKey)
if (!registration || !agentTurnOwnersEqual(registration.owner, owner)) {
return null
}
if (registration.state.executionVerdict === 'exited') {
return null
}
const boundEvidence = this.bindProviderEvidenceToCurrentTurn(registration.state, evidence)
const events = providerEvidenceToLifecycleEvents(owner, boundEvidence)
let lastReduction: AgentTurnLifecycleReduction | null = null
for (const event of events) {
lastReduction = this.reduceAgentTurnEvent(paneKey, event)
}
return lastReduction
}
/**
* Some provider child hooks identify the child but omit the root turn id. Once the host has
* already observed a root start, binding that child to the current turn is the only safe
* recovery; root outcomes remain anonymous and are rejected by the provider adapter.
*/
private bindProviderEvidenceToCurrentTurn(
state: AgentTurnLifecycleState,
evidence: ProviderTurnEvidence
): ProviderTurnEvidence {
if (evidence.turnId || !evidence.workId || !state.currentTurnId) {
return evidence
}
return {
...evidence,
turnId: state.currentTurnId,
eventId: boundedAgentTurnEvidenceId(`${evidence.eventId}:turn:${state.currentTurnId}`)
}
}
protected reduceAgentTurnEvent(
paneKey: string,
event: AgentTurnLifecycleEvent
): AgentTurnLifecycleReduction | null {
const registration = this.agentTurnLifecycleByPaneKey.get(paneKey)
if (!registration) {
return null
}
const reduction = reduceAgentTurnLifecycle(registration.state, event)
// The reducer records ignored/conflicting evidence and its dedupe key in the returned state;
// retaining only accepted transitions would make malformed or stale facts replay forever.
registration.state = reduction.state
const change = {
paneKey,
snapshot: readAgentTurnLifecycleSnapshot(registration.state),
reduction
}
for (const listener of this.agentTurnLifecycleListeners) {
try {
listener(change)
} catch (error) {
console.error('[agent-hooks] lifecycle listener threw', error)
}
}
return reduction
}
observeAgentExecutionVerdict(
paneKey: string,
verdict: AgentStatusRunVerdict,
observedAt = Date.now()
): AgentTurnLifecycleReduction | null {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey.trim())
const registration = this.agentTurnLifecycleByPaneKey.get(resolvedPaneKey)
if (!registration) {
return null
}
return this.reduceAgentTurnEvent(
resolvedPaneKey,
agentExecutionVerdictEvent(registration.owner, verdict, observedAt)
)
}
recordAgentTurnInterruptInputWritten(
paneKey: string,
observedAt = Date.now()
): AgentTurnLifecycleReduction | null {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey.trim())
const registration = this.agentTurnLifecycleByPaneKey.get(resolvedPaneKey)
const turnId = registration?.state.currentTurnId
if (!registration || !turnId) {
return null
}
return this.reduceAgentTurnEvent(resolvedPaneKey, {
kind: 'turn-interrupt-input-written',
owner: registration.owner,
turnId,
writtenAt: observedAt,
evidence: {
eventId: boundedAgentTurnEvidenceId(`interrupt-input-written:${turnId}:${observedAt}`),
producerId: 'orc:pty-input',
observedAt
}
})
}
requestAgentTurnInterrupt(
paneKey: string,
observedAt = Date.now()
): AgentTurnLifecycleReduction | null {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey.trim())
const registration = this.agentTurnLifecycleByPaneKey.get(resolvedPaneKey)
const turnId = registration?.state.currentTurnId
if (!registration || !turnId) {
return null
}
return this.reduceAgentTurnEvent(resolvedPaneKey, {
kind: 'turn-interrupt-requested',
owner: registration.owner,
turnId,
evidence: {
eventId: boundedAgentTurnEvidenceId(`interrupt-requested:${turnId}:${observedAt}`),
producerId: 'orc:interrupt-request',
observedAt
}
})
}
startAgentTurnRecovery(
paneKey: string,
custodyId: string,
deadlineAt: number,
observedAt = Date.now()
): AgentTurnLifecycleReduction | null {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey.trim())
const registration = this.agentTurnLifecycleByPaneKey.get(resolvedPaneKey)
const turnId = registration?.state.currentTurnId
if (!registration || !turnId) {
return null
}
return this.reduceAgentTurnEvent(resolvedPaneKey, {
kind: 'turn-recovery-started',
owner: registration.owner,
turnId,
custodyId,
deadlineAt,
evidence: {
eventId: boundedAgentTurnEvidenceId(`turn-recovery-started:${custodyId}`),
producerId: 'orc:turn-recovery',
observedAt
}
})
}
expireAgentTurnRecovery(
paneKey: string,
turnId: string,
custodyId: string,
observedAt = Date.now()
): AgentTurnLifecycleReduction | null {
return this.reduceRecoveryEvent(paneKey, 'turn-recovery-expired', turnId, custodyId, observedAt)
}
abandonAgentTurnRecovery(
paneKey: string,
turnId: string,
custodyId: string,
observedAt = Date.now()
): AgentTurnLifecycleReduction | null {
return this.reduceRecoveryEvent(
paneKey,
'turn-recovery-abandoned',
turnId,
custodyId,
observedAt
)
}
ingestProviderTerminalTurnRecord(
paneKey: string,
input: Omit<
Parameters<typeof readProviderTerminalTurnRecord>[0],
'paneKey' | 'source' | 'runId' | 'executionId'
>
): AgentTurnLifecycleReduction | null {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey.trim())
const registration = this.agentTurnLifecycleByPaneKey.get(resolvedPaneKey)
if (!registration) {
return null
}
const read = readProviderTerminalTurnRecord({
...input,
paneKey: resolvedPaneKey,
source: registration.owner.provider,
runId: registration.owner.runId,
executionId: registration.owner.attachment.executionId
})
let reduction: AgentTurnLifecycleReduction | null = null
for (const evidence of read.evidence) {
reduction = this.applyProviderEvidence(resolvedPaneKey, registration.owner, evidence)
}
return reduction
}
private reduceRecoveryEvent(
paneKey: string,
kind: 'turn-recovery-expired' | 'turn-recovery-abandoned',
turnId: string,
custodyId: string,
observedAt: number
): AgentTurnLifecycleReduction | null {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey.trim())
const registration = this.agentTurnLifecycleByPaneKey.get(resolvedPaneKey)
if (!registration) {
return null
}
return this.reduceAgentTurnEvent(resolvedPaneKey, {
kind,
owner: registration.owner,
turnId,
custodyId,
evidence: {
// Include the observation time so a stale expiry attempt cannot consume the valid
// deadline-bound event as a duplicate.
eventId: boundedAgentTurnEvidenceId(`${kind}:${custodyId}:${observedAt}`),
producerId: 'orc:turn-recovery',
observedAt
}
})
}
}
@@ -32,6 +32,7 @@ export type PersistedAgentHookEventPayload = Omit<
| 'claudeRunningNonAgentTask'
| 'launchToken'
| 'promptInteractionKey'
| 'providerTurnEvidence'
| 'restoredUnconfirmed'
// Why: revision counters are in-memory and the authority id is regenerated per process, so
// a stored observation could only rehydrate as a stale ordering claim from a dead authority.
+35 -1
View File
@@ -16,6 +16,8 @@ const transferPaneAuthority = vi.fn()
const canTransferPaneAuthority = vi.fn(() => true)
const getStatusSnapshot = vi.fn()
const inferInterrupt = vi.fn()
const requestAgentTurnInterrupt = vi.fn()
const recordAgentTurnInterruptInputWritten = vi.fn()
const clearMigrationUnsupportedPtysByTabPrefix = vi.fn()
const clearMigrationUnsupportedPtysForPaneKey = vi.fn()
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>()
@@ -53,7 +55,9 @@ vi.mock('../agent-hooks/server', async () => {
transferPaneAuthority,
canTransferPaneAuthority,
getStatusSnapshot,
inferInterrupt
inferInterrupt,
requestAgentTurnInterrupt,
recordAgentTurnInterruptInputWritten
}
}
})
@@ -119,6 +123,8 @@ beforeEach(() => {
canTransferPaneAuthority.mockReturnValue(true)
getStatusSnapshot.mockReset()
inferInterrupt.mockReset()
requestAgentTurnInterrupt.mockReset()
recordAgentTurnInterruptInputWritten.mockReset()
clearMigrationUnsupportedPtysByTabPrefix.mockReset()
clearMigrationUnsupportedPtysForPaneKey.mockReset()
onHandlers.clear()
@@ -281,6 +287,34 @@ describe('agentStatus:inferInterrupt IPC', () => {
})
})
describe('agentStatus:recordInterruptInputWritten IPC', () => {
it('records request and input evidence without requiring a transport acknowledgement', async () => {
requestAgentTurnInterrupt.mockReturnValue(null)
recordAgentTurnInterruptInputWritten.mockReturnValue({ disposition: 'accepted' })
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:recordInterruptInputWritten')
expect(handler).toBeDefined()
expect(handler!({}, PANE_KEY)).toBe(true)
expect(requestAgentTurnInterrupt).toHaveBeenCalledWith(PANE_KEY)
expect(recordAgentTurnInterruptInputWritten).toHaveBeenCalledWith(PANE_KEY)
})
it('rejects malformed pane keys before the hook server boundary', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:recordInterruptInputWritten')
expect(handler).toBeDefined()
for (const value of [null, undefined, 123, true]) {
expect(handler!({}, value)).toBe(false)
}
expect(requestAgentTurnInterrupt).not.toHaveBeenCalled()
expect(recordAgentTurnInterruptInputWritten).not.toHaveBeenCalled()
})
})
describe('agentStatus:drop IPC', () => {
it('forwards drop to dropStatusEntry', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
+11
View File
@@ -35,6 +35,7 @@ export function registerAgentHookHandlers(
// future-proofs this file.
ipcMain.removeHandler('agentStatus:getSnapshot')
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:recordInterruptInputWritten')
ipcMain.removeHandler('agentStatus:inferQuestionAnswered')
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
registerAgentStatusRowTeardownIpcHandlers()
@@ -63,6 +64,16 @@ export function registerAgentHookHandlers(
}
return agentHookServer.inferInterrupt(request as AgentInterruptInferenceRequest)
})
ipcMain.handle('agentStatus:recordInterruptInputWritten', (_event, paneKey: unknown): boolean => {
if (typeof paneKey !== 'string') {
return false
}
// Fire-and-forget transports have no remote write acknowledgement. Record the user's intent
// and local delivery evidence, while the shared reducer still waits for provider ack/recovery.
const requested = agentHookServer.requestAgentTurnInterrupt(paneKey)
const written = agentHookServer.recordAgentTurnInterruptInputWritten(paneKey)
return requested !== null || written !== null
})
ipcMain.handle('agentStatus:inferQuestionAnswered', (_event, request: unknown): boolean => {
if (typeof request !== 'object' || request === null) {
return false
+2
View File
@@ -16,6 +16,8 @@ export type AgentStatusApi = {
/** Return the current main-process hook cache after renderer hydration. */
getSnapshot: () => Promise<AgentStatusIpcPayload[]>
inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise<boolean>
/** Record fire-and-forget interrupt input without treating delivery as provider acknowledgement. */
recordInterruptInputWritten?: (paneKey: string) => Promise<boolean>
/** Guarded clear for an answered AskUserQuestion wait — the CLI emits no hook at answer time, so the renderer reports the submit keystroke. */
inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise<boolean>
/** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */
+2
View File
@@ -28,6 +28,8 @@ export const agentStatusApi = {
ipcRenderer.invoke('agentStatus:getSnapshot'),
inferInterrupt: (request: AgentInterruptInferenceRequest): Promise<boolean> =>
ipcRenderer.invoke('agentStatus:inferInterrupt', request),
recordInterruptInputWritten: (paneKey: string): Promise<boolean> =>
ipcRenderer.invoke('agentStatus:recordInterruptInputWritten', paneKey),
inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise<boolean> =>
ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request),
onMigrationUnsupported: (
+2
View File
@@ -25,6 +25,8 @@ export function buildRelayHookEnvelope(
promptInteractionKey: event.promptInteractionKey,
hookEventName: event.hookEventName,
providerPromptId: event.providerPromptId,
providerTurnId: event.providerTurnId,
providerTurnTerminal: event.providerTurnTerminal,
grokPromptBoundary: event.grokPromptBoundary,
compactTrigger: event.compactTrigger,
toolUseId: event.toolUseId,
@@ -654,6 +654,7 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('\x03')
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
expect(window.api.agentStatus.recordInterruptInputWritten).toHaveBeenCalledWith(paneKey)
})
it('removes agent status and pane title on PTY exit after inferred interrupt', async () => {
@@ -73,6 +73,7 @@ export function installTerminalTestGlobals(): void {
},
agentStatus: {
inferInterrupt: vi.fn().mockResolvedValue(false),
recordInterruptInputWritten: vi.fn().mockResolvedValue(false),
reconcileEndedProcess: vi.fn()
}
},
@@ -22,6 +22,8 @@ import { FOREGROUND_GRID_DRIFT_CHECK_MIN_MS } from './foreground-output-budgets'
import { TERMINAL_FOCUS_IN_SEQUENCE, TERMINAL_FOCUS_OUT_SEQUENCE } from './foreground-output-scan'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
import { isCodexPaneStale } from './codex-pane-stale'
import { recordFireAndForgetInterruptInput } from './pty-interrupt-input-evidence'
import { installPtyPaneGeometryState } from './pty-pane-geometry-state'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
@@ -101,8 +103,8 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
const intent = session.pendingTerminalInputIntent
// Why: real xterm can deliver the terminal byte even when our DOM keydown
// listener missed the press. Exact Ctrl+C/Escape bytes are still safe to
// infer for local/remote acknowledged writes; SSH fire-and-forget remains
// excluded because those transports do not expose sendInputAccepted.
// infer for local/remote acknowledged writes; fire-and-forget transports
// record input delivery separately and never settle the turn.
const acknowledgedIntent = intent ?? session.inferIntentFromExactTerminalInput(data)
if (acknowledgedIntent && session.transport.sendInputAccepted) {
const interruptStatusBaseline =
@@ -154,6 +156,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
session.markAcceptedTerminalInputSent()
session.observeAcceptedShellCommandInput(data)
session.observeAcceptedTerminalInput(data, intent)
recordFireAndForgetInterruptInput(session.cacheKey)
} else {
session.requestRecoveryForUndeliverableInput()
}
@@ -165,6 +168,10 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
session.markAcceptedTerminalInputSent()
session.observeAcceptedShellCommandInput(data)
session.observeAcceptedTerminalInput(data)
const fireAndForgetIntent = session.inferIntentFromExactTerminalInput(data)
if (fireAndForgetIntent) {
recordFireAndForgetInterruptInput(session.cacheKey)
}
session.observeSentTerminalInputIntent(data)
} else {
session.clearPendingTerminalInputIntent()
@@ -370,19 +377,6 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
})
}
// Why: observe the outer pane as the layout signal for both desktop drift
// healing and mobile take-back. Normal desktop panes compare xterm against
// the PTY's applied size; mobile-fit panes only report desktop geometry so
// the parked phone-sized PTY is not resized. See docs/mobile-fit-hold.md.
session.pendingGeometryReportRaf = null
session.lastObservedDesktopGrid = null
session.readPaneSize = (): { width: number; height: number } | null => {
if (typeof session.pane.container.getBoundingClientRect !== 'function') {
return null
}
const rect = session.pane.container.getBoundingClientRect()
return { width: rect.width, height: rect.height }
}
session.lastObservedPaneSize = session.readPaneSize()
session.pendingPaneGeometryChanged = false
// Observe the outer pane for desktop drift healing and mobile take-back.
installPtyPaneGeometryState(session)
}
@@ -0,0 +1,14 @@
import { useAppStore } from '@/store'
export function recordFireAndForgetInterruptInput(cacheKey: string): void {
// AskUserQuestion dismissal is an answer, not an interrupt.
if (useAppStore.getState().agentStatusByPaneKey[cacheKey]?.state !== 'working') {
return
}
const recording = window.api.agentStatus.recordInterruptInputWritten?.(cacheKey)
if (recording) {
void recording.catch((error) => {
console.warn('[agent-interrupt] fire-and-forget input recording failed:', error)
})
}
}
@@ -0,0 +1,15 @@
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installPtyPaneGeometryState(session: ConnectPanePtySession): void {
session.pendingGeometryReportRaf = null
session.lastObservedDesktopGrid = null
session.readPaneSize = (): { width: number; height: number } | null => {
if (typeof session.pane.container.getBoundingClientRect !== 'function') {
return null
}
const rect = session.pane.container.getBoundingClientRect()
return { width: rect.width, height: rect.height }
}
session.lastObservedPaneSize = session.readPaneSize()
session.pendingPaneGeometryChanged = false
}
@@ -8,6 +8,7 @@ export function createWebAgentStatusApi(): Partial<PreloadApi> {
onClear: () => noopUnsubscribe,
getSnapshot: () => Promise.resolve([]),
inferInterrupt: () => Promise.resolve(false),
recordInterruptInputWritten: () => Promise.resolve(false),
inferQuestionAnswered: () => Promise.resolve(false),
onMigrationUnsupported: () => noopUnsubscribe,
onMigrationUnsupportedClear: () => noopUnsubscribe,
+28 -1
View File
@@ -19,6 +19,10 @@ import { normalizeProviderEvent } from './agent-hook-listener/provider-dispatch'
import { hasExplicitUserPrompt } from './agent-hook-listener/provider-event-routing'
import { hasExplicitAmpPrompt } from './agent-hook-listener/providers/amp-events'
import { readString } from './agent-hook-listener/tool-input-preview'
import {
normalizeProviderTurnId,
readProviderTurnEvidence
} from './agent-hook-listener/provider-turn-evidence'
/** Canonical transport-agnostic normalization entry shared by main and relay listeners. */
export function normalizeHookPayload(
state: HookListenerState,
@@ -50,6 +54,23 @@ export function normalizeHookPayload(
: source === 'grok'
? normalizeGrokPromptId(hookPayloadRecord.promptId ?? hookPayloadRecord.prompt_id)
: undefined
// Providers disagree on the field name for a serialized turn. Keep this additive
// identity separate from the conversation/session alias; an absent field remains
// anonymous and cannot be promoted to a root outcome by the host.
const providerTurnId = normalizeProviderTurnId(
readFirstString(hookPayloadRecord, [
'turn_id',
'turnId',
'turnID',
'current_turn_id',
'currentTurnId'
])
)
const providerTurnTerminal =
hookPayloadRecord['terminal'] === true ||
hookPayloadRecord['final'] === true ||
hookPayloadRecord['turn_completed'] === true ||
hookPayloadRecord['turnCompleted'] === true
const compactTrigger =
source === 'claude' &&
(eventName === 'PreCompact' || eventName === 'PostCompact') &&
@@ -135,7 +156,7 @@ export function normalizeHookPayload(
}
const grokActiveTurn = source === 'grok' ? state.grokActiveTurnByPaneKey.get(paneKey) : undefined
return {
const normalizedEvent: AgentHookEventPayload = {
paneKey,
source,
launchToken,
@@ -160,6 +181,8 @@ export function normalizeHookPayload(
hookEventName: typeof eventName === 'string' ? eventName : undefined,
providerPromptId:
source === 'grok' ? (grokActiveTurn?.promptId ?? providerPromptId) : providerPromptId,
...(providerTurnId ? { providerTurnId } : {}),
...(providerTurnTerminal ? { providerTurnTerminal: true } : {}),
grokPromptBoundary: grokActiveTurn ? true : undefined,
compactTrigger,
toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']),
@@ -180,4 +203,8 @@ export function normalizeHookPayload(
...(providerSessionOnly ? { providerSessionOnly: true } : {}),
payload: transportPayload
}
const providerEvidence = readProviderTurnEvidence({ event: normalizedEvent }).evidence
return providerEvidence.length > 0
? { ...normalizedEvent, providerTurnEvidence: providerEvidence }
: normalizedEvent
}
@@ -1,6 +1,7 @@
import type { ParsedAgentStatusPayload } from '../agent-status-types'
import type { AgentHookSource } from '../agent-hook-relay'
import type { AgentProviderSessionMetadata } from '../agent-session-resume'
import type { ProviderTurnEvidence } from './provider-turn-evidence'
export type AgentHookEventPayload = {
paneKey: string
@@ -23,6 +24,12 @@ export type AgentHookEventPayload = {
hookEventName?: string
/** Provider-owned turn identity (Claude UUID or opaque Grok prompt id). */
providerPromptId?: string
/** Provider-owned turn identity when a hook exposes a field other than prompt_id. */
providerTurnId?: string
/** Provider-owned terminal marker; absent means an end-shaped event is not terminal evidence. */
providerTurnTerminal?: boolean
/** Host-local provider facts. This is recomputed at each trust boundary and never persisted. */
providerTurnEvidence?: readonly ProviderTurnEvidence[]
/** This row belongs to an observed Grok prompt boundary even when its opaque id is absent. */
grokPromptBoundary?: true
/** Active Claude compact generation, keyed by provider prompt identity. */
@@ -0,0 +1,17 @@
import type { AgentHookSource } from '../agent-hook-relay'
import { boundedAgentTurnEvidenceId } from '../agent-turn-evidence-id'
import type { ProviderTurnOutcome } from './provider-turn-evidence-types'
/** Build a stable bounded dedupe key without using display or process identity. */
export function providerTurnEventId(
source: AgentHookSource,
paneKey: string,
name: string,
turnId: string | undefined,
outcome: ProviderTurnOutcome | undefined,
recordKind: 'event' | 'terminal-record' = 'event',
workId?: string
): string {
const raw = `provider-turn:${recordKind}:${source}:${paneKey}:${name}:${turnId ?? 'anonymous'}:${workId ?? 'root'}:${outcome ?? 'transition'}`
return boundedAgentTurnEvidenceId(raw)
}
@@ -0,0 +1,89 @@
import type { AgentHookSource } from '../agent-hook-relay'
import type { ParsedAgentStatusPayload } from '../agent-status-types'
export type ProviderTurnOutcome = 'completed' | 'failed' | 'interrupted'
export type ProviderWorkKind = 'joined-child' | 'resident-background'
export type ProviderTurnInventoryWork = {
workId: string
kind: ProviderWorkKind
phase: 'active' | 'settled' | 'unresolved'
outcome?: ProviderTurnOutcome
startedAt?: number
settledAt?: number
}
export type ProviderCurrentTurnInventory = {
turnId: string
startedAt?: number
joinedChildren: ProviderTurnInventoryWork[]
residentBackground: ProviderTurnInventoryWork[]
}
export type ProviderTurnEvidence = {
source: AgentHookSource
producerId: string
eventId: string
observedAt: number
kind:
| 'turn-started'
| 'turn-outcome-observed'
| 'turn-interrupt-acknowledged'
| 'work-started'
| 'work-outcome-observed'
| 'current-turn-inventory'
turnId?: string
outcome?: ProviderTurnOutcome
recordKind?: 'event' | 'terminal-record'
workId?: string
workKind?: ProviderWorkKind
/** `null` is a complete answer that no foreground turn exists. */
inventory?: ProviderCurrentTurnInventory | null
}
export type ProviderTurnEvidenceRead = {
evidence: ProviderTurnEvidence[]
/** Why no semantic event was emitted. Useful for diagnostics and conformance fixtures. */
ignored?: 'anonymous-outcome' | 'session-boundary' | 'incomplete-inventory' | 'unsupported'
}
export type ProviderTurnEvidenceInput = {
event: {
source?: AgentHookSource
paneKey: string
hookEventName?: string
providerPromptId?: string
toolAgentId?: string
toolAgentType?: string
payload: ParsedAgentStatusPayload
/** A provider turn key extracted from a provider payload when it is not prompt_id. */
providerTurnId?: string
/** Provider explicitly marked this event as the terminal turn boundary. */
providerTurnTerminal?: boolean
/** A complete provider inventory, when the adapter can query one. */
currentTurnInventory?: ProviderCurrentTurnInventory | null
/** True only when the inventory enumerates the provider's complete current state. */
currentTurnInventoryComplete?: boolean
/** Background work known to outlive the foreground turn. */
residentBackgroundWorkIds?: readonly string[]
}
observedAt?: number
}
export type ProviderInterruptEvidenceInput = {
source: AgentHookSource
paneKey: string
turnId?: string
observedAt?: number
/** Input acceptance is deliberately not accepted here; this is a provider ack only. */
acknowledgedBy: 'provider-hook' | 'provider-record'
}
export type ProviderTerminalTurnRecordInput = {
source: AgentHookSource
paneKey: string
runId: string
executionId: string
record: unknown
observedAt?: number
}
@@ -0,0 +1,249 @@
import { describe, expect, it } from 'vitest'
import {
normalizeProviderTurnId,
providerCurrentTurnInventory,
readProviderInterruptAcknowledgement,
readProviderTerminalTurnRecord,
readProviderTurnEvidence
} from './provider-turn-evidence'
import type { ProviderTurnEvidenceInput } from './provider-turn-evidence'
function event(
overrides: Partial<ProviderTurnEvidenceInput['event']> = {}
): ProviderTurnEvidenceInput['event'] {
return {
paneKey: 'tab:leaf',
source: 'codex',
hookEventName: 'UserPromptSubmit',
providerTurnId: 'turn-1',
payload: { state: 'working', prompt: 'ship it', agentType: 'codex' },
...overrides
}
}
describe('provider turn evidence adapter', () => {
it('bounds provider turn identity before retaining it across a transport', () => {
expect(normalizeProviderTurnId(' turn-1 ')).toBe('turn-1')
expect(normalizeProviderTurnId('x'.repeat(513))).toBeUndefined()
const evidence = readProviderTurnEvidence({
event: event({ paneKey: 'p'.repeat(512), providerTurnId: 't'.repeat(512) })
}).evidence[0]
expect(evidence?.eventId.length).toBeLessThanOrEqual(512)
})
it('emits an attributable start and completion for a provider turn', () => {
const started = readProviderTurnEvidence({ event: event() })
expect(started.evidence).toEqual([
expect.objectContaining({
kind: 'turn-started',
turnId: 'turn-1',
recordKind: 'event'
})
])
const completed = readProviderTurnEvidence({
event: event({
hookEventName: 'Stop',
payload: { state: 'done', prompt: 'ship it', agentType: 'codex' }
}),
observedAt: 123
})
expect(completed.evidence).toEqual([
expect.objectContaining({
kind: 'turn-outcome-observed',
turnId: 'turn-1',
outcome: 'completed',
observedAt: 123
})
])
})
it('never turns an anonymous Stop into a root outcome', () => {
const result = readProviderTurnEvidence({
event: event({
providerTurnId: undefined,
providerPromptId: undefined,
hookEventName: 'Stop'
})
})
expect(result.evidence).toEqual([])
expect(result.ignored).toBe('anonymous-outcome')
})
it('separates child work and resident background work from the root turn', () => {
const result = readProviderTurnEvidence({
event: event({
toolAgentId: 'child-1',
hookEventName: 'SubagentStart',
residentBackgroundWorkIds: ['child-1']
})
})
expect(result.evidence).toEqual([
expect.objectContaining({
kind: 'work-started',
turnId: 'turn-1',
workId: 'child-1',
workKind: 'resident-background'
})
])
})
it('requires a provider terminal marker for milestone agent_end events', () => {
const milestone = readProviderTurnEvidence({
event: event({
source: 'omp',
hookEventName: 'agent_end',
providerTurnId: 'omp-turn',
payload: { state: 'done', prompt: 'ship it', agentType: 'omp' }
})
})
expect(milestone.evidence).toEqual([])
const terminal = readProviderTurnEvidence({
event: event({
source: 'omp',
hookEventName: 'agent_end',
providerTurnId: 'omp-turn',
providerTurnTerminal: true,
payload: { state: 'done', prompt: 'ship it', agentType: 'omp' }
})
})
expect(terminal.evidence).toEqual([
expect.objectContaining({
kind: 'turn-outcome-observed',
turnId: 'omp-turn',
outcome: 'completed'
})
])
})
it('does not let a child Stop settle the root and keeps child ids distinct', () => {
const first = readProviderTurnEvidence({
event: event({
toolAgentId: 'child-1',
hookEventName: 'Stop',
providerTurnId: undefined,
payload: { state: 'done', prompt: 'ship it', agentType: 'codex' }
}),
observedAt: 10
})
const second = readProviderTurnEvidence({
event: event({
toolAgentId: 'child-2',
hookEventName: 'Stop',
providerTurnId: undefined,
payload: { state: 'done', prompt: 'ship it', agentType: 'codex' }
}),
observedAt: 10
})
expect(first.evidence).toEqual([
expect.objectContaining({
kind: 'work-outcome-observed',
workId: 'child-1',
turnId: undefined,
outcome: 'completed'
})
])
expect(second.evidence[0]?.eventId).not.toBe(first.evidence[0]?.eventId)
})
it('recognizes explicit provider interrupt acknowledgement only', () => {
const result = readProviderInterruptAcknowledgement({
source: 'claude',
paneKey: 'tab:leaf',
turnId: 'turn-1',
acknowledgedBy: 'provider-hook',
observedAt: 42
})
expect(result.evidence).toEqual([
expect.objectContaining({
kind: 'turn-interrupt-acknowledged',
outcome: 'interrupted',
observedAt: 42,
recordKind: 'event'
})
])
})
it('requires complete current-turn inventories', () => {
const incomplete = providerCurrentTurnInventory({ turnId: 'turn-1', joinedChildren: [] }, false)
expect(incomplete).toBeNull()
const complete = providerCurrentTurnInventory(
{
turnId: 'turn-1',
joinedChildren: [{ id: 'child-1', phase: 'active' }],
residentBackground: [{ id: 'monitor-1', phase: 'active' }]
},
true
)
expect(complete).toEqual({
turnId: 'turn-1',
joinedChildren: [{ workId: 'child-1', kind: 'joined-child', phase: 'active' }],
residentBackground: [{ workId: 'monitor-1', kind: 'resident-background', phase: 'active' }]
})
expect(providerCurrentTurnInventory({ turnId: 'turn-1', joinedChildren: [] }, true)).toBeNull()
expect(
providerCurrentTurnInventory(
{ turnId: 'turn-1', joinedChildren: [], residentBackground: ['malformed'] },
true
)
).toBeNull()
const noActiveTurn = readProviderTurnEvidence({
event: event({
hookEventName: undefined,
currentTurnInventory: null,
currentTurnInventoryComplete: true
})
})
expect(noActiveTurn.evidence).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'current-turn-inventory',
inventory: null
})
])
)
const missingInventory = readProviderTurnEvidence({
event: event({ currentTurnInventoryComplete: true })
})
expect(missingInventory.evidence).toEqual([])
expect(missingInventory.ignored).toBe('incomplete-inventory')
const malformedInventory = readProviderTurnEvidence({
event: event({
currentTurnInventoryComplete: true,
currentTurnInventory: { turnId: '', joinedChildren: [], residentBackground: [] }
})
})
expect(malformedInventory.evidence).toEqual([])
expect(malformedInventory.ignored).toBe('unsupported')
})
it('recovers a missed start only from a matching terminal record', () => {
const recovered = readProviderTerminalTurnRecord({
source: 'codex',
paneKey: 'tab:leaf',
runId: 'run-1',
executionId: 'exec-1',
record: { runId: 'run-1', executionId: 'exec-1', turnId: 'turn-2', outcome: 'completed' }
})
expect(recovered.evidence).toEqual([
expect.objectContaining({
kind: 'turn-outcome-observed',
turnId: 'turn-2',
recordKind: 'terminal-record'
})
])
const unrelated = readProviderTerminalTurnRecord({
source: 'codex',
paneKey: 'tab:leaf',
runId: 'run-1',
executionId: 'exec-1',
record: { runId: 'run-other', executionId: 'exec-1', turnId: 'turn-2', outcome: 'completed' }
})
expect(unrelated.evidence).toEqual([])
})
})
@@ -0,0 +1,291 @@
import type { AgentHookSource } from '../agent-hook-relay'
import type { AgentHookEventPayload } from './listener-event'
import { providerCurrentTurnInventory } from './provider-turn-inventory'
import { providerTurnEventId } from './provider-turn-event-id'
import { normalizeProviderTurnIdentity } from './provider-turn-identity'
import type {
ProviderInterruptEvidenceInput,
ProviderTurnEvidence,
ProviderTurnEvidenceInput,
ProviderTurnEvidenceRead,
ProviderTurnOutcome,
ProviderWorkKind
} from './provider-turn-evidence-types'
export type {
ProviderCurrentTurnInventory,
ProviderInterruptEvidenceInput,
ProviderTerminalTurnRecordInput,
ProviderTurnEvidence,
ProviderTurnEvidenceInput,
ProviderTurnEvidenceRead,
ProviderTurnInventoryWork,
ProviderTurnOutcome,
ProviderWorkKind
} from './provider-turn-evidence-types'
export { providerCurrentTurnInventory } from './provider-turn-inventory'
export { readProviderTerminalTurnRecord } from './provider-turn-terminal-record'
const TURN_START_EVENTS = new Set([
'UserPromptSubmit',
'user_prompt_submit',
'before_agent_start',
'agent_start',
'SessionBusy',
'session_busy',
'MessagePart',
'message_part',
'turn_started',
'turn/started',
'prompt_submitted'
])
const TURN_OUTCOME_EVENTS = new Set([
'Stop',
'StopFailure',
'StopCancelled',
'stop_cancelled',
'stop',
'stop_failure',
'SessionIdle',
'session_idle',
'agent_end',
'turn_completed',
'turn/completed',
'turn_cancelled',
'turn/cancelled'
])
const CHILD_START_EVENTS = new Set(['SubagentStart', 'subagent_start', 'child_started'])
const CHILD_OUTCOME_EVENTS = new Set([
'SubagentStop',
'subagent_stop',
'TeammateIdle',
'teammate_idle',
'child_completed',
'child_failed'
])
const INTERRUPT_EVENTS = new Set([
'StopCancelled',
'stop_cancelled',
'turn_cancelled',
'turn/interrupted',
'interrupt_acknowledged',
'interrupted',
'cancelled'
])
/** Normalize an optional provider turn key before it is retained in a transport envelope. */
export function normalizeProviderTurnId(value: unknown): string | undefined {
return normalizeProviderTurnIdentity(value)
}
function outcomeFor(name: string, payload: AgentHookEventPayload['payload']): ProviderTurnOutcome {
if (payload.interrupted === true || INTERRUPT_EVENTS.has(name)) {
return 'interrupted'
}
return name === 'StopFailure' || name === 'stop_failure' || name === 'StopCancelled'
? 'failed'
: 'completed'
}
function isTerminalTurnOutcome(
source: AgentHookSource,
name: string,
providerTurnTerminal: boolean | undefined
): boolean {
// Pi-compatible CLIs can emit agent_end between internal steps. Only an explicit terminal
// marker (or a provider other than that family) may settle a root turn.
if ((source === 'pi' || source === 'omp' || source === 'prime-agent') && name === 'agent_end') {
return providerTurnTerminal === true
}
return true
}
function readTurnId(event: ProviderTurnEvidenceInput['event']): string | undefined {
return (
normalizeProviderTurnId(event.providerTurnId) ?? normalizeProviderTurnId(event.providerPromptId)
)
}
function readChildEvidence(
input: ProviderTurnEvidenceInput,
observedAt: number,
name: string,
turnId: string | undefined
): ProviderTurnEvidence[] {
const workId = normalizeProviderTurnIdentity(input.event.toolAgentId)
if (!workId) {
return []
}
const workKind: ProviderWorkKind = input.event.residentBackgroundWorkIds?.includes(workId)
? 'resident-background'
: 'joined-child'
const eventIdValue = providerTurnEventId(
input.event.source ?? 'claude',
input.event.paneKey,
name,
turnId,
TURN_OUTCOME_EVENTS.has(name) ? outcomeFor(name, input.event.payload) : undefined,
'event',
workId
)
const childOutcome =
CHILD_OUTCOME_EVENTS.has(name) ||
(TURN_OUTCOME_EVENTS.has(name) &&
isTerminalTurnOutcome(input.event.source ?? 'claude', name, input.event.providerTurnTerminal))
if (childOutcome) {
return [
{
source: input.event.source ?? 'claude',
producerId: `provider:${input.event.source ?? 'unknown'}`,
eventId: eventIdValue,
observedAt,
kind: 'work-outcome-observed',
turnId,
outcome: outcomeFor(name, input.event.payload),
recordKind: 'event',
workId,
workKind
}
]
}
if (
CHILD_START_EVENTS.has(name) ||
TURN_START_EVENTS.has(name) ||
input.event.payload.state === 'working'
) {
return [
{
source: input.event.source ?? 'claude',
producerId: `provider:${input.event.source ?? 'unknown'}`,
eventId: eventIdValue,
observedAt,
kind: 'work-started',
turnId,
workId,
workKind
}
]
}
return []
}
/**
* Extract provider evidence without deciding the aggregate row state. The caller must supply
* C5's bound attachment/run owner before forwarding the result to the shared C1 reducer.
*/
export function readProviderTurnEvidence(
input: ProviderTurnEvidenceInput
): ProviderTurnEvidenceRead {
const source = input.event.source
if (!source) {
return { evidence: [], ignored: 'unsupported' }
}
const parsedName = normalizeProviderTurnIdentity(input.event.hookEventName)
if (!parsedName && input.event.currentTurnInventoryComplete !== true) {
return { evidence: [], ignored: 'unsupported' }
}
const name = parsedName ?? 'current-turn-inventory'
const observedAt = input.observedAt ?? Date.now()
const turnId = readTurnId(input.event)
const evidence: ProviderTurnEvidence[] = []
const hasChildIdentity = normalizeProviderTurnIdentity(input.event.toolAgentId) !== undefined
if (input.event.currentTurnInventoryComplete === true) {
if (input.event.currentTurnInventory === undefined) {
return { evidence: [], ignored: 'incomplete-inventory' }
}
const inventory =
input.event.currentTurnInventory === null
? null
: providerCurrentTurnInventory(input.event.currentTurnInventory, true)
if (input.event.currentTurnInventory !== null && inventory === null) {
return { evidence: [], ignored: 'unsupported' }
}
evidence.push({
source,
producerId: `provider:${source}`,
eventId: providerTurnEventId(source, input.event.paneKey, name, inventory?.turnId, undefined),
observedAt,
kind: 'current-turn-inventory',
...(inventory?.turnId ? { turnId: inventory.turnId } : {}),
inventory: inventory ?? null
})
} else if (input.event.currentTurnInventory !== undefined) {
return { evidence: [], ignored: 'incomplete-inventory' }
}
// A provider child can reuse the root event vocabulary (notably Claude's plain `Stop`).
// Its agent id is the stronger attribution signal, so never let that event settle the root.
if (!hasChildIdentity && TURN_START_EVENTS.has(name) && turnId) {
evidence.push({
source,
producerId: `provider:${source}`,
eventId: providerTurnEventId(source, input.event.paneKey, name, turnId, undefined),
observedAt,
kind: 'turn-started',
turnId,
recordKind: 'event'
})
}
if (
!hasChildIdentity &&
TURN_OUTCOME_EVENTS.has(name) &&
isTerminalTurnOutcome(source, name, input.event.providerTurnTerminal)
) {
if (input.event.payload.sessionBoundary === true) {
return { evidence, ignored: 'session-boundary' }
}
if (!turnId) {
evidence.push(...readChildEvidence(input, observedAt, name, turnId))
return { evidence, ignored: 'anonymous-outcome' }
}
const outcome = outcomeFor(name, input.event.payload)
evidence.push({
source,
producerId: `provider:${source}`,
eventId: providerTurnEventId(source, input.event.paneKey, name, turnId, outcome),
observedAt,
kind:
INTERRUPT_EVENTS.has(name) || input.event.payload.interrupted === true
? 'turn-interrupt-acknowledged'
: 'turn-outcome-observed',
turnId,
outcome,
recordKind: 'event'
})
}
evidence.push(...readChildEvidence(input, observedAt, name, turnId))
return { evidence }
}
export function readProviderInterruptAcknowledgement(
input: ProviderInterruptEvidenceInput
): ProviderTurnEvidenceRead {
const turnId = normalizeProviderTurnIdentity(input.turnId)
if (!turnId) {
return { evidence: [], ignored: 'anonymous-outcome' }
}
const observedAt = input.observedAt ?? Date.now()
return {
evidence: [
{
source: input.source,
producerId: `provider:${input.source}`,
eventId: providerTurnEventId(
input.source,
input.paneKey,
'interrupt_acknowledged',
turnId,
'interrupted',
input.acknowledgedBy === 'provider-record' ? 'terminal-record' : 'event'
),
observedAt,
kind: 'turn-interrupt-acknowledged',
turnId,
outcome: 'interrupted',
recordKind: input.acknowledgedBy === 'provider-record' ? 'terminal-record' : 'event'
}
]
}
}
@@ -0,0 +1,21 @@
const MAX_PROVIDER_TURN_ID_LENGTH = 512
/** Keep provider-owned identifiers bounded before they cross a transport boundary. */
export function normalizeProviderTurnIdentity(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
if (trimmed.length === 0 || trimmed.length > MAX_PROVIDER_TURN_ID_LENGTH) {
return undefined
}
for (let index = 0; index < trimmed.length; index += 1) {
const code = trimmed.charCodeAt(index)
if (code <= 0x1f || code === 0x7f) {
return undefined
}
}
return trimmed
}
export const PROVIDER_TURN_ID_MAX_LENGTH = MAX_PROVIDER_TURN_ID_LENGTH
@@ -0,0 +1,78 @@
import { normalizeProviderTurnIdentity } from './provider-turn-identity'
import type {
ProviderCurrentTurnInventory,
ProviderTurnInventoryWork,
ProviderWorkKind
} from './provider-turn-evidence-types'
const MAX_WORK_IDS = 128
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function providerCurrentTurnInventory(
value: unknown,
complete: unknown
): ProviderCurrentTurnInventory | null {
if (complete !== true || typeof value !== 'object' || value === null || Array.isArray(value)) {
return null
}
if (!isRecord(value)) {
return null
}
const record = value
const turnId = normalizeProviderTurnIdentity(record.turnId ?? record.turn_id ?? record.id)
if (!turnId) {
return null
}
const readWork = (
candidate: unknown,
kind: ProviderWorkKind
): ProviderTurnInventoryWork[] | null => {
if (!Array.isArray(candidate) || candidate.length > MAX_WORK_IDS) {
return null
}
const result: ProviderTurnInventoryWork[] = []
for (const item of candidate) {
if (!isRecord(item)) {
return null
}
const workId = normalizeProviderTurnIdentity(item.workId ?? item.work_id ?? item.id)
const phase =
item.phase === 'active' || item.phase === 'settled' || item.phase === 'unresolved'
? item.phase
: null
if (!workId || !phase) {
return null
}
const outcome =
item.outcome === 'completed' || item.outcome === 'failed' || item.outcome === 'interrupted'
? item.outcome
: undefined
result.push({
workId,
kind,
phase,
...(outcome ? { outcome } : {}),
...(typeof item.startedAt === 'number' ? { startedAt: item.startedAt } : {}),
...(typeof item.settledAt === 'number' ? { settledAt: item.settledAt } : {})
})
}
return result
}
const joinedChildren = readWork(record.joinedChildren ?? record.joined_children, 'joined-child')
const residentBackground = readWork(
record.residentBackground ?? record.resident_background,
'resident-background'
)
if (joinedChildren === null || residentBackground === null) {
return null
}
return {
turnId,
...(typeof record.startedAt === 'number' ? { startedAt: record.startedAt } : {}),
joinedChildren,
residentBackground
}
}
@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest'
import {
createAgentTurnLifecycleState,
reduceAgentTurnLifecycle,
readAgentTurnLifecycleSnapshot
} from '../agent-turn-lifecycle'
import type { AgentTurnLifecycleEvent, AgentTurnOwner } from '../agent-turn-lifecycle'
import { providerEvidenceToLifecycleEvents } from './provider-turn-lifecycle'
import {
readProviderInterruptAcknowledgement,
readProviderTerminalTurnRecord,
readProviderTurnEvidence
} from './provider-turn-evidence'
const owner: AgentTurnOwner = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-c2',
workspaceKind: 'folder',
runId: 'run-c2',
attachment: { executionId: 'execution-c2' },
provider: 'codex'
}
function requireValue<T>(value: T | undefined): T {
if (value === undefined) {
throw new Error('expected fixture value')
}
return value
}
function applyEvidence(
state: ReturnType<typeof createAgentTurnLifecycleState>,
evidence: Parameters<typeof providerEvidenceToLifecycleEvents>[1]
) {
let current = state
for (const event of providerEvidenceToLifecycleEvents(owner, evidence)) {
current = reduceAgentTurnLifecycle(current, event).state
}
return current
}
function startedEvent(): Extract<AgentTurnLifecycleEvent, { kind: 'turn-started' }> {
return {
kind: 'turn-started',
owner,
evidence: { eventId: 'c2-event', producerId: 'provider:codex', observedAt: 1 },
turnId: 'turn-1'
}
}
describe('provider turn lifecycle bridge', () => {
it('settles a final completion and is idempotent on late duplicate evidence', () => {
const started = readProviderTurnEvidence({
event: {
paneKey: 'tab:leaf',
source: 'codex',
hookEventName: 'UserPromptSubmit',
providerTurnId: 'turn-1',
payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }
},
observedAt: 1
}).evidence[0]
const completed = readProviderTurnEvidence({
event: {
paneKey: 'tab:leaf',
source: 'codex',
hookEventName: 'Stop',
providerTurnId: 'turn-1',
payload: { state: 'done', prompt: 'ship it', agentType: 'codex' }
},
observedAt: 2
}).evidence[0]
expect(started).toBeDefined()
expect(completed).toBeDefined()
let state = applyEvidence(createAgentTurnLifecycleState(owner), requireValue(started))
state = applyEvidence(state, requireValue(completed))
expect(readAgentTurnLifecycleSnapshot(state).turns).toContainEqual(
expect.objectContaining({
turnId: 'turn-1',
phase: 'settled',
outcome: 'completed'
})
)
const duplicate = requireValue(
providerEvidenceToLifecycleEvents(owner, requireValue(completed))[0]
)
expect(reduceAgentTurnLifecycle(state, duplicate).disposition).toBe('duplicate')
})
it('keeps interrupt input delivery separate from provider acknowledgement', () => {
let state = reduceAgentTurnLifecycle(createAgentTurnLifecycleState(owner), startedEvent()).state
state = reduceAgentTurnLifecycle(state, {
kind: 'turn-interrupt-input-written',
owner,
turnId: 'turn-1',
evidence: { eventId: 'input-written', producerId: 'orc:pty', observedAt: 2 }
}).state
expect(readAgentTurnLifecycleSnapshot(state).currentTurn).toMatchObject({
phase: 'active',
outcome: null,
interruptInputWrittenAt: 2
})
const acknowledgement = readProviderInterruptAcknowledgement({
source: 'codex',
paneKey: 'tab:leaf',
turnId: 'turn-1',
acknowledgedBy: 'provider-hook',
observedAt: 3
}).evidence[0]
state = applyEvidence(state, requireValue(acknowledgement))
expect(readAgentTurnLifecycleSnapshot(state).turns).toContainEqual(
expect.objectContaining({
phase: 'settled',
outcome: 'interrupted',
interrupt: 'acknowledged'
})
)
})
it('allows a matching terminal record to recover a missed start', () => {
const evidence = readProviderTerminalTurnRecord({
source: 'codex',
paneKey: 'tab:leaf',
runId: owner.runId,
executionId: owner.attachment.executionId,
record: {
runId: owner.runId,
executionId: owner.attachment.executionId,
turnId: 'turn-recovered',
outcome: 'completed'
},
observedAt: 4
}).evidence[0]
const event = requireValue(providerEvidenceToLifecycleEvents(owner, requireValue(evidence))[0])
const reduced = reduceAgentTurnLifecycle(createAgentTurnLifecycleState(owner), event)
expect(reduced.disposition).toBe('accepted')
expect(readAgentTurnLifecycleSnapshot(reduced.state).turns).toContainEqual(
expect.objectContaining({ turnId: 'turn-recovered', phase: 'settled', outcome: 'completed' })
)
})
it('does not treat an anonymous Stop as a lifecycle event', () => {
const evidence = readProviderTurnEvidence({
event: {
paneKey: 'tab:leaf',
source: 'codex',
hookEventName: 'Stop',
payload: { state: 'done', prompt: '', agentType: 'codex' }
}
})
expect(evidence.evidence).toHaveLength(0)
})
it('preserves joined-child and resident-background inventory semantics', () => {
const evidence = readProviderTurnEvidence({
event: {
paneKey: 'tab:leaf',
source: 'codex',
hookEventName: 'inventory',
currentTurnInventory: {
turnId: 'turn-1',
joinedChildren: [{ workId: 'child', kind: 'joined-child', phase: 'active' }],
residentBackground: [{ workId: 'monitor', kind: 'resident-background', phase: 'active' }]
},
currentTurnInventoryComplete: true,
payload: { state: 'working', prompt: '', agentType: 'codex' }
}
}).evidence[0]
const event = requireValue(providerEvidenceToLifecycleEvents(owner, requireValue(evidence))[0])
const turnStart = startedEvent()
const reduced = reduceAgentTurnLifecycle(
reduceAgentTurnLifecycle(createAgentTurnLifecycleState(owner), {
...turnStart,
evidence: { eventId: 'turn-start', producerId: 'provider:codex', observedAt: 1 }
}).state,
event
)
const snapshot = readAgentTurnLifecycleSnapshot(reduced.state)
expect(snapshot.joinedChildren).toContainEqual(
expect.objectContaining({ workId: 'child', kind: 'joined-child' })
)
expect(snapshot.residentBackground).toContainEqual(
expect.objectContaining({ workId: 'monitor', kind: 'resident-background' })
)
})
})
@@ -0,0 +1,124 @@
import type {
AgentCurrentTurnInventory,
AgentTurnEvidence,
AgentTurnLifecycleEvent,
AgentTurnOwner
} from '../agent-turn-lifecycle'
import type { ProviderTurnEvidence } from './provider-turn-evidence'
function lifecycleEvidence(evidence: ProviderTurnEvidence): AgentTurnEvidence {
return {
eventId: evidence.eventId,
producerId: evidence.producerId,
observedAt: evidence.observedAt
}
}
function inventoryFor(evidence: ProviderTurnEvidence): AgentCurrentTurnInventory | null {
const inventory = evidence.inventory
if (!inventory) {
return null
}
return {
turnId: inventory.turnId,
...(inventory.startedAt !== undefined ? { startedAt: inventory.startedAt } : {}),
joinedChildren: inventory.joinedChildren.map((work) => ({
workId: work.workId,
phase: work.phase,
...(work.outcome ? { outcome: work.outcome } : {}),
...(work.startedAt !== undefined ? { startedAt: work.startedAt } : {}),
...(work.settledAt !== undefined ? { settledAt: work.settledAt } : {})
})),
residentBackground: inventory.residentBackground.map((work) => ({
workId: work.workId,
phase: work.phase,
...(work.outcome ? { outcome: work.outcome } : {}),
...(work.startedAt !== undefined ? { startedAt: work.startedAt } : {}),
...(work.settledAt !== undefined ? { settledAt: work.settledAt } : {})
}))
}
}
/**
* Converts provider facts into C1 lifecycle events. This adapter deliberately has no state and
* never chooses a row status; the host supplies the committed owner and invokes the reducer.
*/
export function providerEvidenceToLifecycleEvents(
owner: AgentTurnOwner,
evidence: ProviderTurnEvidence
): AgentTurnLifecycleEvent[] {
const base = { owner, evidence: lifecycleEvidence(evidence) }
switch (evidence.kind) {
case 'turn-started':
return evidence.turnId
? [
{
...base,
kind: 'turn-started',
turnId: evidence.turnId,
startedAt: evidence.observedAt
}
]
: []
case 'turn-outcome-observed':
return evidence.turnId && evidence.outcome !== undefined && evidence.outcome !== 'interrupted'
? [
{
...base,
kind: 'turn-outcome-observed',
turnId: evidence.turnId,
outcome: evidence.outcome,
settledAt: evidence.observedAt,
recordKind: evidence.recordKind ?? 'event'
}
]
: []
case 'turn-interrupt-acknowledged':
return evidence.turnId
? [
{
...base,
kind: 'turn-interrupt-acknowledged',
turnId: evidence.turnId,
settledAt: evidence.observedAt
}
]
: []
case 'work-started':
return evidence.turnId && evidence.workId && evidence.workKind
? [
{
...base,
kind: 'work-started',
turnId: evidence.turnId,
workId: evidence.workId,
workKind: evidence.workKind,
startedAt: evidence.observedAt
}
]
: []
case 'work-outcome-observed':
return evidence.turnId && evidence.workId && evidence.workKind && evidence.outcome
? [
{
...base,
kind: 'work-outcome-observed',
turnId: evidence.turnId,
workId: evidence.workId,
workKind: evidence.workKind,
outcome: evidence.outcome,
settledAt: evidence.observedAt
}
]
: []
case 'current-turn-inventory':
return [
{
...base,
kind: 'current-turn-inventory',
complete: true,
currentTurn: inventoryFor(evidence)
}
]
}
}
@@ -0,0 +1,65 @@
import { normalizeProviderTurnIdentity } from './provider-turn-identity'
import { providerTurnEventId } from './provider-turn-event-id'
import type {
ProviderTerminalTurnRecordInput,
ProviderTurnEvidenceRead
} from './provider-turn-evidence-types'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Terminal records recover a missed start only when run and attachment keys match. */
export function readProviderTerminalTurnRecord(
input: ProviderTerminalTurnRecordInput
): ProviderTurnEvidenceRead {
if (!isRecord(input.record)) {
return { evidence: [], ignored: 'unsupported' }
}
const record = input.record
const turnId = normalizeProviderTurnIdentity(record.turnId ?? record.turn_id ?? record.id)
const recordRunId = normalizeProviderTurnIdentity(record.runId ?? record.run_id)
const recordExecutionId = normalizeProviderTurnIdentity(record.executionId ?? record.execution_id)
if (!turnId || !recordRunId || !recordExecutionId) {
return { evidence: [], ignored: 'anonymous-outcome' }
}
if (
recordRunId !== normalizeProviderTurnIdentity(input.runId) ||
recordExecutionId !== normalizeProviderTurnIdentity(input.executionId)
) {
return { evidence: [], ignored: 'anonymous-outcome' }
}
const outcome =
record.outcome === 'interrupted' || record.state === 'interrupted'
? 'interrupted'
: record.outcome === 'failed' || record.state === 'failed'
? 'failed'
: record.outcome === 'completed' || record.state === 'completed'
? 'completed'
: null
if (!outcome) {
return { evidence: [], ignored: 'unsupported' }
}
const observedAt = input.observedAt ?? Date.now()
return {
evidence: [
{
source: input.source,
producerId: `provider:${input.source}`,
eventId: providerTurnEventId(
input.source,
input.paneKey,
'terminal_record',
turnId,
outcome,
'terminal-record'
),
observedAt,
kind: outcome === 'interrupted' ? 'turn-interrupt-acknowledged' : 'turn-outcome-observed',
turnId,
outcome,
recordKind: 'terminal-record'
}
]
}
}
+4
View File
@@ -87,6 +87,10 @@ export type AgentHookRelayEnvelope = {
hookEventName?: string
/** Provider-owned turn identity (Claude UUID or opaque Grok prompt id). */
providerPromptId?: string
/** Provider-owned turn identity when a hook exposes a field other than prompt_id. */
providerTurnId?: string
/** Provider-owned terminal marker; optional for mixed-version relay peers. */
providerTurnTerminal?: boolean
/** The row belongs to an observed Grok prompt boundary whose opaque id may be absent. */
grokPromptBoundary?: true
/** Active Claude compact generation, keyed by provider prompt identity. */
+16
View File
@@ -0,0 +1,16 @@
const MAX_AGENT_TURN_EVIDENCE_ID_LENGTH = 512
/** Retain stable evidence entropy without exceeding the lifecycle contract's identifier bound. */
export function boundedAgentTurnEvidenceId(value: string): string {
if (value.length <= MAX_AGENT_TURN_EVIDENCE_ID_LENGTH) {
return value
}
let first = 0x811c9dc5
let second = 0x9e3779b9
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
first = Math.imul(first ^ code, 0x01000193)
second = Math.imul(second ^ (code + index), 0x01000193)
}
return `agent-turn-evidence:digest:${(first >>> 0).toString(16)}${(second >>> 0).toString(16)}`
}