mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
feat(agent-status): reconcile provider turn evidence
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user