Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>`.


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).
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
9 changes: 4 additions & 5 deletions src/server/index/websocket-handler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 18 additions & 13 deletions src/server/responses/native-injection-replay.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -9,6 +12,8 @@ export class NativeInjectionReplay implements NativeSteeringReplayObserver {
private output = new Map<number, Frame>();
private accepted = new Map<string, FunctionResult>();
private pending?: FunctionResult[];
private pendingBytes = 0;
private acceptedBatchBytes: number[] = [];
private explicit: unknown[] = [];
private previous: unknown[] = [];

Expand All @@ -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);
Comment thread
luvs01 marked this conversation as resolved.
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<string>();
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);
}
}
Expand All @@ -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;
}
}
Loading
Loading