diff --git a/src/main/agent-hooks/server-turn-lifecycle.test.ts b/src/main/agent-hooks/server-turn-lifecycle.test.ts new file mode 100644 index 00000000000..bd61d26da54 --- /dev/null +++ b/src/main/agent-hooks/server-turn-lifecycle.test.ts @@ -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) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 3fb3f51f5f1..2afe6d95c13 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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() } } diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 5425acec7f2..274cd6edf27 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -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( diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index d14714ae09b..3f12ac800e3 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -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, diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e7f68829f3b..90e12533ba9 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -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 diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 44e2c940753..446493c8cf7 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -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) diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 6d811216f9f..ca1c91fb251 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -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). diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 0df8ff70445..8c18923a82f 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -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 diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 49575fe223e..2efcce9258e 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -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) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 04325e26bf5..c5e5ee93af7 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -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) diff --git a/src/main/agent-hooks/server/server-turn-lifecycle-event.ts b/src/main/agent-hooks/server/server-turn-lifecycle-event.ts new file mode 100644 index 00000000000..80df7031193 --- /dev/null +++ b/src/main/agent-hooks/server/server-turn-lifecycle-event.ts @@ -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 + } + } +} diff --git a/src/main/agent-hooks/server/server-turn-lifecycle.ts b/src/main/agent-hooks/server/server-turn-lifecycle.ts new file mode 100644 index 00000000000..4f59a7acf75 --- /dev/null +++ b/src/main/agent-hooks/server/server-turn-lifecycle.ts @@ -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[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 + } + }) + } +} diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index b4cf176b176..a47601b625a 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -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. diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index f8e3420720a..cd5e73819be 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -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 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') diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index f460be06b5f..5f2b7c0676a 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -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 diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 7aa6c21115d..5f2186e40af 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -16,6 +16,8 @@ export type AgentStatusApi = { /** Return the current main-process hook cache after renderer hydration. */ getSnapshot: () => Promise inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise + /** Record fire-and-forget interrupt input without treating delivery as provider acknowledgement. */ + recordInterruptInputWritten?: (paneKey: string) => Promise /** 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 /** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */ diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 3cc1654aaed..21abb3130b3 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -28,6 +28,8 @@ export const agentStatusApi = { ipcRenderer.invoke('agentStatus:getSnapshot'), inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferInterrupt', request), + recordInterruptInputWritten: (paneKey: string): Promise => + ipcRenderer.invoke('agentStatus:recordInterruptInputWritten', paneKey), inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), onMigrationUnsupported: ( diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 6b1b45432d8..577a22c70c4 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts index 4b1f376b773..9c6f1cc4e08 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts @@ -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 () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts index eb3a40b6803..18da80f0307 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts @@ -73,6 +73,7 @@ export function installTerminalTestGlobals(): void { }, agentStatus: { inferInterrupt: vi.fn().mockResolvedValue(false), + recordInterruptInputWritten: vi.fn().mockResolvedValue(false), reconcileEndedProcess: vi.fn() } }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts index 3c8166b2410..a75369d359e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -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) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-interrupt-input-evidence.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-interrupt-input-evidence.ts new file mode 100644 index 00000000000..05f982bdfba --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-interrupt-input-evidence.ts @@ -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) + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-pane-geometry-state.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-pane-geometry-state.ts new file mode 100644 index 00000000000..7faf2e5a43a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-pane-geometry-state.ts @@ -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 +} diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts index d7c9740018c..09a79045ea8 100644 --- a/src/renderer/src/web/preload-api/web-agent-status-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-status-api.ts @@ -8,6 +8,7 @@ export function createWebAgentStatusApi(): Partial { onClear: () => noopUnsubscribe, getSnapshot: () => Promise.resolve([]), inferInterrupt: () => Promise.resolve(false), + recordInterruptInputWritten: () => Promise.resolve(false), inferQuestionAnswered: () => Promise.resolve(false), onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 7df46e74cbc..f74852ed3d9 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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 } diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 48797d09d8f..a735a0b7ddc 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -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. */ diff --git a/src/shared/agent-hook-listener/provider-turn-event-id.ts b/src/shared/agent-hook-listener/provider-turn-event-id.ts new file mode 100644 index 00000000000..9db78ae973a --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-event-id.ts @@ -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) +} diff --git a/src/shared/agent-hook-listener/provider-turn-evidence-types.ts b/src/shared/agent-hook-listener/provider-turn-evidence-types.ts new file mode 100644 index 00000000000..52de7fdab97 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-evidence-types.ts @@ -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 +} diff --git a/src/shared/agent-hook-listener/provider-turn-evidence.test.ts b/src/shared/agent-hook-listener/provider-turn-evidence.test.ts new file mode 100644 index 00000000000..126188f6d8d --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-evidence.test.ts @@ -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'] { + 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([]) + }) +}) diff --git a/src/shared/agent-hook-listener/provider-turn-evidence.ts b/src/shared/agent-hook-listener/provider-turn-evidence.ts new file mode 100644 index 00000000000..7697d34e79a --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-evidence.ts @@ -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' + } + ] + } +} diff --git a/src/shared/agent-hook-listener/provider-turn-identity.ts b/src/shared/agent-hook-listener/provider-turn-identity.ts new file mode 100644 index 00000000000..d275e5392c9 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-identity.ts @@ -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 diff --git a/src/shared/agent-hook-listener/provider-turn-inventory.ts b/src/shared/agent-hook-listener/provider-turn-inventory.ts new file mode 100644 index 00000000000..edf1e8d6164 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-inventory.ts @@ -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 { + 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 + } +} diff --git a/src/shared/agent-hook-listener/provider-turn-lifecycle.test.ts b/src/shared/agent-hook-listener/provider-turn-lifecycle.test.ts new file mode 100644 index 00000000000..0e8cce5a120 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-lifecycle.test.ts @@ -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(value: T | undefined): T { + if (value === undefined) { + throw new Error('expected fixture value') + } + return value +} + +function applyEvidence( + state: ReturnType, + evidence: Parameters[1] +) { + let current = state + for (const event of providerEvidenceToLifecycleEvents(owner, evidence)) { + current = reduceAgentTurnLifecycle(current, event).state + } + return current +} + +function startedEvent(): Extract { + 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' }) + ) + }) +}) diff --git a/src/shared/agent-hook-listener/provider-turn-lifecycle.ts b/src/shared/agent-hook-listener/provider-turn-lifecycle.ts new file mode 100644 index 00000000000..0246647c0ee --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-lifecycle.ts @@ -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) + } + ] + } +} diff --git a/src/shared/agent-hook-listener/provider-turn-terminal-record.ts b/src/shared/agent-hook-listener/provider-turn-terminal-record.ts new file mode 100644 index 00000000000..b196c8c3043 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-turn-terminal-record.ts @@ -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 { + 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' + } + ] + } +} diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index dadfa590a4e..9f6fe119646 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -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. */ diff --git a/src/shared/agent-turn-evidence-id.ts b/src/shared/agent-turn-evidence-id.ts new file mode 100644 index 00000000000..e0cbd819b1d --- /dev/null +++ b/src/shared/agent-turn-evidence-id.ts @@ -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)}` +}