diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 165253b870..d2c9a25fe2 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -962,9 +962,10 @@ Return a saved tool result after the matching developer function call has comple ``` Use the response/call IDs from the **same connection**, not these example IDs. -The first version accepts string-valued `function_call_output` only. User/system -messages, rich output arrays, hosted tools and simultaneous `response.steer` are -not accepted in an injection turn. Multiple saved function results can share a +`response.inject` accepts string-valued `function_call_output` only. User/system +messages, rich output arrays, hosted-tool results and simultaneous `response.steer` +are not accepted by that operation. The wider saved-result continuation below is +a separate `response.create` operation, not a hidden conversion of rejected injection. Multiple saved function results can share a single injection. Each call can be submitted only once, including while queued. Parallel tool results are queued and sent one frame at a time, since the success @@ -994,3 +995,40 @@ not gain injection support. Unsupported attempts return an explicit error instea of disappearing. The option stays off by default; synthetic transport tests are not live compatibility certification. Set `codexNativeInjection` to `false` and restart to roll back. No account or conversation files need to be removed. + + +### Rich tool results and explicit approvals after response completion + +With `codexNativeInjection` enabled, a client-sent `response.create` on the same +owned connection can now return **unsent** function/custom results containing text, +image or file parts after `response.completed`. Supply the completed response's +`previous_response_id`, the same lane and unchanged model/settings. Include every +outstanding result or requested approval exactly once; omit already accepted +injected results. The proxy forwards this caller-sent continuation using the +original account and socket with the existing dispatch checks. + +Supported continuation items are `function_call_output`, `custom_tool_call_output` +and `mcp_approval_response`. Tool output may be a string or an array of `input_text`, +`input_image` and `input_file` parts. Image parts require `detail` (`auto`, `low`, +`high` or `original`); file detail is optional (`auto`, `low`, `high`). Use exactly +one image/file source. Inline file data requires a filename. Optional +`prompt_cache_breakpoint: { "mode": "explicit" }` is preserved. Unsupported fields +are rejected, not removed. References are not downloaded or reuploaded by the proxy. +Each result has at most 1,024 content parts within the existing 8 MiB request limit. +A supplied program caller must match the advertised call; it cannot impersonate +another tool or agent. Content order, file references and original spelling survive. + +For a server-issued `mcp_approval_request`, pass its ID as `approval_request_id` and +an explicit `approve: true` or `approve: false`. A refusal is forwarded unchanged. +The proxy does not decide, default, auto-approve or execute the requested tool. +A missing or unrelated decision is rejected. Hosted `multi_agent_call` actions and +other server-run tools are **not** developer functions: their events, outputs and +encrypted agent messages are preserved, never executed or injected by OpenCodex. + +This does not enable rich/custom/approval **mid-response injection**, nor simultaneous +steering on a multi-agent response. Those operations have different upstream +contracts. Unsupported injection is refused before reserving a call, so an unsent +result remains available for a later explicit continuation. There is no automatic +conversion, retry, tool rerun or account/API switch. A single-agent steering turn +can follow a completed multi-agent turn as a new explicit request using ordinary +routing. Client support and backend entitlement still require live verification. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index d67c999569..bf794541fe 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -572,3 +572,11 @@ A hub that serves its own local clients also sets [`unauthenticatedLoopbackListener`](#local-clients-that-cannot-receive-the-token). Its port-less companion form is what makes a hub a single-port deployment, and it is refused on a loopback or wildcard `hostname`, where the public listener already holds `127.0.0.1:`. + + +The opt-in `codexNativeInjection` owner also accepts typed saved-result +continuations after the response terminal: rich function/custom outputs and explicit +MCP approval decisions remain on the original account/socket. This does not widen +`response.inject` beyond string-valued function results. Multi-agent requests never +acquire the single-agent steering owner merely because injection is disabled. +See [the continuation contract](/guides/codex-integration/#rich-tool-results-and-explicit-approvals-after-response-completion). diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 846d4c7d6a..f15e51edd6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1489,6 +1489,7 @@ "responses-account-change-scrub.test.ts": "responses", "response-log-inspection.test.ts": "server", "request-log-nonstream.test.ts": "usage", + "ws-native-result-continuations.test.ts": "responses", "ws-native-injection.test.ts": "responses", "ws-native-steering.test.ts": "responses" }, diff --git a/src/server/index/websocket-handler.ts b/src/server/index/websocket-handler.ts index d117f960fa..20199d9e1f 100644 --- a/src/server/index/websocket-handler.ts +++ b/src/server/index/websocket-handler.ts @@ -1,6 +1,5 @@ -import type { NativeResponseControl } from "../responses/native-response-control"; +import { nativeResponseControlMode, type NativeResponseControl } from "../responses/native-response-control"; import { NativeInjectionChannel } from "../responses/native-injection"; -import { isInjectionRequest } from "../responses/native-injection-protocol"; import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering"; import { createNativeSteeringLogObserver } from "../responses/native-steering-log"; import type { Server, ServerWebSocket } from "bun"; @@ -226,9 +225,9 @@ export function createWebsocketHandler(ctx: ServeOptionsContext) { try { const idleMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) ? Math.max(1, config.stallTimeoutSec) * 1000 : 300_000; - nativeControl = config.codexNativeInjection === true && isInjectionRequest(frame) - ? new NativeInjectionChannel(frame, idleMs) - : config.codexNativeSteering === true ? new NativeSteeringChannel(frame, idleMs) : undefined; + const mode = nativeResponseControlMode(frame, config); + nativeControl = mode === "injection" ? new NativeInjectionChannel(frame, idleMs) + : mode === "steering" ? new NativeSteeringChannel(frame, idleMs) : undefined; } catch { sendJsonFrame(ws, buildWsErrorFrame(400, { type: "invalid_request_error", message: "Invalid native steering request settings" })); return; diff --git a/src/server/responses/native-injection-replay.ts b/src/server/responses/native-injection-replay.ts index 3a71566c5c..bfce0a3511 100644 --- a/src/server/responses/native-injection-replay.ts +++ b/src/server/responses/native-injection-replay.ts @@ -1,6 +1,9 @@ import { MAX_NATIVE_STEERING_REPLAY_BYTES, type NativeSteeringReplayObserver } from "./native-steering-replay"; import { injectionRecord as record, type InjectionFrame as Frame, type FunctionResult } from "./native-injection-protocol"; +import { nativeResultFingerprint } from "./native-tool-results"; +import { nativeResponseOutput } from "./native-response-output"; + /** A bounded journal of accepted tool results, independent of user steering history. */ export class NativeInjectionReplay implements NativeSteeringReplayObserver { private prefix: unknown[]; @@ -9,6 +12,8 @@ export class NativeInjectionReplay implements NativeSteeringReplayObserver { private output = new Map(); private accepted = new Map(); private pending?: FunctionResult[]; + private pendingBytes = 0; + private acceptedBatchBytes: number[] = []; private explicit: unknown[] = []; private previous: unknown[] = []; @@ -28,26 +33,25 @@ export class NativeInjectionReplay implements NativeSteeringReplayObserver { } /** Journal before physical send, with rollback usable only for a known unsent frame. */ submitted(frame: Frame): () => void { - const input = Array.isArray(frame.input) ? frame.input : []; + const input = Array.isArray(frame.input) ? structuredClone(frame.input) : []; const bytes = this.reserve(input); - if (frame.type === "response.inject") this.pending = input as FunctionResult[]; + if (frame.type === "response.inject") { this.pending = input as FunctionResult[]; this.pendingBytes = bytes; } else this.explicit = input; return () => { - if (frame.type === "response.inject") this.pending = undefined; + if (frame.type === "response.inject") { this.pending = undefined; this.pendingBytes = 0; } else this.explicit = []; this.bytes -= bytes; }; } /** Keep wire output order and insert each accepted result after its owning function call. */ private completedOutput(response: Frame): unknown[] { - const output = Array.isArray(response.output) && response.output.length - ? response.output : [...this.output.entries()].sort((a, b) => a[0] - b[0]).map(([, item]) => item); + const output = nativeResponseOutput(this.output, response.output); const echoed = new Set(); for (const item of output) { if (!record(item) || item.type !== "function_call_output" || typeof item.call_id !== "string") continue; const accepted = this.accepted.get(item.call_id); if (accepted) { - if (echoed.has(item.call_id) || item.output !== accepted.output) throw new Error("Native injection replay result mismatch."); + if (echoed.has(item.call_id) || nativeResultFingerprint(item as FunctionResult) !== nativeResultFingerprint(accepted)) throw new Error("Native injection replay result mismatch."); echoed.add(item.call_id); } } @@ -70,31 +74,32 @@ export class NativeInjectionReplay implements NativeSteeringReplayObserver { for (const item of this.explicit) this.prefix.push(item); } this.current = String(record(frame.response) ? frame.response.id : ""); - this.output.clear(); this.accepted.clear(); this.explicit = []; this.previous = []; + this.output.clear(); this.accepted.clear(); this.acceptedBatchBytes = []; this.explicit = []; this.previous = []; } else if (frame.type === "response.inject.created" || frame.type === "response.inject.failed") { if (!this.pending) throw new Error("Native injection replay acknowledgement has no pending input."); if (frame.type === "response.inject.created") { for (const item of this.pending) this.accepted.set(item.call_id, item); - } else this.bytes -= Buffer.byteLength(JSON.stringify(this.pending)); - this.pending = undefined; + this.acceptedBatchBytes.push(this.pendingBytes); + } else this.bytes -= this.pendingBytes; + this.pending = undefined; this.pendingBytes = 0; } else if (frame.type === "response.output_item.done") { if (!Number.isSafeInteger(frame.output_index) || (frame.output_index as number) < 0 || (frame.output_index as number) > 10_000 || !record(frame.item)) throw new Error("Native injection replay output identity is invalid."); const old = this.output.get(frame.output_index as number); if (old) this.bytes -= Buffer.byteLength(JSON.stringify(old)); - this.reserve(frame.item); this.output.set(frame.output_index as number, frame.item); + this.reserve(frame.item); this.output.set(frame.output_index as number, structuredClone(frame.item)); } else if (record(frame.response) && ["response.completed", "response.failed", "response.incomplete"].includes(String(frame.type))) { if (this.pending) throw new Error("Native injection replay cannot commit an unacknowledged result."); const output = this.completedOutput(frame.response); for (const item of this.output.values()) this.bytes -= Buffer.byteLength(JSON.stringify(item)); - for (const item of this.accepted.values()) this.bytes -= Buffer.byteLength(JSON.stringify(item)); - this.reserve(output); this.output.clear(); this.accepted.clear(); this.previous = output; + for (const bytes of this.acceptedBatchBytes) this.bytes -= bytes; + this.reserve(output); this.output.clear(); this.accepted.clear(); this.acceptedBatchBytes = []; this.previous = output; if (frame.type === "response.completed") this.remember(this.prefix, { ...frame.response, output }); } } /** Drop all retained bodies at cancellation, connection teardown or unknown delivery. */ dispose(): void { - this.prefix = []; this.output.clear(); this.accepted.clear(); this.pending = undefined; + this.prefix = []; this.output.clear(); this.accepted.clear(); this.pending = undefined; this.pendingBytes = 0; this.acceptedBatchBytes = []; this.explicit = []; this.previous = []; this.bytes = 0; } } diff --git a/src/server/responses/native-injection.ts b/src/server/responses/native-injection.ts index 09f762f896..c2f4a1c811 100644 --- a/src/server/responses/native-injection.ts +++ b/src/server/responses/native-injection.ts @@ -8,7 +8,10 @@ import { type FunctionResult, type InjectionFrame as Frame, } from "./native-injection-protocol"; -type Call = { itemId: unknown; state: "available" | "queued" | "accepted" | "failed"; result?: FunctionResult; recoverable?: boolean }; +import { nativeResultKey, nativeResultMatches, nativeResultFingerprint, nativeSavedResults, nativeToolRequirement, + type NativeToolRequirement } from "./native-tool-results"; + +type Call = { requirement: NativeToolRequirement; state: "available" | "queued" | "accepted" | "failed"; result?: string; recoverable?: boolean }; type Submission = { frame: Frame; results: FunctionResult[]; bytes: number }; const ENVELOPE = new Set(["type", "input", "previous_response_id", "stream", "stream_id"]); @@ -83,18 +86,18 @@ export class NativeInjectionChannel implements NativeResponseControl { private live(): void { if (!this.send || this.finished) injectionError("injection_not_supported", "No live native injection transport is available on this route."); } - /** Record only completed developer function calls, excluding hosted agent/tool actions. */ + /** Advertise client-owned function/custom calls and approvals, never hosted execution. */ private advertise(item: unknown): void { - if (!record(item) || item.type !== "function_call") return; - if (!injectionId(item.call_id)) throw new Error("Native injection function identity is invalid."); - const old = this.calls.get(item.call_id); + const requirement = nativeToolRequirement(item); + if (!requirement) return; + const old = this.calls.get(requirement.key); if (old) { - if (old.itemId !== item.id) throw new Error("Native injection function identity was reused."); + if (old.requirement.identity !== requirement.identity) throw new Error("Native result call identity was reused."); return; } - const bytes = Buffer.byteLength(item.call_id) + (typeof item.id === "string" ? Buffer.byteLength(item.id) : 0); + const bytes = Buffer.byteLength(JSON.stringify(requirement)); if (this.calls.size >= MAX_NATIVE_INJECTION_CALLS || this.callBytes + bytes > 256 * 1024) throw new Error("Native injection call budget exceeded."); - this.calls.set(item.call_id, { itemId: item.id, state: "available" }); this.callBytes += bytes; + this.calls.set(requirement.key, { requirement, state: "available" }); this.callBytes += bytes; } /** Queue validated saved results; reserve each call before any possibly synchronous send. */ inject(frame: Frame): void { @@ -108,8 +111,8 @@ export class NativeInjectionChannel implements NativeResponseControl { } const results = injectionResults(frame.input); for (const item of results) { - const call = this.calls.get(item.call_id); - if (!call) injectionError("injection_call_not_found", "The result does not match a completed function call on this connection."); + const call = this.calls.get(nativeResultKey(item)); + if (!call || !nativeResultMatches(item, call.requirement)) injectionError("injection_call_not_found", "The result does not match a completed function call on this connection."); if (call.state !== "available") injectionError("duplicate_injection", "This function result was already submitted; do not replay it."); } const text = JSON.stringify(frame); @@ -120,7 +123,7 @@ export class NativeInjectionChannel implements NativeResponseControl { // Detach from caller-owned objects before keeping data across asynchronous callbacks. const copy = JSON.parse(text) as Frame; const submission = { frame: copy, results: copy.input as FunctionResult[], bytes }; - for (const item of results) this.calls.get(item.call_id)!.state = "queued"; + for (const item of results) this.calls.get(nativeResultKey(item))!.state = "queued"; this.queue.push(submission); this.queueBytes += bytes; this.pump(); } @@ -151,11 +154,11 @@ export class NativeInjectionChannel implements NativeResponseControl { this.replay?.observe(event); this.lastAckSequence = event.sequence_number as number; for (const item of pending.results) { - const call = this.calls.get(item.call_id)!; + const call = this.calls.get(nativeResultKey(item))!; call.state = failed ? "failed" : "accepted"; // Retain a digest, not another result body, for an explicitly rejected continuation. call.recoverable = failed && record(event.error) && event.error.code === "response_already_completed"; - if (call.recoverable) call.result = { ...item, output: injectionFingerprint(item.output) }; + if (call.recoverable) call.result = nativeResultFingerprint(item); } clearTimeout(this.ackTimer); this.ackTimer = undefined; this.inFlight = undefined; this.queue.shift(); this.queueBytes -= pending.bytes; @@ -170,25 +173,31 @@ export class NativeInjectionChannel implements NativeResponseControl { continue(frame: Frame): boolean { if (!this.send || this.finished) return false; if (this.queue.length || this.continuationSent) injectionError("injection_pending", "Wait for every injection acknowledgement before creating another response."); + if (!this.terminal && frame.previous_response_id === this.currentId) { + injectionError("injection_pending", "Wait for the response terminal before sending saved-result continuations."); + } if (!this.terminal || frame.previous_response_id !== this.currentId) return false; if (this.terminal.type !== "response.completed") injectionError("injection_response_failed", "The parent response did not complete successfully."); if ((frame.stream_id ?? undefined) !== this.lane || frame.generate === false) injectionError("invalid_injection", "Use the same lane for an injection continuation."); for (const [key, value] of Object.entries(frame)) { if (!ENVELOPE.has(key) && this.settings.get(key) !== injectionFingerprint(value)) injectionError("injection_settings_changed", "A native injection continuation cannot change the pinned model or settings."); } - const results = injectionResults(frame.input); + for (const key of this.settings.keys()) { + if (!Object.hasOwn(frame, key)) injectionError("injection_settings_changed", "A native injection continuation cannot change the pinned model or settings."); + } + const results = nativeSavedResults(frame.input); const required = [...this.calls.entries()].filter(([, call]) => call.state !== "accepted"); - if (!required.length || results.length !== required.length) injectionError("invalid_injection", "Supply every outstanding saved function result exactly once."); + if (!required.length || results.length !== required.length) injectionError("invalid_injection", "Supply every outstanding saved tool result exactly once."); for (const item of results) { - const call = this.calls.get(item.call_id); - if (!call || call.state === "accepted" || call.state === "queued" - || (call.state === "failed" && (!call.recoverable || call.result?.output !== injectionFingerprint(item.output)))) { - injectionError("invalid_injection", "Continuation input must match unsent or explicitly completion-rejected function results."); + const call = this.calls.get(nativeResultKey(item)); + if (!call || !nativeResultMatches(item, call.requirement) || call.state === "accepted" || call.state === "queued" + || (call.state === "failed" && (!call.recoverable || call.result !== nativeResultFingerprint(item)))) { + injectionError("invalid_injection", "Continuation input must match unsent or explicitly completion-rejected tool results."); } } if (Buffer.byteLength(JSON.stringify(frame)) > MAX_NATIVE_INJECTION_BYTES) injectionError("invalid_injection", "Native injection continuation exceeds its byte limit."); this.continuationSent = true; - try { this.recordTerminal(); this.replay?.submitted(frame); this.send(frame); } + try { this.recordTerminal(); const copy = JSON.parse(JSON.stringify(frame)) as Frame; this.replay?.submitted(copy); this.send(copy); } catch { this.fail(); injectionError("injection_delivery_unknown", "Continuation delivery is unknown; do not automatically resend results."); } if (!this.finished) this.armIdle(this.deadlines.ackMs); return true; diff --git a/src/server/responses/native-response-control.ts b/src/server/responses/native-response-control.ts index 0b779da033..290452a2eb 100644 --- a/src/server/responses/native-response-control.ts +++ b/src/server/responses/native-response-control.ts @@ -2,6 +2,8 @@ import type { OcxProviderConfig } from "../../types"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import type { NativeSteeringReplayObserver } from "./native-steering-replay"; +import { isInjectionRequest } from "./native-injection-protocol"; + /** Shared transport ownership, not a shared steer/inject protocol state machine. */ export interface NativeResponseControl { readonly kind?: "steering" | "injection"; @@ -31,3 +33,11 @@ export function nativeResponseControlEligible(provider: OcxProviderConfig, contr && provider.upstreamWebsocket === true && provider.authMode !== "forward" && provider.baseUrl?.replace(/\/+$/, "") === "https://api.openai.com/v1"; } + +/** Select by execution mode, never model name; a multi-agent request cannot acquire steering. */ +export function nativeResponseControlMode(frame: Record, flags: { + codexNativeInjection?: boolean; codexNativeSteering?: boolean; +}): "injection" | "steering" | undefined { + if (isInjectionRequest(frame)) return flags.codexNativeInjection === true ? "injection" : undefined; + return flags.codexNativeSteering === true ? "steering" : undefined; +} diff --git a/src/server/responses/native-response-output.ts b/src/server/responses/native-response-output.ts new file mode 100644 index 0000000000..6115c76913 --- /dev/null +++ b/src/server/responses/native-response-output.ts @@ -0,0 +1,36 @@ +import { injectionFingerprint, injectionRecord as record, type InjectionFrame as Frame } from "./native-injection-protocol"; + +/** + * Preserve completed wire items missing from a sparse terminal, including hosted + * tool results and encrypted agent messages. Shared IDs must retain content and + * relative order; conflicting transcripts fail instead of silently losing data. + */ +export function nativeResponseOutput(done: ReadonlyMap, terminal: unknown): Frame[] { + const observed = [...done.entries()].sort((a, b) => a[0] - b[0]).map(([, item]) => item); + if (terminal == null || (Array.isArray(terminal) && !terminal.length)) return observed; + if (!Array.isArray(terminal) || terminal.some(item => !record(item))) throw new Error("Invalid native response output."); + const identity = (item: Frame) => typeof item.id === "string" ? `id:${item.id}` : `body:${injectionFingerprint(item)}`; + const positions = new Map(); + observed.forEach((item, index) => { + const key = identity(item); + if (positions.has(key)) throw new Error("Duplicate native completed output identity."); + positions.set(key, index); + }); + const result: Frame[] = []; + const seen = new Set(); + let cursor = 0; + for (const item of terminal as Frame[]) { + const key = identity(item); + if (seen.has(key)) throw new Error("Duplicate native terminal output identity."); + seen.add(key); + const position = positions.get(key); + if (position === undefined) { result.push(item); continue; } + if (position < cursor || injectionFingerprint(item) !== injectionFingerprint(observed[position])) { + throw new Error("Native terminal output contradicts completed wire items."); + } + while (cursor < position) result.push(observed[cursor++]); + result.push(item); cursor++; + } + while (cursor < observed.length) result.push(observed[cursor++]); + return result; +} diff --git a/src/server/responses/native-steering.ts b/src/server/responses/native-steering.ts index 05ccf3e482..d5979ffa8e 100644 --- a/src/server/responses/native-steering.ts +++ b/src/server/responses/native-steering.ts @@ -112,6 +112,9 @@ export class NativeSteeringChannel { /** Pin the initial lane and setting digests without opening a transport. */ constructor(initial: Frame, private readonly idleMs = 300_000) { + if (record(initial.multi_agent) && initial.multi_agent.enabled === true) { + throw new NativeSteeringError("native_control_mode_mismatch", "Multi-agent responses cannot use the single-agent steering channel."); + } this.lane = initial.stream_id; for (const [key, value] of Object.entries(initial)) { if (!["type", "input", "previous_response_id", "stream", "stream_id"].includes(key)) this.settings.set(key, fingerprint(value)); diff --git a/src/server/responses/native-tool-results.ts b/src/server/responses/native-tool-results.ts new file mode 100644 index 0000000000..77c2fb1247 --- /dev/null +++ b/src/server/responses/native-tool-results.ts @@ -0,0 +1,130 @@ +import { + injectionError, injectionFingerprint, injectionId, injectionRecord as record, + MAX_NATIVE_INJECTION_CALLS, type InjectionFrame as Frame, +} from "./native-injection-protocol"; + +export type NativeToolOutput = string | Frame[]; +export type NativeToolResult = { + type: "function_call_output" | "custom_tool_call_output"; + call_id: string; + output: NativeToolOutput; + id?: string; + caller?: Frame | null; +}; +export type NativeApprovalResult = { + type: "mcp_approval_response"; + approval_request_id: string; + approve: boolean; + reason?: string | null; + id?: string; +}; +export type NativeSavedResult = NativeToolResult | NativeApprovalResult; +export type NativeToolRequirement = { key: string; type: NativeSavedResult["type"]; identity: string; caller: string }; +export const MAX_NATIVE_RESULT_PARTS = 1024; + +/** Fixed diagnostics deliberately exclude caller identifiers and saved result bodies. */ +function invalid(): never { + return injectionError("invalid_injection", "Invalid saved tool result, content part, caller or approval decision."); +} +/** Reject unknown fields rather than silently discarding them or widening the wire schema. */ +function keys(value: Frame, allowed: string[]): void { + if (Object.keys(value).some(key => !allowed.includes(key))) invalid(); +} +/** References remain opaque: no local file reads, URL downloads or cross-account uploads. */ +function source(value: unknown): boolean { + return typeof value === "string" && value.length > 0; +} +/** Validate a supplied caller while treating absent and explicit direct callers alike. */ +function caller(value: unknown): string { + if (value == null) return injectionFingerprint({ type: "direct" }); + if (!record(value)) return invalid(); + if (value.type === "direct") keys(value, ["type"]); + else if (value.type === "program" && injectionId(value.caller_id)) keys(value, ["type", "caller_id"]); + else return invalid(); + return injectionFingerprint(value); +} +/** Bound rich result shape without coercing it to text or fetching its content. */ +function output(value: unknown): void { + if (typeof value === "string") return; + if (!Array.isArray(value) || value.length > MAX_NATIVE_RESULT_PARTS) invalid(); + for (const part of value) { + if (!record(part)) invalid(); + if (part.prompt_cache_breakpoint !== undefined) { + if (!record(part.prompt_cache_breakpoint) || part.prompt_cache_breakpoint.mode !== "explicit") invalid(); + keys(part.prompt_cache_breakpoint, ["mode"]); + } + if (part.type === "input_text") { + keys(part, ["type", "text", "prompt_cache_breakpoint"]); + if (typeof part.text !== "string") invalid(); + } else if (part.type === "input_image") { + keys(part, ["type", "image_url", "file_id", "detail", "prompt_cache_breakpoint"]); + if (!["auto", "low", "high", "original"].includes(String(part.detail))) invalid(); + if (Number(source(part.image_url)) + Number(source(part.file_id)) !== 1) invalid(); + for (const key of ["image_url", "file_id"]) if (part[key] != null && !source(part[key])) invalid(); + if (part.file_id != null && !injectionId(part.file_id)) invalid(); + } else if (part.type === "input_file") { + keys(part, ["type", "file_id", "file_url", "file_data", "filename", "detail", "prompt_cache_breakpoint"]); + if ([part.file_id, part.file_url, part.file_data].filter(source).length !== 1) invalid(); + for (const key of ["file_id", "file_url", "file_data", "filename"]) if (part[key] != null && !source(part[key])) invalid(); + if (part.file_id != null && !injectionId(part.file_id)) invalid(); + if (part.file_data != null && !source(part.filename)) invalid(); + if (part.detail !== undefined && !["auto", "low", "high"].includes(String(part.detail))) invalid(); + } else invalid(); + } +} +/** Separate call IDs from approval IDs so identical spellings cannot authorize each other. */ +export function nativeResultKey(value: NativeSavedResult): string { + return JSON.stringify([value.type === "mcp_approval_response" ? "approval" : "call", + value.type === "mcp_approval_response" ? value.approval_request_id : value.call_id]); +} +/** Parse the wider continuation schema; this does NOT grant response.inject support. */ +export function nativeSavedResults(value: unknown): NativeSavedResult[] { + if (!Array.isArray(value) || !value.length || value.length > MAX_NATIVE_INJECTION_CALLS) invalid(); + const seen = new Set(); + for (const item of value) { + if (!record(item) || (item.id !== undefined && !injectionId(item.id))) invalid(); + if (item.type === "mcp_approval_response") { + keys(item, ["type", "approval_request_id", "approve", "reason", "id"]); + if (!injectionId(item.approval_request_id) || typeof item.approve !== "boolean" + || (item.reason != null && typeof item.reason !== "string")) invalid(); + } else { + keys(item, ["type", "call_id", "output", "caller", "id"]); + if (!["function_call_output", "custom_tool_call_output"].includes(String(item.type)) || !injectionId(item.call_id)) invalid(); + output(item.output); caller(item.caller); + } + const key = nativeResultKey(item as NativeSavedResult); + if (seen.has(key)) injectionError("duplicate_injection", "Each saved result or approval must occur exactly once."); + seen.add(key); + } + return value as NativeSavedResult[]; +} +/** Bind a client-owned call or approval to its type, caller and server-supplied identity. */ +export function nativeToolRequirement(item: unknown): NativeToolRequirement | undefined { + if (!record(item)) return; + let type: NativeSavedResult["type"]; + let key: string; + if (item.type === "mcp_approval_request") { + if (!injectionId(item.id)) invalid(); + type = "mcp_approval_response"; key = JSON.stringify(["approval", item.id]); + } else { + if (item.type !== "function_call" && item.type !== "custom_tool_call") return; + if (!injectionId(item.call_id) || (item.id !== undefined && !injectionId(item.id))) invalid(); + type = item.type === "function_call" ? "function_call_output" : "custom_tool_call_output"; + key = JSON.stringify(["call", item.call_id]); + } + const origin = caller(item.caller); + // Retain only a bounded digest of provenance, never another copy of the call body. + return { key, type, caller: origin, identity: injectionFingerprint({ type, id: item.id, name: item.name, + server_label: item.server_label, arguments: item.arguments, input: item.input, caller: origin, agent: item.agent }) }; +} +/** Approval decisions must be supplied by the caller; no default or synthetic approval exists. */ +export function nativeResultMatches(item: NativeSavedResult, required: NativeToolRequirement): boolean { + return nativeResultKey(item) === required.key && item.type === required.type + && (item.type === "mcp_approval_response" || caller(item.caller) === required.caller); +} +/** Compare content rather than object identity; retain content-array order and caller identity. */ +export function nativeResultFingerprint(item: NativeSavedResult): string { + return injectionFingerprint(item.type === "mcp_approval_response" + ? { type: item.type, approval_request_id: item.approval_request_id, approve: item.approve, reason: item.reason } + : { type: item.type, call_id: item.call_id, output: item.output, caller: caller(item.caller) }); +} diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 5bc604082c..27a4de9046 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,6 +1,6 @@ # Adapter Registry Authority -Native function-result injection follows [the separate opt-in control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/catalog.md b/structure/catalog.md index d602e50a96..760dcfaf4e 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,6 +1,6 @@ # Model Catalog -Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index b9e253082e..faf83ef09e 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,6 +1,6 @@ # Claude Desktop Integration -Native function-result injection follows [the separate opt-in control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 8281557d9c..a5db402c7f 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,6 +1,6 @@ # Images Data Plane -Native function-result injection follows [the separate opt-in control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index cdef291e98..f5e00c1468 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,6 +1,6 @@ # Inbound Compatibility Surfaces -Native function-result injection follows [the separate opt-in control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index eec2337dd5..09264658ba 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,6 +1,6 @@ # GUI And Management API -Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 2321a1db2e..e3a7ad19e8 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,6 +1,6 @@ # Background Service And Sidecars -Native function-result injection follows [the separate opt-in control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index e35f40c637..864de99d30 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,6 +1,6 @@ # xAI Grok Provider -Native function-result injection follows [the separate opt-in control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index 7c29d16d92..1b0dbc06b5 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/subagents.md b/structure/subagents.md index 76d7e551cd..a68e4f06d0 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -1,6 +1,6 @@ # Subagents And Multi-Agent Surface -Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index d28fc7d663..65f25b9612 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,6 +1,6 @@ # Byte Accounting -Native function-result injection follows [the separate opt-in control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 29750a4d4b..d7113c550f 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,6 +1,6 @@ # Transport Inventory -Native function-result injection follows [the separate opt-in control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 7fe0a98e17..2833d62b30 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -1,6 +1,6 @@ # Responses Transport -Native function-result injection follows [the separate opt-in control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index cd59e55da1..d584a33dd3 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -321,7 +321,9 @@ to the outgoing beta header. No client/model capability or subscription entitlem is inferred. Translated, Combo, sidecar, plaintext-restoration and HTTP-fallback paths cannot receive controls. The common interface lives in `src/server/responses/native-response-control.ts`; it shares transport ownership, -not protocol semantics, with steering. Mixed steer/inject turns are rejected. +not protocol semantics, with steering. Mode selection excludes multi-agent turns from +steering even when injection is disabled. An explicit new turn after completion can +select another mode through ordinary dispatch; no queued work or acceptance is invented. `src/server/responses/native-injection.ts` retains the normally selected credential and private socket. `src/server/responses/native-injection-protocol.ts` validates @@ -355,3 +357,36 @@ Existing socket/SSE frame limits and the active-response stall deadline also app `tests/responses/ws-native-injection.test.ts` exercises the real handler, captured auth, dispatch, relay, replay and synthetic failure paths. It is not live backend or Codex App/CLI compatibility certification. + + +### Rich saved-result continuations and server-owned output + +`src/server/responses/native-tool-results.ts` validates the wider **continuation** +contract: function/custom results accept strings or bounded arrays of `input_text`, +`input_image` and `input_file`; MCP approval responses require an explicit boolean. +Absent and explicit direct callers compare alike; program callers must match the +server-advertised origin. Call and approval namespaces are distinct. Type, call, +item, caller and agent provenance remain bound to this connection. Hosted calls +never advertise client-owned result slots. References are forwarded, not fetched, +uploaded, interpreted as local paths, flattened or split into separate requests. +Result contents compare structurally with array order preserved. The parser +allows documented detail/cache-breakpoint fields; unknown shapes are refused. + +Only an explicit same-parent/lane/settings `response.create` after the terminal +can return all remaining saved results and approval decisions, once. An early +same-parent create cannot cancel into normal dispatch. Missing decisions never +become approval; rejected and accepted results remain distinguishable. Rich, +custom and approval **inject** frames still fail before physical send: a general +Responses input shape is not evidence that a beta injection operation accepts it. +The existing count, byte, acknowledgement and account-ownership limits remain. + +`src/server/responses/native-response-output.ts` reconciles completed wire items +with sparse terminal output without losing hosted calls, their results, encrypted +agent messages or provenance. Shared IDs must preserve content and relative order; +a contradiction fails rather than silently choosing one transcript. Continuation +bodies are copied before retention; accepted results alone enter replay history. +The wire relay does not synthesize or modify server-owned events or approvals. +`tests/responses/ws-native-result-continuations.test.ts` covers those contracts, +including false approval decisions, typed identity, content order, unsupported +injection batches, sparse terminals and explicit mode transitions. No test asserts +that a live subscription backend accepts these optional execution modes. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2edccae893..d3265ef130 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1321,6 +1321,7 @@ "responses-account-change-scrub.test.ts": "responses", "response-log-inspection.test.ts": "server", "request-log-nonstream.test.ts": "usage", + "ws-native-result-continuations.test.ts": "responses", "ws-native-injection.test.ts": "responses", "ws-native-steering.test.ts": "responses" } diff --git a/tests/helpers/native-injection-fixture.ts b/tests/helpers/native-injection-fixture.ts index 571c2bfe1d..07f2703334 100644 --- a/tests/helpers/native-injection-fixture.ts +++ b/tests/helpers/native-injection-fixture.ts @@ -67,6 +67,15 @@ export async function beginInjection(fields: Frame = {}, settings = injectionCon expect(socket).toBeDefined(); return { ...client, socket, id: socket.root }; } +/** A saved-result continuation must restate the settings the opening frame pinned. */ +export function continuationFrame(fields: Frame, api = false): Frame { + return { + model: api ? "api/gpt-5.6-sol" : "gpt-5.6-sol", + multi_agent: { enabled: true }, + tools: [{ type: "function", name: "get_value", parameters: { type: "object", properties: {} } }], + ...fields, + }; +} export function advertiseInjection(socket: InjectionSocket, call = "call-1", index = 0) { const item = { id: `item-${call}`, type: "function_call", call_id: call, name: "get_value", arguments: "{}" }; socket.emit({ type: "response.output_item.added", output_index: index, item }); diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 6a0223fc16..5ec7a0c6f9 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -9,6 +9,8 @@ export const RESPONSES_CORE_MODULES = [ "core.ts", "core-options.ts", "native-response-control.ts", + "native-tool-results.ts", + "native-response-output.ts", "native-injection-protocol.ts", "native-injection-replay.ts", "native-steering.ts", diff --git a/tests/responses/ws-native-injection.test.ts b/tests/responses/ws-native-injection.test.ts index 7adf7c3e18..c992cfaf81 100644 --- a/tests/responses/ws-native-injection.test.ts +++ b/tests/responses/ws-native-injection.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { installInjectionFixture, beginInjection, injectionClient, injectionConfig, InjectionSocket, - advertiseInjection, completeInjection, acknowledgeInjection, savedResult, waitForInjection, fallbackCalls, + advertiseInjection, completeInjection, acknowledgeInjection, continuationFrame, savedResult, waitForInjection, fallbackCalls, } from "../helpers/native-injection-fixture"; import { configSchema } from "../../src/config/schema/config-schema"; import { getRequestLogEntries } from "../../src/server/request-log"; @@ -81,9 +81,9 @@ test("completion rejection is preserved; only an explicit caller continuation re socket.emit(failed); await waitForInjection(() => sent.some(event => event.type === failed.type)); expect(sent.at(-1)).toEqual(failed); expect(socket.frames).toHaveLength(2); - send({ type: "response.create", previous_response_id: id, input: [savedResult("call-1", "changed output")] }); + send(continuationFrame({ type: "response.create", previous_response_id: id, input: [savedResult("call-1", "changed output")] })); expect(sent.at(-1)?.error.code).toBe("invalid_injection"); expect(socket.frames).toHaveLength(2); - const continuation = { type: "response.create", previous_response_id: id, input }; + const continuation = continuationFrame({ type: "response.create", previous_response_id: id, input }); send(continuation); send(continuation); await waitForInjection(() => socket.frames.length === 3); expect(socket.frames[2].input).toEqual(input); expect(socket.frames[2].previous_response_id).toBe(id); diff --git a/tests/responses/ws-native-result-continuations.test.ts b/tests/responses/ws-native-result-continuations.test.ts new file mode 100644 index 0000000000..9073ce0630 --- /dev/null +++ b/tests/responses/ws-native-result-continuations.test.ts @@ -0,0 +1,277 @@ +import { expect, test } from "bun:test"; +import { + beginInjection, injectionConfig, installInjectionFixture, advertiseInjection, savedResult, + acknowledgeInjection, completeInjection, continuationFrame, waitForInjection, InjectionSocket, fallbackCalls, + type Frame, +} from "../helpers/native-injection-fixture"; +import { NativeInjectionChannel } from "../../src/server/responses/native-injection"; +import { NativeInjectionReplay } from "../../src/server/responses/native-injection-replay"; +import { NativeSteeringChannel } from "../../src/server/responses/native-steering"; +import { nativeResponseControlMode } from "../../src/server/responses/native-response-control"; +import { nativeSavedResults, nativeResultFingerprint, nativeToolRequirement, nativeResultMatches, + MAX_NATIVE_RESULT_PARTS } from "../../src/server/responses/native-tool-results"; +import { nativeResponseOutput } from "../../src/server/responses/native-response-output"; +import { MAX_NATIVE_INJECTION_BYTES } from "../../src/server/responses/native-injection-protocol"; + +installInjectionFixture(); + +const rich = () => [ + { type: "input_text", text: "fixture result", prompt_cache_breakpoint: { mode: "explicit" } }, + { type: "input_image", file_id: "file-fixture-image", detail: "original" }, + { type: "input_file", filename: "fixture.txt", file_data: "Zml4dHVyZQ==", detail: "low" }, +]; +const customCall = (extra: Frame = {}) => ({ type: "custom_tool_call", id: "custom-item", call_id: "custom-call", name: "custom", input: "fixture", ...extra }); +const approvalCall = (extra: Frame = {}) => ({ type: "mcp_approval_request", id: "approval-item", name: "read", server_label: "fixture", arguments: "{}", ...extra }); +const customResult = (extra: Frame = {}) => ({ type: "custom_tool_call_output", call_id: "custom-call", output: rich(), ...extra }); +const approvalResult = (approve: boolean) => ({ type: "mcp_approval_response", approval_request_id: "approval-item", approve, reason: "caller decision" }); +function emitItem(socket: InjectionSocket, item: Frame, index: number) { + socket.emit({ type: "response.output_item.added", output_index: index, item }); + socket.emit({ type: "response.output_item.done", output_index: index, item }); +} +function unit() { + const sent: Frame[] = []; + const remembered: Frame[] = []; + const channel = new NativeInjectionChannel({ multi_agent: { enabled: true } }); + channel.replayFactory = () => new NativeInjectionReplay([], (input, response) => remembered.push({ input: structuredClone(input), response: structuredClone(response) })); + const detach = channel.attach(frame => sent.push(structuredClone(frame)), () => {}); + channel.observe({ type: "response.created", response: { id: "r1" } }); + const item = (value: Frame, index = 0) => { + channel.observe({ type: "response.output_item.added", output_index: index, item: value }); + channel.observe({ type: "response.output_item.done", output_index: index, item: value }); + }; + const terminal = () => channel.observe({ type: "response.completed", response: { id: "r1", output: [] } }); + return { channel, detach, sent, remembered, item, terminal }; +} + +const outputs = ["", [], [{ type: "input_text", text: "" }], rich(), + [{ type: "input_image", image_url: "https://example.invalid/fixture.png", detail: "auto" }], + [{ type: "input_file", file_id: "file-fixture" }], + [{ type: "input_file", file_url: "https://example.invalid/fixture.pdf", detail: "high" }]]; +for (const type of ["function_call_output", "custom_tool_call_output"]) { + test.each(outputs.map((output, i) => [i, output] as const))(`${type} continuation shape %s is lossless`, (_, output) => { + const value = [{ type, call_id: "call", output }]; + expect(nativeSavedResults(value)).toEqual(value); + }); +} +const invalidResults = [null, {}, [], [{ role: "system", content: "no" }], + [customResult({ output: [null] })], [customResult({ output: [{ type: "output_text", text: "no" }] })], + [customResult({ output: [{ type: "input_image", image_url: "a", file_id: "b", detail: "auto" }] })], + [customResult({ output: [{ type: "input_image", file_id: "a", detail: "invented" }] })], + [customResult({ output: [{ type: "input_file", file_data: "YQ==" }] })], + [customResult({ output: [{ type: "input_file", file_url: "a", detail: "original" }] })], + [customResult({ output: [{ type: "input_file", file_id: "a", file_url: "b" }] })], + [customResult({ output: [{ type: "input_text", text: "a", extra: true }] })], + [customResult({ output: [{ type: "input_text", text: "a", prompt_cache_breakpoint: { mode: "other" } }] })], + [customResult({ output: Array.from({ length: MAX_NATIVE_RESULT_PARTS + 1 }, () => ({ type: "input_text", text: "" })) })], + [customResult({ caller: { type: "program", caller_id: "x", extra: true } })], + [customResult(), customResult()], [customResult({ call_id: "bad\nidentity" })], + [{ type: "mcp_approval_response", approval_request_id: "approval-item" }], + [{ ...approvalResult(true), approve: "true" }], + [{ type: "multi_agent_call_output", call_id: "server-call", output: "no" }]]; +test.each(invalidResults.map((value, i) => [i, value] as const))("invalid saved result %s is rejected", (_, value) => { + expect(() => nativeSavedResults(value)).toThrow(); +}); + +test("semantic comparison ignores object-key order but retains content-array order and caller", () => { + const a = nativeSavedResults([customResult()])[0]; + const b = nativeSavedResults([{ output: rich().map(part => Object.fromEntries(Object.entries(part).reverse())), call_id: "custom-call", type: "custom_tool_call_output" }])[0]; + expect(nativeResultFingerprint(a)).toBe(nativeResultFingerprint(b)); + expect(nativeResultFingerprint(a)).not.toBe(nativeResultFingerprint(nativeSavedResults([customResult({ output: rich().reverse() })])[0])); + expect(nativeResultFingerprint(a)).not.toBe(nativeResultFingerprint(nativeSavedResults([customResult({ caller: { type: "program", caller_id: "program" } })])[0])); +}); + +test.each([false, true])("rich/custom/approval continuation uses one original socket; API=%s", async api => { + const { socket, send, sent, ws, id } = await beginInjection({}, injectionConfig(api)); + const func = advertiseInjection(socket); + const custom = customCall(); const approval = approvalCall(); + emitItem(socket, custom, 1); emitItem(socket, approval, 2); + completeInjection(socket, { output: [func, custom, approval] }); + await waitForInjection(() => sent.some(frame => frame.type === "response.completed")); + expect(ws.data.nativeControl).toBeDefined(); + const frame = continuationFrame({ type: "response.create", previous_response_id: id, + input: [savedResult("call-1", "text"), customResult(), approvalResult(false)] }, api); + send(frame); + await waitForInjection(() => socket.frames.length === 2); + expect(socket.frames[1]).toMatchObject({ ...frame, model: "gpt-5.6-sol" }); + socket.emit({ type: "response.created", response: { id: "r2", previous_response_id: id, output: [] } }); + completeInjection(socket, {}, "r2"); + await waitForInjection(() => !ws.data.nativeControl); + expect(InjectionSocket.all).toHaveLength(1); expect(fallbackCalls).toBe(0); + expect(socket.options.headers.authorization).toBe(api ? "Bearer fixture-public-key" : "Bearer test"); + expect(sent.filter(frame => frame.type === "response.created")).toHaveLength(2); +}); + +test.each([false, true])("approval %s is caller-supplied, required and never defaulted", approve => { + const x = unit(); + try { + x.item(approvalCall()); x.terminal(); + expect(x.channel.ended).toBe(false); expect(x.sent).toEqual([]); + expect(() => x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [savedResult("approval-item")] })).toThrow(); + expect(x.sent).toEqual([]); + expect(x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [approvalResult(approve)] })).toBe(true); + expect(x.sent[0].input).toEqual([approvalResult(approve)]); + } finally { x.detach(); } +}); + +test("extended injection is refused before send and does not consume a call needed by continuation", async () => { + const { socket, send, sent, id } = await beginInjection(); + const call = advertiseInjection(socket); const custom = customCall(); + emitItem(socket, custom, 1); + send({ type: "response.inject", response_id: id, input: [savedResult(), customResult()] }); + expect(socket.frames).toHaveLength(1); + expect(sent.at(-1)?.type).toBe("error"); + completeInjection(socket, { output: [call, custom] }); + send(continuationFrame({ type: "response.create", previous_response_id: id, input: [{ type: "function_call_output", call_id: "call-1", output: rich() }, customResult()] })); + await waitForInjection(() => socket.frames.length === 2); + expect(socket.frames[1].input[0].output).toEqual(rich()); + expect(fallbackCalls).toBe(0); +}); + +test("accepted injection and unsent custom/approval results have separate completion state", async () => { + const { socket, send, id, ws } = await beginInjection(); + const func = advertiseInjection(socket); const custom = customCall(); const approval = approvalCall(); + emitItem(socket, custom, 1); emitItem(socket, approval, 2); + send({ type: "response.inject", response_id: id, input: [savedResult()] }); + acknowledgeInjection(socket); completeInjection(socket, { output: [func, custom, approval] }); + expect(ws.data.nativeControl).toBeDefined(); + send(continuationFrame({ type: "response.create", previous_response_id: id, input: [savedResult(), customResult(), approvalResult(true)] })); + expect(socket.frames).toHaveLength(2); + send(continuationFrame({ type: "response.create", previous_response_id: id, input: [customResult(), approvalResult(true)] })); + await waitForInjection(() => socket.frames.length === 3); + expect(socket.frames[2].input).toEqual([customResult(), approvalResult(true)]); +}); + +test("call type, program caller and foreign approval identity cannot be substituted", () => { + const x = unit(); const origin = { type: "program", caller_id: "program-one" }; + try { + x.item(customCall({ caller: origin, agent: { agent_name: "/root/a" } })); x.item(approvalCall(), 1); x.terminal(); + for (const bad of [savedResult("custom-call"), customResult(), customResult({ caller: { type: "program", caller_id: "program-two" } })]) { + expect(() => x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [bad, approvalResult(true)] })).toThrow(); + } + expect(() => x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [customResult({ caller: origin }), { ...approvalResult(false), approval_request_id: "foreign" }] })).toThrow(); + expect(x.sent).toEqual([]); + expect(x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [customResult({ caller: origin }), approvalResult(false)] })).toBe(true); + } finally { x.detach(); } +}); + +test("a continuation that omits or changes a pinned setting fails closed", () => { + const x = unit(); + try { + x.item(customCall()); x.terminal(); + for (const frame of [ + { type: "response.create", previous_response_id: "r1", input: [customResult()] }, + { type: "response.create", previous_response_id: "r1", multi_agent: { enabled: false }, input: [customResult()] }, + ]) { + try { x.channel.continue(frame); expect.unreachable(); } + catch (error) { expect((error as { code?: string }).code).toBe("injection_settings_changed"); } + } + expect(x.sent).toEqual([]); + expect(x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [customResult()] })).toBe(true); + } finally { x.detach(); } +}); + +test("identical ID spellings for a call and approval remain separate requirements", () => { + const req = nativeToolRequirement(approvalCall())!; + expect(nativeResultMatches(nativeSavedResults([savedResult("approval-item")])[0], req)).toBe(false); + expect(nativeResultMatches(nativeSavedResults([approvalResult(false)])[0], req)).toBe(true); +}); + +test("same call ID reused by another agent or tool type fails closed", () => { + const x = unit(); + try { + x.item(customCall({ agent: { agent_name: "/root/a" } })); + expect(() => x.item(customCall({ agent: { agent_name: "/root/b" } }), 1)).toThrow(); + } finally { x.detach(); } +}); + +test("oversized rich continuation is refused before send; original call remains available", () => { + const x = unit(); + try { + x.item(customCall()); x.terminal(); + expect(() => x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [customResult({ output: [{ type: "input_text", text: "x".repeat(MAX_NATIVE_INJECTION_BYTES) }] })] })).toThrow(); + expect(x.sent).toEqual([]); + expect(x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [customResult()] })).toBe(true); + } finally { x.detach(); } +}); + +test("continuation history is detached from later caller mutation", () => { + const x = unit(); + try { + x.item(customCall()); x.terminal(); + const input = [customResult()]; + x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input }); + input[0].output[0].text = "changed"; + x.channel.observe({ type: "response.created", response: { id: "r2", previous_response_id: "r1" } }); + x.channel.observe({ type: "response.completed", response: { id: "r2", output: [] } }); + expect(x.sent[0].input[0].output[0].text).toBe("fixture result"); + expect(x.remembered.at(-1)?.input.at(-1)).toEqual(customResult()); + } finally { x.detach(); } +}); + +const hosted = [ + { type: "multi_agent_call", id: "host-call", call_id: "server-call", action: "spawn_agent", arguments: "{}", agent: { agent_name: "/root" } }, + { type: "multi_agent_call_output", id: "host-result", call_id: "server-call", action: "spawn_agent", output: [{ type: "output_text", text: "fixture", annotations: [] }], agent: { agent_name: "/root" } }, + { type: "agent_message", id: "host-message", author: "/root/a", recipient: "/root", content: [{ type: "encrypted_content", encrypted_content: "opaque-fixture" }], agent: { agent_name: "/root" } }, +]; +test("hosted actions and encrypted messages survive wire relay and sparse terminal replay", async () => { + const { socket, sent, send, ws, id } = await beginInjection(); + hosted.forEach((item, index) => emitItem(socket, item, index)); + send({ type: "response.inject", response_id: id, input: [savedResult("server-call")] }); + expect(socket.frames).toHaveLength(1); + const message = { type: "message", id: "last-message", role: "assistant", content: [{ type: "output_text", text: "done", annotations: [] }] }; + emitItem(socket, message, 3); completeInjection(socket, { output: [message] }); + await waitForInjection(() => !ws.data.nativeControl); + expect(sent.filter(frame => frame.type === "response.output_item.done").map(frame => frame.item)).toEqual([...hosted, message]); + expect(fallbackCalls).toBe(0); +}); +test("hosted sparse terminal items are retained in the committed continuation prefix", () => { + const x = unit(); + try { + hosted.forEach((item, index) => x.item(item, index)); x.item(customCall(), 3); + x.channel.observe({ type: "response.completed", response: { id: "r1", output: [customCall()] } }); + x.channel.continue({ type: "response.create", previous_response_id: "r1", multi_agent: { enabled: true }, input: [customResult()] }); + x.channel.observe({ type: "response.created", response: { id: "r2", previous_response_id: "r1" } }); + x.channel.observe({ type: "response.completed", response: { id: "r2", output: [] } }); + expect(x.remembered.at(-1)?.input).toEqual([...hosted, customCall(), customResult()]); + } finally { x.detach(); } +}); +test("sparse terminal merge preserves hosted order and rejects contradictory identity/content", () => { + const done = new Map(hosted.map((item, i) => [i, item])); + expect(nativeResponseOutput(done, [structuredClone(hosted[2])])).toEqual(hosted); + expect(() => nativeResponseOutput(done, [hosted[2], hosted[0]])).toThrow(); + expect(() => nativeResponseOutput(done, [{ ...hosted[0], action: "different" }])).toThrow(); + expect(() => nativeResponseOutput(done, [hosted[0], hosted[0]])).toThrow(); +}); + +for (const injection of [false, true]) for (const steering of [false, true]) { + test(`mode selection is exclusive; injection=${injection}, steering=${steering}`, () => { + const flags = { codexNativeInjection: injection, codexNativeSteering: steering }; + expect(nativeResponseControlMode({ multi_agent: { enabled: true } }, flags)).toBe(injection ? "injection" : undefined); + expect(nativeResponseControlMode({}, flags)).toBe(steering ? "steering" : undefined); + }); +} +test("direct steering construction cannot bypass the single-agent mode boundary", () => { + expect(() => new NativeSteeringChannel({ multi_agent: { enabled: true } })).toThrow(); +}); +test("a completed injection turn may be followed by an explicit ordinary steering turn", async () => { + const { socket, ws, send } = await beginInjection({}, { ...injectionConfig(), codexNativeSteering: true }); + completeInjection(socket); await waitForInjection(() => !ws.data.nativeControl); + send({ type: "response.create", model: "gpt-5.6-sol", input: "new explicit turn" }); + await waitForInjection(() => InjectionSocket.all.length === 2); + expect(ws.data.nativeControl).toBeInstanceOf(NativeSteeringChannel); + expect(socket.frames).toHaveLength(1); expect(fallbackCalls).toBe(0); +}); + + +test("an early same-parent rich continuation cannot escape to normal dispatch", async () => { + const { socket, send, sent, id } = await beginInjection(); + emitItem(socket, customCall(), 0); + send(continuationFrame({ type: "response.create", previous_response_id: id, input: [customResult()] })); + expect(sent.at(-1)?.error.code).toBe("injection_pending"); + expect(socket.frames).toHaveLength(1); expect(InjectionSocket.all).toHaveLength(1); + expect(fallbackCalls).toBe(0); + completeInjection(socket, { output: [customCall()] }); + send(continuationFrame({ type: "response.create", previous_response_id: id, input: [customResult()] })); + await waitForInjection(() => socket.frames.length === 2); + expect(socket.frames[1].input).toEqual([customResult()]); +});