Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ collision-safe public function tool. Matching request history and JSON/SSE funct
translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward
keeps the native private type unchanged.

For OpenCode Go at `https://opencode.ai/zen/go/v1`, requests with `authMode` other
than `"forward"` convert plaintext Codex `agent_message` items into public user messages, preserving content parts and readable author/recipient
metadata. This conversion leaves encrypted or unknown content unchanged and does not apply
to other destinations. Providers using `authMode: "forward"` retain these items unchanged.
See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages)
for the separate opt-in encrypted-task recovery behavior.

The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that
its stricter backend rejects: fully textual `system` messages inside `input` are appended to the
top-level `instructions` string in request order, and the top-level `truncation` field is removed.
Expand Down
29 changes: 29 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -810,3 +810,32 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5
"visionSidecar": { "enabled": true }
}
```

## OpenCode Go session and agent messages

With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and
base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items
become user messages when `authMode` is not `"forward"` (for example, `"key"`).
Providers using `authMode: "forward"` retain these items unchanged. This conversion is scoped to that destination, including
renamed provider entries; other Responses destinations keep their input unchanged.
Author and recipient remain explicit text metadata, and the content parts are preserved.
Encrypted and unknown content is not normalized; native encrypted tasks still require the
separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery).

With task recovery enabled, replayed `NEW_TASK` and `MESSAGE` items reuse a cached assignment only
after validating the caller and matching the parent-thread scope. Replay restoration
does not make a new recovery request or extend cache expiry. Expired or unseen
ciphertext is not replaced. Fresh encrypted `NEW_TASK` and `MESSAGE` items use the same
opt-in recovery path, including native-parent `send_message` delivery. Message type,
sender, recipient, parent scope and caller credentials remain part of validation or cache identity.

When a request contains several agent messages, cached replay restoration checks each
message independently. The cache separates message type, sender, recipient and ciphertext
within the admitted caller/account and parent scope. Fresh recovery only handles the
current tail message (ignoring trailing `compaction_trigger` or `additional_tools` metadata).
It does not batch-recover unseen historical messages; those remain unchanged. A cache miss
or expiry does not extend the history-recovery contract.

Sender and recipient on Go Responses are context for the receiving model, not a new
machine-readable routing protocol. Tool routing continues to use the existing collaboration
contracts.
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,7 @@
"openai-responses-passthrough.test.ts": "responses",
"opencode-cli.test.ts": "providers",
"opencode-free-provider.test.ts": "providers",
"opencode-go-agent-messages.test.ts": "providers",
"opencode-go-deepseek.test.ts": "providers",
"opencode-go-grok46-responses.test.ts": "providers",
"opencode-go-luna-wire.test.ts": "providers",
Expand Down Expand Up @@ -1064,6 +1065,7 @@
"selected-models.test.ts": "codex-integration",
"self-launch-argv.test.ts": "lib",
"server-403-permission-e2e.test.ts": "server",
"server-agent-task-recovery-replay.test.ts": "server",
"server-auth.test.ts": "server",
"server-background-lifecycle.test.ts": "server",
"server-clickjacking-headers.test.ts": "server",
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go";
import { createHash } from "node:crypto";
import type { IncomingMeta, ProviderAdapter } from "./base";
import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types";
Expand Down Expand Up @@ -2354,6 +2355,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
parsed._rawBody,
forward || parsed._previousResponseInputExpanded === true,
);
if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody);
outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId);
// stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the
// tier write so a force-fast/default decision can never mutate parsed._rawBody.
Expand Down
35 changes: 35 additions & 0 deletions src/adapters/opencode-go.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/** Match the Go destination, including user-renamed provider entries. */
export function isOpenCodeGo(baseUrl: string): boolean {
try {
const url = new URL(baseUrl);
return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1";
} catch { return false; }
}

/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */
export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const record = body as Record<string, unknown>;
if (!Array.isArray(record.input)) return body;
let changed = false;
const input = record.input.map((item: unknown) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const message = item as Record<string, unknown>;
if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item;
// Genuine ciphertext and unknown part types must retain their existing fail-closed path.
if (!message.content.every(part => part && typeof part === "object"
&& ["input_text", "input_image", "input_file"].includes(part.type))) return item;
const identities = Object.fromEntries(["author", "recipient"]
.filter(key => typeof message[key] === "string")
.map(key => [key, message[key]]));
changed = true;
return {
type: "message", role: "user",
content: [
...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []),
...message.content,
],
};
});
return changed ? { ...record, input } : body;
}
11 changes: 11 additions & 0 deletions src/server/responses/agent-task-recovery-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,14 @@ export function agentTaskRecoveryWaiterCountForTests(): number {
export function agentTaskRecoveryCacheSnapshotForTests(): { entries: number; bytes: number } {
return { entries: RECOVERY_CACHE.size, bytes: recoveryCacheBytes };
}

/** Read an existing recovery without starting a request or extending its lifetime. */
export function cachedAgentTaskRecovery(key: string): string | null {
const entry = RECOVERY_CACHE.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
deleteRecoveryCacheEntry(key, entry);
return null;
}
return entry.assignment;
}
28 changes: 24 additions & 4 deletions src/server/responses/agent-task-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { readBoundedResponseBody } from "../../lib/bounded-body";
import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors";
import { structurallyValidFernetTokens } from "./encrypted-payload";
import {
cachedAgentTaskRecovery,
discardCachedAgentTaskRecovery,
resetAgentTaskRecoveryCache,
resolveCachedAgentTaskRecovery,
Expand Down Expand Up @@ -61,15 +62,15 @@ interface AgentEnvelope {
itemIndex: number;
encryptedIndex: number;
headerText: string;
messageType: "NEW_TASK";
messageType: "NEW_TASK" | "MESSAGE";
taskName: string;
sender: string;
ciphertext: string;
author: string;
recipient: string;
}

const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;

function findEnvelope(input: unknown): AgentEnvelope | null {
if (!Array.isArray(input)) return null;
Expand All @@ -90,7 +91,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
if (!Array.isArray(content)) return null;

let headerText: string | null = null;
let messageType: "NEW_TASK" | null = null;
let messageType: "NEW_TASK" | "MESSAGE" | null = null;
let taskName: string | null = null;
let sender: string | null = null;
let encryptedIndex = -1;
Expand All @@ -113,7 +114,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|| part.text.slice(match.index + match[0].length).trim().length > 0
) return null;
headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0];
messageType = "NEW_TASK";
messageType = match[1] as "NEW_TASK" | "MESSAGE";
taskName = match[2]!;
sender = match[3]!;
}
Expand Down Expand Up @@ -496,3 +497,22 @@ export function discardEncryptedAgentTaskRecovery(
export function resetAgentTaskRecoveryState(): void {
resetAgentTaskRecoveryCache();
}

/** Codex replays the original encrypted agent messages after tool calls. Reuse only an admitted cache hit. */
export function restoreCachedEncryptedAgentTasks(
req: Request, input: unknown, config: OcxConfig,
context: { parentThreadId?: string | null } = {},
): number {
if (!Array.isArray(input)) return 0;
let restored = 0;
for (const item of input) {
if (!item || typeof item !== "object" || item.type !== "agent_message") continue;
const single = [item];
// Revalidates caller credentials and the exact supported agent envelope before cache access.
const admitted = admittedRecovery(req, single, config, context.parentThreadId);
if (!admitted) continue;
const assignment = cachedAgentTaskRecovery(admitted.cacheKey);
if (assignment && injectAssignment(single, admitted.envelope, assignment)) restored += 1;
}
return restored;
}
11 changes: 8 additions & 3 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ import {
agentTaskRecoveryConfig,
discardEncryptedAgentTaskRecovery,
recoverEncryptedAgentTask,
restoreCachedEncryptedAgentTasks,
} from "./agent-task-recovery";
import { relaySseEagerBounded } from "../relay-eager";
import {
Expand Down Expand Up @@ -3234,14 +3235,18 @@ async function handleResponsesInner(
inboundWire === "responses"
&&
threadSpawn
&& unreadableEncryptedAgentTask
&& agentTaskRecovery
&& !isCanonicalOpenAiForwardProvider(route.provider)
&& !options.comboAttempt
&& !canPassThroughEncryptedV2AgentTask(route, inboundWire)
) {
let recovered = false;
try {
let recovered = restoreCachedEncryptedAgentTasks(
req, (body as { input?: unknown } | undefined)?.input, config, { parentThreadId },
) > 0;
unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
(body as { input?: unknown } | undefined)?.input,
);
if (unreadableEncryptedAgentTask) try {
recovered = await recoverEncryptedAgentTask(
req,
(body as { input?: unknown } | undefined)?.input,
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@
"openai-responses-passthrough.test.ts": "responses",
"opencode-cli.test.ts": "providers",
"opencode-free-provider.test.ts": "providers",
"opencode-go-agent-messages.test.ts": "providers",
"opencode-go-deepseek.test.ts": "providers",
"opencode-go-grok46-responses.test.ts": "providers",
"opencode-go-luna-wire.test.ts": "providers",
Expand Down Expand Up @@ -901,6 +902,7 @@
"selected-models.test.ts": "codex-integration",
"self-launch-argv.test.ts": "lib",
"server-403-permission-e2e.test.ts": "server",
"server-agent-task-recovery-replay.test.ts": "server",
"server-auth.test.ts": "server",
"server-background-lifecycle.test.ts": "server",
"server-clickjacking-headers.test.ts": "server",
Expand Down
56 changes: 56 additions & 0 deletions tests/providers/opencode-go-agent-messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from "bun:test";
import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses";
import { normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go";
import { parseRequest } from "../../src/responses/parser";
import { createTranslatorBudget } from "../../src/lib/translator-budget";
import type { OcxProviderConfig } from "../../src/types";

const base: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "synthetic-key" };
const body = () => ({ model: "muse-spark-1.3-contributor", input: [{ type: "agent_message", id: "amsg_test", author: "/root/reader", recipient: "/root/checker", content: [{ type: "input_text", text: "Exact assignment\nwith lines." }] }], stream: true });

test("Responses converts plaintext task and peer messages without mutating replay or losing routing identities", async () => {
const raw = body(); const original = structuredClone(raw); const budget = createTranslatorBudget();
const request = await createResponsesPassthroughAdapter(base).buildRequest(parseRequest(raw), { headers: new Headers(), translatorBudget: budget });
const sent = JSON.parse(request.body as string);
expect(sent.input[0].type).toBe("message");
expect(sent.input[0].role).toBe("user");
expect(sent.input[0].content[0].text).toContain('"author":"/root/reader"');
expect(sent.input[0].content[0].text).toContain('"recipient":"/root/checker"');
expect(sent.input[0].content[1]).toEqual(raw.input[0]!.content[0]);
expect(sent.input[0].id).toBeUndefined();
expect(raw).toEqual(original);
budget.dispose();
});

test("ciphertext and unknown content are never reclassified as plaintext", () => {
for (const part of [{ type: "encrypted_content", encrypted_content: "opaque" }, { type: "future_type", text: "opaque" }]) {
const raw = { input: [{ type: "agent_message", content: [part] }] };
expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw);
}
});

test("image parts stay intact beside the assignment", () => {
const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" };
const raw = { input: [{ type: "agent_message", content: [{ type: "input_text", text: "Inspect image" }, image] }] };
const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw;
expect(result.input[0]!.content[1]).toBe(image);
});

test("native forward keeps agent_message and auth/session headers unchanged", async () => {
const budget = createTranslatorBudget();
const provider = { ...base, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const };
const request = await createResponsesPassthroughAdapter(provider).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "native-id", authorization: "Bearer native-test" }), translatorBudget: budget });
expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message");
expect(new Headers(request.headers).get("x-opencode-session")).toBeNull();
expect(new Headers(request.headers).get("session-id")).toBe("native-id");
expect(new Headers(request.headers).get("authorization")).toBe("Bearer native-test");
budget.dispose();
});

test("other destinations do not get Go normalization or session identity", async () => {
const budget = createTranslatorBudget();
const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget });
expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message");
expect(new Headers(request.headers).get("x-opencode-session")).toBeNull();
budget.dispose();
});
Loading
Loading