diff --git a/src/context-projection/recovery-live.ts b/src/context-projection/recovery-live.ts new file mode 100644 index 0000000000..e66a4d544d --- /dev/null +++ b/src/context-projection/recovery-live.ts @@ -0,0 +1,158 @@ +import type { OcxContext, OcxParsedRequest } from "../types"; +import type { InternalToolCallV1, InternalToolExecutionV1 } from "../internal-tools/types"; +import { recoveryEligibilityV1, type RecoverySuspensionReasonV1 } from "./capability"; +import { LIVE_DUPLICATE_CONTEXT_PROJECTION_POLICY_V1, LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1 } from "./policy"; +import { applyContextProjectionPlan, planContextProjection } from "./projector"; +import { ContextRecoverySessionV1 } from "./recovery"; +import { executeContextRecoveryCallV1 } from "./recovery-tool"; +import { addContextRecoveryToolV1 } from "./synthetic-tool"; +import type { ContextRecoverySidecarV1 } from "./composition"; +import type { + ContextProjectionContinuationV1, + ContextProjectionMetricsV1, + ContextProjectionSuspensionReason, +} from "./types"; + +export type LiveRecoverySuspensionReasonV1 = RecoverySuspensionReasonV1 + | "inactive_epoch" + | "compaction" + | "tool_name_collision" + | "planner"; + +export type LiveRecoveryProjectionV1 = + | { + active: false; + providerContext: OcxContext; + reason: LiveRecoverySuspensionReasonV1; + metrics?: ContextProjectionMetricsV1; + } + | { + active: true; + providerContext: OcxContext; + metrics: ContextProjectionMetricsV1; + session: ContextRecoverySessionV1; + execute: (call: InternalToolCallV1) => InternalToolExecutionV1; + }; + +export interface PrepareLiveRecoveryProjectionV1Options { + epoch?: ContextProjectionContinuationV1; + adapterKind: "stateless" | "runTurn"; + providerStateful?: boolean; + isPassthrough: boolean; + isCompaction?: boolean; + hasSidecar?: boolean; + sidecarKind?: ContextRecoverySidecarV1; + collapseDuplicatesFirst?: boolean; + recoveryToolSurvivesBudget: boolean; + /** Exact logical-to-wire tool-name transform used by the selected provider adapter. */ + toWireName?: (logicalName: string) => string; + signal?: AbortSignal; +} + + +function applyDuplicatePrePassV1(canonicalContext: OcxContext, signal?: AbortSignal): OcxContext { + const plan = planContextProjection(canonicalContext, LIVE_DUPLICATE_CONTEXT_PROJECTION_POLICY_V1, { signal }); + if (plan.eligibility.state !== "eligible") return canonicalContext; + return applyContextProjectionPlan(canonicalContext, plan); +} +function suspendedRecoveryMetricsV1( + reason: ContextProjectionSuspensionReason, + sidecarKind?: ContextRecoverySidecarV1, +): ContextProjectionMetricsV1 { + return { + version: 1, + variant: "recovery", + candidates: 0, + duplicateCandidates: 0, + duplicateProjected: 0, + largeCandidates: 0, + largeProjected: 0, + originalBytes: 0, + projectedBytes: 0, + planningMs: 0, + plannerScannedBytes: 0, + hashScannedBytes: 0, + recoveryCalls: 0, + recoveryBytes: 0, + recoveryMisses: 0, + recoveryLimitHits: 0, + recoveryScanBytes: 0, + internalModelRounds: 0, + failOpenRestarts: 0, + receiptActiveTurns: 0, + receiptActiveTurnsWithRecovery: 0, + suspensionReason: reason, + ...(sidecarKind ? { sidecarKind } : {}), + }; +} + +function suspendedRecoveryProjectionV1( + providerContext: OcxContext, + reason: LiveRecoverySuspensionReasonV1, + metrics: ContextProjectionMetricsV1 = suspendedRecoveryMetricsV1(reason), +): LiveRecoveryProjectionV1 { + return { active: false, providerContext, reason, metrics }; +} + +export function prepareLiveRecoveryProjectionV1( + parsed: OcxParsedRequest, + canonicalContext: OcxContext, + options: PrepareLiveRecoveryProjectionV1Options, +): LiveRecoveryProjectionV1 { + if ( + options.epoch?.state !== "active" + || options.epoch.variant !== "recovery-v1" + || options.epoch.policyId !== LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1.policyId + ) return suspendedRecoveryProjectionV1(canonicalContext, "inactive_epoch"); + if (options.isCompaction) return suspendedRecoveryProjectionV1(canonicalContext, "compaction"); + if (options.hasSidecar) { + return suspendedRecoveryProjectionV1( + canonicalContext, + "sidecar", + suspendedRecoveryMetricsV1("sidecar", options.sidecarKind), + ); + } + + const eligibility = recoveryEligibilityV1(parsed, { + adapterKind: options.adapterKind, + providerStateful: options.providerStateful === true, + isPassthrough: options.isPassthrough, + recoveryToolSurvivesBudget: options.recoveryToolSurvivesBudget, + }); + if (!eligibility.eligible) { + return suspendedRecoveryProjectionV1(canonicalContext, eligibility.reason); + } + + const addedTool = addContextRecoveryToolV1(canonicalContext.tools, { + ...(options.toWireName ? { toWireName: options.toWireName } : {}), + }); + if (!addedTool.ok) { + return suspendedRecoveryProjectionV1(canonicalContext, "tool_name_collision"); + } + + const planningContext = options.collapseDuplicatesFirst + ? applyDuplicatePrePassV1(canonicalContext, options.signal) + : canonicalContext; + const plan = planContextProjection(planningContext, LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1, { + signal: options.signal, + }); + if (plan.eligibility.state !== "eligible") { + // Correctness beats cache continuity: if projection itself cannot be constructed safely, + // advertise no internal tool and send the untouched provider context for this request. + return suspendedRecoveryProjectionV1(canonicalContext, "planner", plan.metrics); + } + + const projected = applyContextProjectionPlan(planningContext, plan); + const providerContext = { ...projected, tools: addedTool.tools }; + const session = new ContextRecoverySessionV1(plan.registry, options.signal); + return { + active: true, + providerContext, + metrics: plan.metrics, + session, + execute: call => executeContextRecoveryCallV1(session, call), + }; +} + + + diff --git a/src/context-projection/recovery-tool.ts b/src/context-projection/recovery-tool.ts new file mode 100644 index 0000000000..ed2a153470 --- /dev/null +++ b/src/context-projection/recovery-tool.ts @@ -0,0 +1,115 @@ +import type { OcxToolResultMessage } from "../types"; +import type { InternalToolCallV1, InternalToolExecutionV1 } from "../internal-tools/types"; +import { ContextRecoverySessionV1, type ContextRecoveryErrorV1 } from "./recovery"; +import { CONTEXT_RECOVERY_TOOL_NAME } from "./synthetic-tool"; + +const RECOVERABLE_LOOKUP_ERRORS = new Set([ + "not_found", + "stale_cursor", + "invalid_range", + "line_limit", + "query_too_long", + "empty_query", +]); + +function objectArgs(text: string): Record | null { + try { + const value: unknown = JSON.parse(text || "{}"); + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + } catch { + return null; + } +} + +function optionalPositiveInteger(value: unknown): number | undefined | null { + if (value === undefined) return undefined; + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; +} + +function toolResult(callId: string, payload: unknown, isError: boolean): OcxToolResultMessage { + return { + role: "toolResult", + toolCallId: callId, + toolName: CONTEXT_RECOVERY_TOOL_NAME, + content: JSON.stringify(payload), + isError, + timestamp: Date.now(), + }; +} + +function recoverableProtocolError( + call: InternalToolCallV1, + error: "invalid_arguments" | "invalid_operation", + op?: string, + ref?: string, +): InternalToolExecutionV1 { + return { + ok: true, + toolResult: toolResult(call.id, { + ok: false, + ...(op ? { op } : {}), + ...(ref ? { ref } : {}), + error, + }, true), + }; +} + +function recoveryFailureOrToolError( + call: InternalToolCallV1, + op: "read" | "grep", + ref: string, + error: ContextRecoveryErrorV1, +): InternalToolExecutionV1 { + if (!RECOVERABLE_LOOKUP_ERRORS.has(error)) return { ok: false, reason: error }; + return { + ok: true, + toolResult: toolResult(call.id, { ok: false, op, ref, error }, true), + }; +} + +/** + * Execute one request-local bounded exact-recovery call. + * Correctable lookup/schema mistakes are returned to the hidden model as bounded tool errors. + * Hard outer budgets, cancellation, and executor mismatches abandon the hidden trajectory so the + * caller can restart once from untouched canonical state. + */ +export function executeContextRecoveryCallV1( + session: ContextRecoverySessionV1, + call: InternalToolCallV1, +): InternalToolExecutionV1 { + if (call.name !== CONTEXT_RECOVERY_TOOL_NAME) return { ok: false, reason: "wrong_tool" }; + const args = objectArgs(call.argumentsText); + if (!args || typeof args.op !== "string" || typeof args.ref !== "string" || args.ref.length === 0) { + return recoverableProtocolError(call, "invalid_arguments"); + } + + if (args.op === "read") { + const startLine = optionalPositiveInteger(args.start_line); + const endLine = optionalPositiveInteger(args.end_line); + const maxBytes = optionalPositiveInteger(args.max_bytes); + if (startLine === null || endLine === null || maxBytes === null || (args.cursor !== undefined && typeof args.cursor !== "string")) { + return recoverableProtocolError(call, "invalid_arguments", "read", args.ref); + } + const result = session.read(args.ref, { + ...(startLine !== undefined ? { startLine } : {}), + ...(endLine !== undefined ? { endLine } : {}), + ...(typeof args.cursor === "string" ? { cursor: args.cursor } : {}), + ...(maxBytes !== undefined ? { maxBytes } : {}), + }); + if (!result.ok) return recoveryFailureOrToolError(call, "read", args.ref, result.error); + return { ok: true, toolResult: toolResult(call.id, { op: "read", ref: args.ref, ...result }, false) }; + } + + if (args.op === "grep") { + if (typeof args.query !== "string") { + return recoverableProtocolError(call, "invalid_arguments", "grep", args.ref); + } + const result = session.grepLiteral(args.ref, { query: args.query }); + if (!result.ok) return recoveryFailureOrToolError(call, "grep", args.ref, result.error); + return { ok: true, toolResult: toolResult(call.id, { op: "grep", ref: args.ref, ...result }, false) }; + } + + return recoverableProtocolError(call, "invalid_operation", args.op, args.ref); +} diff --git a/src/context-projection/runtime.ts b/src/context-projection/runtime.ts index fc0d9b1a37..7e3a1a58e3 100644 --- a/src/context-projection/runtime.ts +++ b/src/context-projection/runtime.ts @@ -18,39 +18,34 @@ export interface ResolveLiveContextProjectionEpochV1Input { } /** - * CPG-04 intentionally activates only duplicate-v1. Recovery config is accepted by - * the parser but remains non-live until the recovery protocol/loop exists. + * Projection policy is latched for a Responses chain. Existing duplicate/recovery epochs keep + * their original variant; legacy chains without metadata fail open disabled instead of upgrading. + * Native passthrough never starts or resumes CPG and permanently disables an inherited epoch. */ export function resolveLiveContextProjectionEpochV1( input: ResolveLiveContextProjectionEpochV1Input, ): ContextProjectionContinuationV1 | undefined { if (input.isPassthrough) { if (!input.previous) return undefined; - if (input.previous.variant !== "duplicate-v1") { - return { ...input.previous, state: "disabled" }; - } return resolveContextProjectionEpochV1({ previousResponseId: input.previousResponseId, previous: input.previous, - requestedMode: "duplicate", + requestedMode: input.previous.variant === "recovery-v1" ? "recovery" : "duplicate", emergencyDisable: true, }); } if (input.previous) { - if (input.previous.variant !== "duplicate-v1") { - return { ...input.previous, state: "disabled" }; - } return resolveContextProjectionEpochV1({ previousResponseId: input.previousResponseId, previous: input.previous, - requestedMode: "duplicate", + requestedMode: input.previous.variant === "recovery-v1" ? "recovery" : "duplicate", emergencyDisable: input.emergencyDisable, }); } - if (input.requestedMode !== "duplicate") return undefined; + if (input.requestedMode !== "duplicate" && input.requestedMode !== "recovery" && input.requestedMode !== "on") return undefined; return resolveContextProjectionEpochV1({ previousResponseId: input.previousResponseId, - requestedMode: "duplicate", + requestedMode: input.requestedMode, emergencyDisable: input.emergencyDisable, }); } @@ -92,3 +87,4 @@ export function applyDuplicateProjectionV1( metrics: plan.metrics, }; } + diff --git a/src/internal-tools/events.ts b/src/internal-tools/events.ts new file mode 100644 index 0000000000..90a93d0955 --- /dev/null +++ b/src/internal-tools/events.ts @@ -0,0 +1,174 @@ +import type { + AdapterEvent, + OcxAssistantContentPart, + OcxAssistantMessage, + OcxThinkingContent, + OcxUsage, +} from "../types"; +import type { InternalGenerationScanV1, InternalToolCallV1 } from "./types"; + +function flushCall( + pending: InternalToolCallV1 | undefined, + internalToolNames: ReadonlySet, + scan: InternalGenerationScanV1, +): undefined { + if (!pending) return undefined; + if (internalToolNames.has(pending.name)) scan.internalCalls.push(pending); + else scan.hasClientToolCall = true; + return undefined; +} + +export function scanInternalGenerationV1( + events: readonly AdapterEvent[], + internalToolNames: ReadonlySet, +): InternalGenerationScanV1 { + const scan: InternalGenerationScanV1 = { + internalCalls: [], + hasClientToolCall: false, + hasAssistantText: false, + hasError: false, + }; + let pending: InternalToolCallV1 | undefined; + for (const event of events) { + if (event.type === "tool_call_start") { + pending = flushCall(pending, internalToolNames, scan); + pending = { + id: event.id, + name: event.name, + argumentsText: "", + ...(event.providerMetadata ? { providerMetadata: event.providerMetadata } : {}), + }; + } else if (event.type === "tool_call_delta" && pending) { + pending.argumentsText += event.arguments; + } else if (event.type === "tool_call_end") { + pending = flushCall(pending, internalToolNames, scan); + } else { + if (event.type === "text_delta" && event.text.length > 0) scan.hasAssistantText = true; + if (event.type === "error") scan.hasError = true; + } + } + flushCall(pending, internalToolNames, scan); + return scan; +} + +function parseArgumentsObject(text: string): Record { + try { + const value: unknown = JSON.parse(text || "{}"); + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + } catch { + return {}; + } +} + +function extractThinking(events: readonly AdapterEvent[]): OcxThinkingContent[] { + const parts: OcxThinkingContent[] = []; + let thinking = ""; + let signature: string | undefined; + let rawReasoning = ""; + const flushThinking = (): void => { + if (!thinking && !signature) return; + parts.push({ type: "thinking", thinking, ...(signature ? { signature } : {}) }); + thinking = ""; + signature = undefined; + }; + const flushRaw = (): void => { + if (!rawReasoning) return; + parts.push({ type: "thinking", thinking: rawReasoning }); + rawReasoning = ""; + }; + for (const event of events) { + if (event.type === "thinking_delta") { + flushRaw(); + thinking += event.thinking; + } else if (event.type === "thinking_signature") { + signature = event.signature; + flushThinking(); + } else if (event.type === "redacted_thinking") { + flushThinking(); + flushRaw(); + parts.push({ type: "thinking", thinking: "", redacted: [event.data] }); + } else if (event.type === "reasoning_raw_delta") { + flushThinking(); + rawReasoning += event.text; + } + } + flushThinking(); + flushRaw(); + return parts; +} + +/** + * Build only the provider-private portion that is safe to replay after recovery. + * Client-visible text and non-internal tool calls from the same generation are deliberately omitted. + */ +export function buildInternalAssistantTurnV1( + events: readonly AdapterEvent[], + calls: readonly InternalToolCallV1[], + timestamp = Date.now(), +): OcxAssistantMessage { + const content: OcxAssistantContentPart[] = [ + ...extractThinking(events), + ...calls.map(call => ({ + type: "toolCall" as const, + id: call.id, + name: call.name, + arguments: parseArgumentsObject(call.argumentsText), + ...(call.providerMetadata ? { providerMetadata: call.providerMetadata } : {}), + })), + ]; + return { role: "assistant", content, timestamp }; +} + +export function mergeUsageV1(a: OcxUsage | undefined, b: OcxUsage | undefined): OcxUsage | undefined { + if (!a) return b; + if (!b) return a; + const inputTokens = a.inputTokens + b.inputTokens; + const outputTokens = a.outputTokens + b.outputTokens; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + ...(a.contextTotalTokens !== undefined || b.contextTotalTokens !== undefined + ? { contextTotalTokens: Math.max(a.contextTotalTokens ?? 0, b.contextTotalTokens ?? 0) } + : {}), + ...(a.cachedInputTokens !== undefined || b.cachedInputTokens !== undefined + ? { cachedInputTokens: (a.cachedInputTokens ?? 0) + (b.cachedInputTokens ?? 0) } + : {}), + ...(a.cacheReadInputTokens !== undefined || b.cacheReadInputTokens !== undefined + ? { cacheReadInputTokens: (a.cacheReadInputTokens ?? 0) + (b.cacheReadInputTokens ?? 0) } + : {}), + ...(a.cacheCreationInputTokens !== undefined || b.cacheCreationInputTokens !== undefined + ? { cacheCreationInputTokens: (a.cacheCreationInputTokens ?? 0) + (b.cacheCreationInputTokens ?? 0) } + : {}), + ...(a.reasoningOutputTokens !== undefined || b.reasoningOutputTokens !== undefined + ? { reasoningOutputTokens: (a.reasoningOutputTokens ?? 0) + (b.reasoningOutputTokens ?? 0) } + : {}), + ...(a.estimated || b.estimated ? { estimated: true } : {}), + }; +} + +export function terminalUsageV1(events: readonly AdapterEvent[]): OcxUsage | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === "done" || event?.type === "incomplete" || event?.type === "error") return event.usage; + } + return undefined; +} + +export function addHiddenUsageToVisibleTerminalV1( + events: readonly AdapterEvent[], + hidden: OcxUsage | undefined, +): AdapterEvent[] { + if (!hidden) return [...events]; + const copy = [...events]; + for (let index = copy.length - 1; index >= 0; index -= 1) { + const event = copy[index]; + if (event?.type === "done" || event?.type === "incomplete" || event?.type === "error") { + copy[index] = { ...event, usage: mergeUsageV1(hidden, event.usage) } as AdapterEvent; + break; + } + } + return copy; +} diff --git a/src/internal-tools/loop.ts b/src/internal-tools/loop.ts new file mode 100644 index 0000000000..66aebb95de --- /dev/null +++ b/src/internal-tools/loop.ts @@ -0,0 +1,101 @@ +import type { AdapterEvent, OcxContext, OcxUsage } from "../types"; +import { MAX_CONTEXT_INTERNAL_MODEL_ROUNDS_PER_OUTER_TURN } from "../context-projection/recovery"; +import { + addHiddenUsageToVisibleTerminalV1, + buildInternalAssistantTurnV1, + mergeUsageV1, + scanInternalGenerationV1, + terminalUsageV1, +} from "./events"; +import type { + InternalToolCallV1, + InternalToolExecutionV1, + RecoveryPriorityLoopResultV1, +} from "./types"; + +export interface RecoveryPriorityLoopOptionsV1 { + initialContext: OcxContext; + internalToolNames: ReadonlySet; + dispatch: (providerContext: OcxContext, round: number) => Promise; + execute: (call: InternalToolCallV1) => Promise | InternalToolExecutionV1; + maxRounds?: number; + signal?: AbortSignal; +} + +/** + * Buffer each model generation until we know whether it contains an internal recovery call. + * A recovery-bearing generation is never exposed. Only signed/raw thinking plus the internal + * calls themselves are replayed provider-side; stale text and client tool calls are discarded. + */ +export async function runRecoveryPriorityLoopV1( + options: RecoveryPriorityLoopOptionsV1, +): Promise { + const maxRounds = Math.max( + 1, + Math.min( + MAX_CONTEXT_INTERNAL_MODEL_ROUNDS_PER_OUTER_TURN, + Math.floor(options.maxRounds ?? MAX_CONTEXT_INTERNAL_MODEL_ROUNDS_PER_OUTER_TURN), + ), + ); + let providerContext = options.initialContext; + let hiddenRounds = 0; + let recoveryCalls = 0; + let hiddenUsage: OcxUsage | undefined; + + const failOpen = ( + reason: "recovery_failed" | "round_limit" | "provider_error" | "aborted", + usage = hiddenUsage, + ): RecoveryPriorityLoopResultV1 => ({ + kind: "fail_open", + reason, + hiddenRounds, + recoveryCalls, + ...(usage ? { hiddenUsage: usage } : {}), + }); + + for (;;) { + if (options.signal?.aborted) return failOpen("aborted"); + + let events: AdapterEvent[]; + try { + events = await options.dispatch(providerContext, hiddenRounds); + } catch { + return failOpen("provider_error"); + } + const scan = scanInternalGenerationV1(events, options.internalToolNames); + + if (scan.internalCalls.length === 0) { + return { + kind: "complete", + events: addHiddenUsageToVisibleTerminalV1(events, hiddenUsage), + providerContext, + hiddenRounds, + recoveryCalls, + }; + } + + // Once a generation contains an internal call it is abandoned from client-visible history, + // but any terminal usage already reported by the provider remains billable and must survive + // every fail-open exit, including the generation that trips the hidden-round limit. + const generationUsage = mergeUsageV1(hiddenUsage, terminalUsageV1(events)); + hiddenRounds += 1; + recoveryCalls += scan.internalCalls.length; + if (scan.hasError) return failOpen("provider_error", generationUsage); + if (hiddenRounds >= maxRounds) return failOpen("round_limit", generationUsage); + + const toolResults = []; + for (const call of scan.internalCalls) { + if (options.signal?.aborted) return failOpen("aborted", generationUsage); + const executed = await options.execute(call); + if (!executed.ok) return failOpen("recovery_failed", generationUsage); + toolResults.push(executed.toolResult); + } + + hiddenUsage = generationUsage; + const assistant = buildInternalAssistantTurnV1(events, scan.internalCalls); + providerContext = { + ...providerContext, + messages: [...providerContext.messages, assistant, ...toolResults], + }; + } +} diff --git a/src/internal-tools/types.ts b/src/internal-tools/types.ts new file mode 100644 index 0000000000..3f4163f81f --- /dev/null +++ b/src/internal-tools/types.ts @@ -0,0 +1,42 @@ +import type { + AdapterEvent, + OcxContext, + OcxProviderOpaqueToolCallMetadata, + OcxToolResultMessage, + OcxUsage, +} from "../types"; + +export interface InternalToolCallV1 { + id: string; + name: string; + argumentsText: string; + providerMetadata?: OcxProviderOpaqueToolCallMetadata; +} + +export interface InternalGenerationScanV1 { + internalCalls: InternalToolCallV1[]; + hasClientToolCall: boolean; + hasAssistantText: boolean; + hasError: boolean; +} + +export type InternalToolExecutionV1 = + | { ok: true; toolResult: OcxToolResultMessage } + | { ok: false; reason: string }; + +export type RecoveryPriorityLoopResultV1 = + | { + kind: "complete"; + events: AdapterEvent[]; + providerContext: OcxContext; + hiddenRounds: number; + recoveryCalls: number; + } + | { + kind: "fail_open"; + reason: "recovery_failed" | "round_limit" | "provider_error" | "aborted"; + hiddenRounds: number; + recoveryCalls: number; + /** Usage from completed provider generations that are abandoned before canonical restart. */ + hiddenUsage?: OcxUsage; + }; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 5189191bab..ba7cceeaa3 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -11,6 +11,10 @@ import { import { parseRequest } from "../../responses/parser"; import { observeContextProjectionShadow } from "../../context-projection/shadow"; import { applyDuplicateProjectionV1, resolveLiveContextProjectionEpochV1 } from "../../context-projection/runtime"; +import { prepareLiveRecoveryProjectionV1, type LiveRecoveryProjectionV1 } from "../../context-projection/recovery-live"; +import { CONTEXT_RECOVERY_TOOL_NAME } from "../../context-projection/synthetic-tool"; +import { runRecoveryPriorityLoopV1 } from "../../internal-tools/loop"; +import { addHiddenUsageToVisibleTerminalV1 } from "../../internal-tools/events"; import type { ContextProjectionContinuationV1 } from "../../context-projection/types"; import { bindReasoningReplayScope, @@ -2317,16 +2321,39 @@ async function handleResponsesInner( parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() }); } - const duplicateProjection = applyDuplicateProjectionV1(parsed.context, { - isPassthrough, - isCompaction: routedCompaction, - epoch: contextProjectionContinuation, - signal: options.abortSignal, - }); - if (duplicateProjection.metrics) logCtx.contextProjection = duplicateProjection.metrics; - if (duplicateProjection.providerContext !== parsed.context) { - // Provider-only working view. `_rawBody` remains exact canonical state. - parsed.context = duplicateProjection.providerContext; + let recoveryCanonicalParsed: OcxParsedRequest | undefined; + let liveRecoveryProjection: LiveRecoveryProjectionV1 | undefined; + if (contextProjectionContinuation?.variant === "recovery-v1") { + recoveryCanonicalParsed = { ...parsed, context: parsed.context, options: { ...parsed.options } }; + liveRecoveryProjection = prepareLiveRecoveryProjectionV1(parsed, parsed.context, { + epoch: contextProjectionContinuation, + adapterKind: adapter.runTurn ? "runTurn" : "stateless", + isPassthrough, + isCompaction: routedCompaction, + hasSidecar: Boolean(visionPlan || parsed._webSearch || parsed._imageGeneration || config.images?.videoBridgeEnabled === true), + recoveryToolSurvivesBudget: adapter.contextRecoveryPreservesAdvertisedTools === true, + ...(adapter.contextRecoveryToolNameToWire + ? { toWireName: adapter.contextRecoveryToolNameToWire } + : {}), + signal: options.abortSignal, + }); + if (liveRecoveryProjection.metrics) logCtx.contextProjection = liveRecoveryProjection.metrics; + if (liveRecoveryProjection.active) { + // Provider-only working request. The canonical raw body and caller options remain untouched. + parsed = { ...parsed, context: liveRecoveryProjection.providerContext, options: { ...parsed.options } }; + } + } else { + const duplicateProjection = applyDuplicateProjectionV1(parsed.context, { + isPassthrough, + isCompaction: routedCompaction, + epoch: contextProjectionContinuation, + signal: options.abortSignal, + }); + if (duplicateProjection.metrics) logCtx.contextProjection = duplicateProjection.metrics; + if (duplicateProjection.providerContext !== parsed.context) { + // Provider-only working view. `_rawBody` remains exact canonical state. + parsed = { ...parsed, context: duplicateProjection.providerContext, options: { ...parsed.options } }; + } } if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { @@ -4248,8 +4275,123 @@ async function handleResponsesInner( : retryEvents; }; + let recoveryFinalEvents: AdapterEvent[] | undefined; + if (liveRecoveryProjection?.active && recoveryCanonicalParsed) { + const collectResponseEvents = async ( + response: Response, + nextParsed: OcxParsedRequest, + ): Promise => { + if (!response.ok) { + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + throw new Error(`hidden context-recovery provider status ${response.status}`); + } + if (nextParsed.stream) { + const events: AdapterEvent[] = []; + for await (const event of activeAdapter.parseStream(response, translatorBudget)) events.push(event); + return events; + } + if (activeAdapter.parseResponse) return activeAdapter.parseResponse(response, translatorBudget); + throw new Error("hidden context recovery requires a parseable stateless response"); + }; + + let firstGeneration = true; + const dispatchHiddenGeneration = async ( + providerContext: OcxParsedRequest["context"], + ): Promise => { + const nextParsed: OcxParsedRequest = { + ...parsed, + context: providerContext, + options: { ...parsed.options }, + }; + if (firstGeneration) { + firstGeneration = false; + return collectResponseEvents(upstreamResponse, nextParsed); + } + + let hiddenRequest: AdapterRequest | undefined; + try { + hiddenRequest = await activeAdapter.buildRequest(nextParsed, { + headers: selectedForwardHeaders, + translatorBudget, + }); + recordAdapterReasoning(logCtx, hiddenRequest); + const estimate = typeof hiddenRequest.usageLog?.inputTokens === "number" + ? hiddenRequest.usageLog.inputTokens + : undefined; + if (estimate !== undefined) logCtx.usageLogInputTokens = estimate; + noteAttemptSend(logCtx.activeAttempt, estimate); + let response: Response; + if (activeAdapter.fetchResponse) { + await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal); + response = await activeAdapter.fetchResponse(hiddenRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + stream: nextParsed.stream, + }); + } else { + response = await fetchWithResetRetry( + recovery => fetchWithHeaderTimeout( + hiddenRequest!.url, + applyUpstreamRecoveryInit({ + method: hiddenRequest!.method, + headers: hiddenRequest!.headers, + body: hiddenRequest!.body, + }, recovery), + upstream.signal, + connectMs, + nextParsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: nextParsed.modelId, + }), + ), + { abortSignal: upstream.signal, label: safeHostLabel(hiddenRequest.url) }, + ); + } + // Provider/account rotation is intentionally forbidden inside a hidden trajectory. + return await collectResponseEvents(response, nextParsed); + } finally { + hiddenRequest?.releaseBodyObservation?.(); + } + }; + + const loopResult = await runRecoveryPriorityLoopV1({ + initialContext: parsed.context, + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: dispatchHiddenGeneration, + execute: liveRecoveryProjection.execute, + signal: options.abortSignal, + }); + const metrics = liveRecoveryProjection.metrics; + metrics.internalModelRounds = loopResult.hiddenRounds; + metrics.recoveryCalls = loopResult.recoveryCalls; + metrics.recoveryBytes = liveRecoveryProjection.session.returnedBytes; + metrics.recoveryScanBytes = liveRecoveryProjection.session.scannedBytes; + metrics.receiptActiveTurns = metrics.largeProjected > 0 ? 1 : 0; + metrics.receiptActiveTurnsWithRecovery = liveRecoveryProjection.session.calls > 0 ? 1 : 0; + + if (loopResult.kind === "complete") { + parsed = { ...parsed, context: loopResult.providerContext, options: { ...parsed.options } }; + recoveryFinalEvents = loopResult.events; + } else if (loopResult.reason === "aborted") { + recoveryFinalEvents = [{ type: "error", message: "client closed request during context recovery", status: 499 }]; + } else { + metrics.failOpenRestarts = 1; + if (loopResult.reason === "recovery_failed") metrics.recoveryMisses += 1; + if (loopResult.reason === "round_limit") metrics.recoveryLimitHits += 1; + // Exactly one canonical restart. Abandoned hidden output is never reused by the restart. + parsed = { ...recoveryCanonicalParsed, options: { ...recoveryCanonicalParsed.options } }; + const restartEvents: AdapterEvent[] = []; + for await (const event of fetchTerminalGuardContinuation(parsed)) restartEvents.push(event); + recoveryFinalEvents = addHiddenUsageToVisibleTerminalV1(restartEvents, loopResult.hiddenUsage); + } + logCtx.contextProjection = metrics; + } + if (parsed.stream) { - const initialEventStream = activeAdapter.parseStream(upstreamResponse, translatorBudget); + const initialEventStream: AsyncIterable = recoveryFinalEvents + ? (async function* () { yield* recoveryFinalEvents!; })() + : activeAdapter.parseStream(upstreamResponse, translatorBudget); const eventStream = terminalGuardEnabled ? guardTerminalEventStream({ parsed, @@ -4315,7 +4457,7 @@ async function handleResponsesInner( if (activeAdapter.parseResponse) { let events: AdapterEvent[]; try { - const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget); + const initialEvents = recoveryFinalEvents ?? await activeAdapter.parseResponse(upstreamResponse, translatorBudget); let guardedEvents: AdapterEvent[]; if (terminalGuardEnabled) { guardedEvents = []; diff --git a/tests/context-projection-duplicate-live.test.ts b/tests/context-projection-duplicate-live.test.ts index b8ce3c8d61..f8f4158706 100644 --- a/tests/context-projection-duplicate-live.test.ts +++ b/tests/context-projection-duplicate-live.test.ts @@ -3,7 +3,10 @@ import { applyDuplicateProjectionV1, resolveLiveContextProjectionEpochV1, } from "../src/context-projection/runtime"; -import { LIVE_DUPLICATE_CONTEXT_PROJECTION_POLICY_V1 } from "../src/context-projection/policy"; +import { + LIVE_DUPLICATE_CONTEXT_PROJECTION_POLICY_V1, + LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1, +} from "../src/context-projection/policy"; import type { ContextProjectionContinuationV1 } from "../src/context-projection/types"; import type { OcxContext, OcxToolResultMessage } from "../src/types"; @@ -32,13 +35,22 @@ function activeDuplicate(): ContextProjectionContinuationV1 { }; } -describe("CPG-04 duplicate-only live runtime", () => { +function activeRecovery(): ContextProjectionContinuationV1 { + return { + schemaVersion: 1, + variant: "recovery-v1", + policyId: LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1.policyId, + state: "active", + }; +} + +describe("live context projection epoch/runtime", () => { test("new duplicate-mode chain latches duplicate-v1", () => { expect(resolveLiveContextProjectionEpochV1({ requestedMode: "duplicate" })).toEqual(activeDuplicate()); }); - test("recovery mode is not activated before the recovery rollout", () => { - expect(resolveLiveContextProjectionEpochV1({ requestedMode: "recovery" })).toBeUndefined(); + test("new recovery-mode chain latches recovery-v1 once CPG-06 is available", () => { + expect(resolveLiveContextProjectionEpochV1({ requestedMode: "recovery" })).toEqual(activeRecovery()); }); test("an active duplicate epoch stays stable across later normal config changes", () => { @@ -51,6 +63,16 @@ describe("CPG-04 duplicate-only live runtime", () => { } }); + test("an active recovery epoch stays recovery even if normal config changes later", () => { + for (const requestedMode of ["off", "shadow", "duplicate"] as const) { + expect(resolveLiveContextProjectionEpochV1({ + requestedMode, + previousResponseId: "resp-recovery", + previous: activeRecovery(), + })).toEqual(activeRecovery()); + } + }); + test("legacy chained request remains disabled instead of retroactively projecting", () => { expect(resolveLiveContextProjectionEpochV1({ requestedMode: "duplicate", @@ -59,6 +81,13 @@ describe("CPG-04 duplicate-only live runtime", () => { ...activeDuplicate(), state: "disabled", }); + expect(resolveLiveContextProjectionEpochV1({ + requestedMode: "recovery", + previousResponseId: "resp-legacy", + })).toEqual({ + ...activeRecovery(), + state: "disabled", + }); }); test("native passthrough keeps provider context byte-for-byte equivalent", () => { diff --git a/tests/context-projection-recovery-fail-open-usage.test.ts b/tests/context-projection-recovery-fail-open-usage.test.ts new file mode 100644 index 0000000000..11d53e00a2 --- /dev/null +++ b/tests/context-projection-recovery-fail-open-usage.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { runRecoveryPriorityLoopV1 } from "../src/internal-tools/loop"; +import { CONTEXT_RECOVERY_TOOL_NAME } from "../src/context-projection/synthetic-tool"; +import type { AdapterEvent, OcxContext } from "../src/types"; + +function baseContext(): OcxContext { + return { messages: [{ role: "user", content: "use exact evidence", timestamp: 1 }] }; +} + +function recoveryGeneration(inputTokens: number, outputTokens: number): AdapterEvent[] { + return [ + { type: "tool_call_start", id: "recover", name: CONTEXT_RECOVERY_TOOL_NAME }, + { type: "tool_call_delta", arguments: '{"op":"read","ref":"ctx_1"}' }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens, outputTokens, cachedInputTokens: 7 } }, + ]; +} + +describe("CPG-06 fail-open usage accounting", () => { + test("recovery execution failure returns the already-billed hidden generation usage", async () => { + const out = await runRecoveryPriorityLoopV1({ + initialContext: baseContext(), + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: async () => recoveryGeneration(100, 10), + execute: async () => ({ ok: false, reason: "stale_ref" }), + }); + + expect(out.kind).toBe("fail_open"); + if (out.kind !== "fail_open") throw new Error("expected fail-open"); + expect(out.reason).toBe("recovery_failed"); + expect(out.hiddenUsage).toEqual({ + inputTokens: 100, + outputTokens: 10, + cachedInputTokens: 7, + }); + }); + + test("round-limit fail-open includes the generation that triggered the limit", async () => { + const out = await runRecoveryPriorityLoopV1({ + initialContext: baseContext(), + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + maxRounds: 1, + dispatch: async () => recoveryGeneration(40, 4), + execute: async () => ({ ok: false, reason: "must-not-run" }), + }); + + expect(out.kind).toBe("fail_open"); + if (out.kind !== "fail_open") throw new Error("expected fail-open"); + expect(out.reason).toBe("round_limit"); + expect(out.hiddenUsage?.inputTokens).toBe(40); + expect(out.hiddenUsage?.outputTokens).toBe(4); + }); +}); diff --git a/tests/context-projection-recovery-live-wire-collision.test.ts b/tests/context-projection-recovery-live-wire-collision.test.ts new file mode 100644 index 0000000000..64ce867965 --- /dev/null +++ b/tests/context-projection-recovery-live-wire-collision.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { prepareLiveRecoveryProjectionV1 } from "../src/context-projection/recovery-live"; +import { LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1 } from "../src/context-projection/policy"; +import { CONTEXT_RECOVERY_TOOL_NAME } from "../src/context-projection/synthetic-tool"; +import type { ContextProjectionContinuationV1 } from "../src/context-projection/types"; +import type { OcxContext, OcxParsedRequest } from "../src/types"; + +const epoch: ContextProjectionContinuationV1 = { + schemaVersion: 1, + variant: "recovery-v1", + policyId: LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1.policyId, + state: "active", +}; + +function parsed(context: OcxContext): OcxParsedRequest { + return { + modelId: "test-model", + context, + stream: true, + options: { toolChoice: "auto" }, + }; +} + +describe("CPG-06 live recovery wire identity", () => { + test("fails open before projection when a caller tool collides after provider wire-name mapping", () => { + const canonical: OcxContext = { + tools: [{ + name: `cx_${CONTEXT_RECOVERY_TOOL_NAME}`, + description: "caller tool", + parameters: { type: "object" }, + }], + messages: [{ + role: "toolResult", + toolCallId: "call-1", + toolName: "exec", + toolNamespace: "tools", + content: "x".repeat(128 * 1024), + isError: false, + timestamp: 1, + }], + }; + + const result = prepareLiveRecoveryProjectionV1(parsed(canonical), canonical, { + epoch, + adapterKind: "stateless", + isPassthrough: false, + recoveryToolSurvivesBudget: true, + toWireName: name => name.startsWith("cx_") ? name : `cx_${name}`, + }); + + expect(result.active).toBe(false); + if (result.active) throw new Error("expected collision fail-open"); + expect(result.reason).toBe("tool_name_collision"); + expect(result.providerContext).toBe(canonical); + }); +}); diff --git a/tests/context-projection-recovery-live.test.ts b/tests/context-projection-recovery-live.test.ts new file mode 100644 index 0000000000..a73cd37748 --- /dev/null +++ b/tests/context-projection-recovery-live.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; +import { prepareLiveRecoveryProjectionV1 } from "../src/context-projection/recovery-live"; +import { LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1 } from "../src/context-projection/policy"; +import { CONTEXT_RECOVERY_TOOL_NAME } from "../src/context-projection/synthetic-tool"; +import type { ContextProjectionContinuationV1 } from "../src/context-projection/types"; +import type { OcxContext, OcxParsedRequest, OcxToolChoice, OcxToolResultMessage } from "../src/types"; + +function recoveryEpoch(): ContextProjectionContinuationV1 { + return { + schemaVersion: 1, + variant: "recovery-v1", + policyId: LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1.policyId, + state: "active", + }; +} + +function result(content: string, isError = false): OcxToolResultMessage { + return { + role: "toolResult", + toolCallId: "call-large", + toolName: "exec", + toolNamespace: "tools", + content, + timestamp: 1, + isError, + }; +} + +function canonicalContext(message = result("x".repeat(100 * 1024))): OcxContext { + return { + messages: [message], + tools: [{ name: "shell", description: "shell", parameters: { type: "object" } }], + }; +} + +function parsed(context: OcxContext, toolChoice?: OcxToolChoice): OcxParsedRequest { + return { + modelId: "test-model", + context, + stream: true, + options: { ...(toolChoice !== undefined ? { toolChoice } : {}) }, + _rawBody: { model: "test-model", input: "canonical" }, + }; +} + +function prepare(request: OcxParsedRequest, overrides: Partial[2]> = {}) { + return prepareLiveRecoveryProjectionV1(request, request.context, { + epoch: recoveryEpoch(), + adapterKind: "stateless", + isPassthrough: false, + recoveryToolSurvivesBudget: true, + ...overrides, + }); +} + +describe("CPG-06 live recovery projection gates", () => { + test("eligible auto request projects large result, advertises frozen recovery tool, and preserves canonical context", () => { + const canonical = canonicalContext(); + const snapshot = structuredClone(canonical); + const request = parsed(canonical, "auto"); + request.options.parallelToolCalls = false; + + const live = prepare(request); + expect(live.active).toBe(true); + if (!live.active) throw new Error(live.reason); + expect(live.providerContext).not.toBe(canonical); + expect((live.providerContext.messages[0] as OcxToolResultMessage).content).toContain("[OpenCodex context projection v1]"); + expect(live.providerContext.tools?.at(-1)?.name).toBe(CONTEXT_RECOVERY_TOOL_NAME); + expect(live.metrics.largeProjected).toBe(1); + expect(request.options.parallelToolCalls).toBe(false); + expect(canonical).toEqual(snapshot); + }); + + test("large error output remains full even in an otherwise eligible recovery epoch", () => { + const full = "ERR" + "x".repeat(100 * 1024); + const canonical = canonicalContext(result(full, true)); + const live = prepare(parsed(canonical, "auto")); + expect(live.active).toBe(true); + if (!live.active) throw new Error(live.reason); + expect((live.providerContext.messages[0] as OcxToolResultMessage).content).toBe(full); + expect(live.metrics.largeProjected).toBe(0); + }); + + test("unsafe tool-choice variants fail open without advertising the internal tool", () => { + const choices: OcxToolChoice[] = [ + "none", + "required", + { name: "shell" }, + { allowedTools: ["shell"], mode: "auto" }, + { allowedTools: ["shell"], mode: "required" }, + ]; + for (const choice of choices) { + const canonical = canonicalContext(); + const request = parsed(canonical, choice); + const live = prepare(request); + expect(live.active).toBe(false); + if (live.active) throw new Error("unexpected active recovery"); + expect(live.reason).toBe("tool_choice"); + expect(live.metrics).toEqual(expect.objectContaining({ + variant: "recovery", + suspensionReason: "tool_choice", + candidates: 0, + recoveryCalls: 0, + })); + expect(live.providerContext).toBe(canonical); + expect(canonical.tools?.some(tool => tool.name === CONTEXT_RECOVERY_TOOL_NAME)).toBe(false); + expect(request.options.toolChoice).toEqual(choice); + } + }); + + test("structured output, passthrough, sidecar, compaction, and runTurn all fail open to canonical context", () => { + const cases: Array<{ + mutate?: (request: OcxParsedRequest) => void; + options?: Partial[2]>; + reason: string; + }> = [ + { mutate: request => { request._structuredOutput = true; }, reason: "structured_output" }, + { options: { isPassthrough: true }, reason: "native_passthrough" }, + { options: { hasSidecar: true, sidecarKind: "vision" }, reason: "sidecar" }, + { options: { isCompaction: true }, reason: "compaction" }, + { options: { adapterKind: "runTurn", recoveryToolSurvivesBudget: false }, reason: "stateful_adapter" }, + ]; + for (const entry of cases) { + const canonical = canonicalContext(); + const request = parsed(canonical, "auto"); + entry.mutate?.(request); + const live = prepare(request, entry.options); + expect(live.active).toBe(false); + if (live.active) throw new Error("unexpected active recovery"); + expect(live.reason).toBe(entry.reason); + expect(live.metrics).toEqual(expect.objectContaining({ + variant: "recovery", + suspensionReason: entry.reason, + ...(entry.reason === "sidecar" ? { sidecarKind: "vision" } : {}), + })); + expect(live.providerContext).toBe(canonical); + } + }); + + test("caller collision with internal tool name disables recovery instead of shadowing it", () => { + const canonical = canonicalContext(); + canonical.tools!.push({ + name: CONTEXT_RECOVERY_TOOL_NAME, + description: "client-owned collision", + parameters: { type: "object" }, + }); + const live = prepare(parsed(canonical, "auto")); + expect(live).toEqual(expect.objectContaining({ + active: false, + providerContext: canonical, + reason: "tool_name_collision", + metrics: expect.objectContaining({ + variant: "recovery", + suspensionReason: "tool_name_collision", + }), + })); + }); + + test("planner abort fails open with full canonical context and no internal tool advertisement", () => { + const canonical = canonicalContext(); + const controller = new AbortController(); + controller.abort(); + const live = prepare(parsed(canonical, "auto"), { signal: controller.signal }); + expect(live.active).toBe(false); + if (live.active) throw new Error("unexpected active recovery"); + expect(live.reason).toBe("planner"); + expect(live.providerContext).toBe(canonical); + expect(canonical.tools?.some(tool => tool.name === CONTEXT_RECOVERY_TOOL_NAME)).toBe(false); + }); + + test("wrong or disabled epoch never changes provider-visible context", () => { + const canonical = canonicalContext(); + const request = parsed(canonical, "auto"); + const disabled = { ...recoveryEpoch(), state: "disabled" as const }; + const live = prepareLiveRecoveryProjectionV1(request, canonical, { + epoch: disabled, + adapterKind: "stateless", + isPassthrough: false, + recoveryToolSurvivesBudget: true, + }); + expect(live).toEqual(expect.objectContaining({ + active: false, + providerContext: canonical, + reason: "inactive_epoch", + metrics: expect.objectContaining({ + variant: "recovery", + suspensionReason: "inactive_epoch", + }), + })); + }); + + test("combined on-mode pre-pass collapses an exact duplicate before projecting the first large result", () => { + const large = "x".repeat(100 * 1024); + const first = result(large); + const second = { + ...result(large), + toolCallId: "call-large-dup", + }; + const canonical = { + messages: [first, second], + tools: [{ name: "shell", description: "shell", parameters: { type: "object" } }], + }; + const withoutPrePass = prepare(parsed(canonical, "auto")); + expect(withoutPrePass.active).toBe(true); + if (!withoutPrePass.active) throw new Error(withoutPrePass.reason); + expect(withoutPrePass.metrics.largeProjected).toBe(2); + + const withPrePass = prepare(parsed(canonical, "auto"), { collapseDuplicatesFirst: true }); + expect(withPrePass.active).toBe(true); + if (!withPrePass.active) throw new Error(withPrePass.reason); + expect(withPrePass.metrics.largeProjected).toBe(1); + expect((withPrePass.providerContext.messages[0] as { content: string }).content).toContain("[OpenCodex context projection v1]"); + expect((withPrePass.providerContext.messages[1] as { content: string }).content).toContain("[OpenCodex exact duplicate v1]"); + expect((canonical.messages[0] as { content: string }).content).toBe(large); + expect((canonical.messages[1] as { content: string }).content).toBe(large); + }); +}); + diff --git a/tests/context-projection-recovery-loop.test.ts b/tests/context-projection-recovery-loop.test.ts new file mode 100644 index 0000000000..9f7f8cd614 --- /dev/null +++ b/tests/context-projection-recovery-loop.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { runRecoveryPriorityLoopV1 } from "../src/internal-tools/loop"; +import { CONTEXT_RECOVERY_TOOL_NAME } from "../src/context-projection/synthetic-tool"; +import type { AdapterEvent, OcxContext, OcxToolResultMessage } from "../src/types"; + +function recoveryCall(id = "recover-1", args = { op: "read", ref: "ctx_1" }): AdapterEvent[] { + return [ + { type: "tool_call_start", id, name: CONTEXT_RECOVERY_TOOL_NAME }, + { type: "tool_call_delta", arguments: JSON.stringify(args) }, + { type: "tool_call_end" }, + ]; +} + +function clientCall(id: string, name = "shell"): AdapterEvent[] { + return [ + { type: "tool_call_start", id, name }, + { type: "tool_call_delta", arguments: '{"cmd":"danger-before-evidence"}' }, + { type: "tool_call_end" }, + ]; +} + +function done(inputTokens = 10, outputTokens = 2): AdapterEvent { + return { type: "done", usage: { inputTokens, outputTokens } }; +} + +function resultFor(id: string): OcxToolResultMessage { + return { + role: "toolResult", + toolCallId: id, + toolName: CONTEXT_RECOVERY_TOOL_NAME, + content: JSON.stringify({ ok: true, content: "exact evidence" }), + isError: false, + timestamp: 2, + }; +} + +function baseContext(): OcxContext { + return { + messages: [{ role: "user", content: "answer using the receipt", timestamp: 1 }], + }; +} + +function eventText(events: readonly AdapterEvent[]): string { + return events.flatMap(event => event.type === "text_delta" ? [event.text] : []).join(""); +} + +function eventToolIds(events: readonly AdapterEvent[]): string[] { + return events.flatMap(event => event.type === "tool_call_start" ? [event.id] : []); +} + +describe("CPG-06 recovery-priority internal loop", () => { + test("recovery-only generation remains internal and re-enters the model", async () => { + const canonical = baseContext(); + const snapshot = structuredClone(canonical); + const generations: AdapterEvent[][] = [ + [...recoveryCall(), done(20, 3)], + [{ type: "text_delta", text: "final answer" }, done(11, 4)], + ]; + let dispatches = 0; + const out = await runRecoveryPriorityLoopV1({ + initialContext: canonical, + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: async () => generations[dispatches++]!, + execute: async call => ({ ok: true, toolResult: resultFor(call.id) }), + }); + + expect(out.kind).toBe("complete"); + if (out.kind !== "complete") throw new Error(out.reason); + expect(dispatches).toBe(2); + expect(eventText(out.events)).toBe("final answer"); + expect(eventToolIds(out.events)).toEqual([]); + expect(out.hiddenRounds).toBe(1); + expect(out.recoveryCalls).toBe(1); + expect(canonical).toEqual(snapshot); + expect(out.providerContext).not.toBe(canonical); + expect(out.providerContext.messages).toHaveLength(3); + }); + + test("recovery plus a stale real tool call suppresses the entire generation", async () => { + const generations: AdapterEvent[][] = [ + [...clientCall("stale-real"), ...recoveryCall(), done()], + [...clientCall("fresh-real"), done()], + ]; + let dispatches = 0; + const out = await runRecoveryPriorityLoopV1({ + initialContext: baseContext(), + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: async () => generations[dispatches++]!, + execute: async call => ({ ok: true, toolResult: resultFor(call.id) }), + }); + + expect(out.kind).toBe("complete"); + if (out.kind !== "complete") throw new Error(out.reason); + expect(eventToolIds(out.events)).toEqual(["fresh-real"]); + expect(JSON.stringify(out.providerContext)).not.toContain("stale-real"); + }); + + test("recovery plus assistant text suppresses stale text and requires regeneration", async () => { + const generations: AdapterEvent[][] = [ + [{ type: "text_delta", text: "stale conclusion" }, ...recoveryCall(), done()], + [{ type: "text_delta", text: "evidence-backed conclusion" }, done()], + ]; + let dispatches = 0; + const out = await runRecoveryPriorityLoopV1({ + initialContext: baseContext(), + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: async () => generations[dispatches++]!, + execute: async call => ({ ok: true, toolResult: resultFor(call.id) }), + }); + + expect(out.kind).toBe("complete"); + if (out.kind !== "complete") throw new Error(out.reason); + expect(eventText(out.events)).toBe("evidence-backed conclusion"); + expect(JSON.stringify(out.providerContext)).not.toContain("stale conclusion"); + }); + + test("recovery plus real tool plus assistant text exposes none of the abandoned generation", async () => { + const generations: AdapterEvent[][] = [ + [{ type: "text_delta", text: "stale" }, ...clientCall("stale-real"), ...recoveryCall(), done()], + [{ type: "text_delta", text: "fresh" }, ...clientCall("fresh-real"), done()], + ]; + let dispatches = 0; + const out = await runRecoveryPriorityLoopV1({ + initialContext: baseContext(), + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: async () => generations[dispatches++]!, + execute: async call => ({ ok: true, toolResult: resultFor(call.id) }), + }); + + expect(out.kind).toBe("complete"); + if (out.kind !== "complete") throw new Error(out.reason); + expect(eventText(out.events)).toBe("fresh"); + expect(eventToolIds(out.events)).toEqual(["fresh-real"]); + expect(JSON.stringify(out.providerContext)).not.toContain("stale-real"); + expect(JSON.stringify(out.providerContext)).not.toContain('"text":"stale"'); + }); + + test("multiple recovery calls in one generation are executed in order", async () => { + const generations: AdapterEvent[][] = [ + [...recoveryCall("r1"), ...recoveryCall("r2", { op: "grep", ref: "ctx_2", query: "needle" }), done()], + [{ type: "text_delta", text: "done" }, done()], + ]; + const seen: string[] = []; + let dispatches = 0; + const out = await runRecoveryPriorityLoopV1({ + initialContext: baseContext(), + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: async () => generations[dispatches++]!, + execute: async call => { + seen.push(call.id); + return { ok: true, toolResult: resultFor(call.id) }; + }, + }); + expect(out.kind).toBe("complete"); + expect(seen).toEqual(["r1", "r2"]); + if (out.kind === "complete") expect(out.recoveryCalls).toBe(2); + }); + + test("recovery execution failure abandons the hidden trajectory for canonical fail-open", async () => { + const canonical = baseContext(); + const out = await runRecoveryPriorityLoopV1({ + initialContext: canonical, + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: async () => [...recoveryCall(), done()], + execute: async () => ({ ok: false, reason: "stale_ref" }), + }); + expect(out).toEqual(expect.objectContaining({ kind: "fail_open", reason: "recovery_failed" })); + expect(canonical.messages).toHaveLength(1); + }); + + test("hidden-round budget exhaustion fails open instead of exposing the last recovery generation", async () => { + const out = await runRecoveryPriorityLoopV1({ + initialContext: baseContext(), + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + maxRounds: 2, + dispatch: async () => [...recoveryCall(), done()], + execute: async call => ({ ok: true, toolResult: resultFor(call.id) }), + }); + expect(out).toEqual(expect.objectContaining({ kind: "fail_open", reason: "round_limit", hiddenRounds: 2 })); + }); + + test("hidden usage is accumulated into the visible terminal event", async () => { + const generations: AdapterEvent[][] = [ + [...recoveryCall(), done(100, 10)], + [{ type: "text_delta", text: "ok" }, done(30, 4)], + ]; + let dispatches = 0; + const out = await runRecoveryPriorityLoopV1({ + initialContext: baseContext(), + internalToolNames: new Set([CONTEXT_RECOVERY_TOOL_NAME]), + dispatch: async () => generations[dispatches++]!, + execute: async call => ({ ok: true, toolResult: resultFor(call.id) }), + }); + expect(out.kind).toBe("complete"); + if (out.kind !== "complete") return; + const terminal = out.events.find(event => event.type === "done"); + expect(terminal?.type).toBe("done"); + if (terminal?.type === "done") { + expect(terminal.usage?.inputTokens).toBe(130); + expect(terminal.usage?.outputTokens).toBe(14); + } + }); +}); diff --git a/tests/context-projection-recovery-sidecar-gates.test.ts b/tests/context-projection-recovery-sidecar-gates.test.ts new file mode 100644 index 0000000000..f5fbb0f1a0 --- /dev/null +++ b/tests/context-projection-recovery-sidecar-gates.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { prepareLiveRecoveryProjectionV1 } from "../src/context-projection/recovery-live"; +import { LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1 } from "../src/context-projection/policy"; +import type { OcxContext, OcxParsedRequest } from "../src/types"; + +const epoch = { + schemaVersion: 1 as const, + variant: "recovery-v1" as const, + policyId: LIVE_RECOVERY_CONTEXT_PROJECTION_POLICY_V1.policyId, + state: "active" as const, +}; + +function request(): OcxParsedRequest { + const context: OcxContext = { + messages: [{ + role: "toolResult", + toolCallId: "call-large", + toolName: "exec", + content: "x".repeat(100 * 1024), + isError: false, + timestamp: 1, + }], + }; + return { modelId: "test", context, stream: true, options: { toolChoice: "auto" } }; +} + +describe("CPG-06 recovery sidecar safety", () => { + test("a preplanned sidecar, including video-only bridge activation, keeps recovery fully off", () => { + const parsed = request(); + const canonical = parsed.context; + const live = prepareLiveRecoveryProjectionV1(parsed, canonical, { + epoch, + adapterKind: "stateless", + isPassthrough: false, + hasSidecar: true, + sidecarKind: "vision", + recoveryToolSurvivesBudget: true, + }); + expect(live).toEqual(expect.objectContaining({ + active: false, + providerContext: canonical, + reason: "sidecar", + metrics: expect.objectContaining({ + variant: "recovery", + suspensionReason: "sidecar", + sidecarKind: "vision", + }), + })); + }); +}); + + diff --git a/tests/context-projection-review-hardening.test.ts b/tests/context-projection-review-hardening.test.ts new file mode 100644 index 0000000000..10ab3f7f0b --- /dev/null +++ b/tests/context-projection-review-hardening.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { createContextArtifactRegistry } from "../src/context-projection/registry"; +import { ContextRecoverySessionV1 } from "../src/context-projection/recovery"; +import { executeContextRecoveryCallV1 } from "../src/context-projection/recovery-tool"; +import { + CONTEXT_RECOVERY_TOOL_NAME, + CONTEXT_RECOVERY_TOOL_V1, +} from "../src/context-projection/synthetic-tool"; +import { planContextProjection } from "../src/context-projection/projector"; +import type { ContextProjectionPolicyV1 } from "../src/context-projection/types"; +import type { OcxContext, OcxToolResultMessage } from "../src/types"; + +const DUPLICATE_POLICY: ContextProjectionPolicyV1 = { + schemaVersion: 1, + variant: "duplicate-v1", + policyId: "review-duplicate-v1", + duplicateMinBytes: 1, + largeMinBytes: 96 * 1024, + previewHeadBytes: 8 * 1024, + previewTailBytes: 8 * 1024, + maxScannedUtf8Bytes: 64 * 1024 * 1024, +}; + +const RECOVERY_POLICY: ContextProjectionPolicyV1 = { + ...DUPLICATE_POLICY, + variant: "recovery-v1", + policyId: "review-recovery-v1", + largeMinBytes: 16, +}; + +function toolResult(content: string, overrides: Partial = {}): OcxToolResultMessage { + return { + role: "toolResult", + toolCallId: "call-1", + toolName: "exec", + toolNamespace: "tools", + content, + isError: false, + timestamp: 1, + ...overrides, + }; +} + +describe("context projection deep-review hardening", () => { + test("core passes the native-passthrough boundary into the epoch resolver", async () => { + const source = await Bun.file(new URL("../src/server/responses/core.ts", import.meta.url)).text(); + const call = source.match( + /contextProjectionContinuation = resolveLiveContextProjectionEpochV1\(\{[\s\S]*?\n\s*\}\);/, + )?.[0]; + expect(call).toBeDefined(); + expect(call).toContain("isPassthrough,"); + }); + + test("a recoverable stale or missing lookup is returned to the hidden model instead of forcing restart", () => { + const session = new ContextRecoverySessionV1(createContextArtifactRegistry()); + const out = executeContextRecoveryCallV1(session, { + id: "recover-missing", + name: CONTEXT_RECOVERY_TOOL_NAME, + argumentsText: JSON.stringify({ op: "read", ref: "ctx_missing" }), + }); + + expect(out.ok).toBe(true); + if (!out.ok) throw new Error(out.reason); + expect(out.toolResult.isError).toBe(true); + expect(JSON.parse(String(out.toolResult.content))).toEqual({ + ok: false, + op: "read", + ref: "ctx_missing", + error: "not_found", + }); + }); + + test("recovery tool provider-visible JSON remains byte-stable inside the v1 policy epoch", () => { + expect(JSON.stringify(CONTEXT_RECOVERY_TOOL_V1)).toBe( + '{"name":"__ocx_context_v1","description":"Read exact text omitted by an OpenCodex context-projection receipt. Use only refs that appear in such receipts.","parameters":{"type":"object","properties":{"op":{"type":"string","enum":["read","grep"]},"ref":{"type":"string"},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"cursor":{"type":"string"},"max_bytes":{"type":"integer","minimum":1},"query":{"type":"string"}},"required":["op","ref"],"additionalProperties":false},"contextRecovery":true}', + ); + }); + + test("duplicate receipt identifies the exact earlier occurrence when call ids repeat", () => { + const first = "A".repeat(64); + const second = "B".repeat(64); + const context: OcxContext = { + messages: [ + toolResult(first), + toolResult(second, { timestamp: 2 }), + toolResult(second, { timestamp: 3 }), + ], + }; + const plan = planContextProjection(context, DUPLICATE_POLICY); + const duplicate = plan.replacements.find(item => item.messageIndex === 2); + expect(duplicate?.kind).toBe("duplicate"); + expect(duplicate?.projectedContent).toContain("source_occurrence: 1"); + expect(duplicate?.projectedContent).toContain("source_tool: tools/exec"); + }); + + test("planner abort polling cannot be skipped by mixed one- and two-code-unit text", () => { + let reads = 0; + const signal = { + get aborted() { + reads += 1; + return reads >= 3; + }, + } as AbortSignal; + const content = `a${"😀".repeat(20_000)}`; + const plan = planContextProjection( + { messages: [toolResult(content)] }, + RECOVERY_POLICY, + { signal }, + ); + expect(plan.eligibility).toEqual({ state: "suspended", reason: "aborted" }); + expect(reads).toBeGreaterThanOrEqual(3); + }); + + test("a 10 MiB one-line result stays bounded and never mutates canonical content", () => { + const content = "q".repeat(10 * 1024 * 1024); + const canonical: OcxContext = { messages: [toolResult(content)] }; + const plan = planContextProjection(canonical, RECOVERY_POLICY); + expect(plan.eligibility.state).toBe("eligible"); + expect(plan.replacements).toHaveLength(1); + expect(plan.replacements[0]?.kind).toBe("large"); + expect((canonical.messages[0] as OcxToolResultMessage).content).toBe(content); + }); +});