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
3 changes: 1 addition & 2 deletions docs-site/src/content/docs/guides/sub-agent-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ Generated developer guidance is placed before the current task in both parsed
messages and saved raw history, preserving the same order when that history is replayed.

Malformed, empty, opaque or incomplete envelopes still fail validation. Actual tool
results keep their required `call_id`; native passthrough and compaction retain their
existing raw-input handling. See [the adapter contract](/reference/adapters/#external-task-input-on-translated-responses-routes).
results keep their required `call_id`; native passthrough and compaction apply the same user-turn mapping to their raw bodies. See [the adapter contract](/reference/adapters/#external-task-input-on-translated-responses-routes).

## How it works

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ and image order are preserved; image detail `original` maps to `high`.
Empty content, malformed or opaque parts, file-id-only images and partial envelopes
remain invalid. Ordinary function/custom tool results still require a nonempty
`call_id`. The envelope metadata identifies a compatibility shape and grants no
additional permissions. Native passthrough and compaction retain their raw-body rules.
additional permissions. Native passthrough and compaction apply the same user-turn mapping to their raw bodies.

## `openai-chat`

Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,7 @@
"openai-provider-option.test.ts": "adapters/openai",
"responses-forward-client-headers.test.ts": "responses",
"openai-responses-passthrough.test.ts": "responses",
"external-task-input-repair.test.ts": "responses",
"openai-responses-summary-none.test.ts": "responses",
"responses-forward-output-cap.test.ts": "responses",
"opencode-cli.test.ts": "providers",
Expand Down
14 changes: 14 additions & 0 deletions src/adapters/openai-responses/tool-output-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation";
import { isPlainObject } from "./internal";
import { peekBridgeSearchReplay } from "../../responses/bridge-search-replay-cache";
import { externalTaskInputResponsesContent } from "../../responses/task-input";

const MAX_RESPONSES_CALL_ID_LENGTH = 64;

Expand Down Expand Up @@ -169,6 +170,13 @@ export function repairUnidentifiedToolOutputItems(body: unknown): unknown {
|| (typeof item.call_id === "string" && item.call_id.length > 0)) {
return item;
}
// #6764: an external task envelope is user input, not an orphaned result. Recognize it
// before the generic repair so the summarizer and the destination see a plain user turn.
const taskInput = externalTaskInputResponsesContent(item);
if (taskInput) {
changed = true;
return { type: "message", role: "user", content: taskInput };
}
if (!isRepairableToolOutput(item.output)) return item;
changed = true;
return {
Expand Down Expand Up @@ -380,6 +388,12 @@ export function repairOrphanedInputItems(
const isCustomOutput = item.type === "custom_tool_call_output";
if (isFnOutput || isCustomOutput) {
flushPendingSyntheticOutputs();
const taskInput = isFnOutput ? externalTaskInputResponsesContent(item) : undefined;
if (taskInput) {
changed = true;
repaired.push({ type: "message", role: "user", content: taskInput });
continue;
}
const callId = typeof item.call_id === "string" ? item.call_id : "";
const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId);
const usableOutput = isRepairableToolOutput(item.output);
Expand Down
17 changes: 17 additions & 0 deletions src/responses/task-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,20 @@ export function externalTaskInputContent(item: unknown): string | OcxContentPart
block.type === "output_text" ? { ...block, type: "input_text" } : block,
));
}

/**
* The same recognition, emitted as Responses-wire user content for the raw-body repairs (#6764).
*
* The passthrough and routed-compaction paths never read the parsed context, so they used to
* send this envelope through the generic orphan repair, which prefixes
* "[tool output for unknown call]". A summarizer then read an authorized handover as a stray tool
* result. Recognition is shared with the parser so both paths agree on what is task input.
*/
export function externalTaskInputResponsesContent(item: unknown): Record<string, unknown>[] | undefined {
if (externalTaskInputContent(item) === undefined) return undefined;
const output = (item as { output: string | TaskInputBlock[] }).output;
if (typeof output === "string") return [{ type: "input_text", text: output }];
return output.map(block => block.type === "input_image"
? { type: "input_image", image_url: block.image_url, ...(block.detail ? { detail: block.detail === "original" ? "high" : block.detail } : {}) }
: { type: "input_text", text: block.text });
}
4 changes: 2 additions & 2 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ nonblank `id`/`name`/`namespace`, and fully representable nonempty text/image ou
existing continuation conversation-boundary calculation. The metadata is structural,
not authentication. Unknown/opaque/malformed parts reject the entire conversion;
ordinary missing/empty tool call ids retain the existing translated-route 400 guard.
Native passthrough and compaction retain raw-body handling. The leaf reuses the input
content converter after validation and imports no optional subsystem.
Raw-body passthrough/compaction repairs emit it as a plain user turn before the orphan marker
(`externalTaskInputResponsesContent`, #6764). The leaf imports no optional subsystem.
Stateful developer-guidance injection reuses that validator for its raw insertion
boundary, so parsed messages and stored raw history retain the same task/guidance order.

Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@
"openai-provider-option-startup.test.ts": "adapters/openai",
"openai-provider-option-tooling.test.ts": "adapters/openai", "openai-provider-option.test.ts": "adapters/openai",
"responses-forward-client-headers.test.ts": "responses", "openai-responses-passthrough.test.ts": "responses",
"external-task-input-repair.test.ts": "responses",
"openai-responses-summary-none.test.ts": "responses", "responses-forward-output-cap.test.ts": "responses",
"opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers",
"opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers",
Expand Down
102 changes: 102 additions & 0 deletions tests/responses/external-task-input-repair.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Issue #6764: Codex external task input (a `function_call_output` envelope with no pairing key
* but a complete id/name/namespace) must reach the raw-body Responses paths as the same user turn
* the parser produces, never as "[tool output for unknown call]". The summarizer of a routed
* compaction read the mislabelled handover as an orphaned tool result and lost the active task.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { handleResponses } from "../../src/server/responses";
import { repairOrphanedInputItems, repairUnidentifiedToolOutputItems } from "../../src/adapters/openai-responses/tool-output-recovery";
import { externalTaskInputResponsesContent } from "../../src/responses/task-input";
import { compactionRequest, completedPayload, drainCompactionResponseState, installCompactionRoutingAclFixture, jsonResponse, keyProviderConfig } from "../helpers/compaction-routing-fixtures";
import { acquireOwnedSpendHome } from "../helpers/owned-spend-home";

const DELEGATION = "<codex_delegation>Human-authorized task B</codex_delegation>";
const seed = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
type: "function_call_output",
id: "fco_synthetic",
name: "send_message_to_thread",
namespace: "codex_app",
output: DELEGATION,
...extra,
});
const userTurn = (text: string) => ({ type: "message", role: "user", content: [{ type: "input_text", text }] });

describe("external task input in raw-body repairs (#6764)", () => {
test("unidentified-output repair emits the handover as a plain user turn", () => {
expect(repairUnidentifiedToolOutputItems({ input: [seed()] })).toEqual({ input: [userTurn(DELEGATION)] });
expect(repairUnidentifiedToolOutputItems({ input: [seed({ call_id: null })] })).toEqual({ input: [userTurn(DELEGATION)] });
});

test("forward orphan repair agrees with the unidentified-output repair", () => {
const body = { input: [seed(), { type: "function_call_output", call_id: "call_gone", output: "stale" }] };
expect(repairOrphanedInputItems(body, false, false, true)).toEqual({ input: [
userTurn(DELEGATION),
userTurn("[tool output for call_gone]\nstale"),
] });
// A stateful destination keeps real orphans for previous_response_id, but a keyless envelope
// can never pair with stored state, so it is still task input.
expect(repairOrphanedInputItems({ input: [seed()] }, false, false, false)).toEqual({ input: [userTurn(DELEGATION)] });
});

test("structured output keeps its images and order", () => {
const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "original" };
const output = [{ type: "output_text", text: "look" }, image, { type: "text", text: "then act" }];
expect(externalTaskInputResponsesContent(seed({ output }))).toEqual([
{ type: "input_text", text: "look" }, { ...image, detail: "high" }, { type: "input_text", text: "then act" },
]);
});

test("ordinary orphaned output keeps the existing marker", () => {
// Recognition is structural: delegation text without the envelope is not promoted.
const plain = { type: "function_call_output", id: "fco_plain", output: DELEGATION };
expect(externalTaskInputResponsesContent(plain)).toBeUndefined();
expect(repairUnidentifiedToolOutputItems({ input: [plain] })).toEqual({ input: [
userTurn(`[tool output for unknown call]\n${DELEGATION}`),
] });
expect(externalTaskInputResponsesContent(seed({ call_id: "call_1" }))).toBeUndefined();
});
});

describe("routed compaction summarizes the handover as task input (#6764)", () => {
installCompactionRoutingAclFixture();
const originalFetch = globalThis.fetch;
let releaseSpendHome: (() => void) | undefined;
beforeEach(() => { releaseSpendHome ??= acquireOwnedSpendHome(); });
afterEach(async () => {
try { await drainCompactionResponseState(); } finally {
releaseSpendHome?.();
releaseSpendHome = undefined;
globalThis.fetch = originalFetch;
}
});

test("completed task A, active external task B, then compaction", async () => {
const bodies: string[] = [];
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
bodies.push(String(init?.body ?? ""));
return jsonResponse(completedPayload("summary"));
}) as typeof fetch;

const res = await handleResponses(compactionRequest({
model: "gw/some-model",
stream: false,
input: [
userTurn("Task A: inspect example.txt"),
{ type: "function_call", call_id: "call_a", name: "shell", arguments: "{}" },
{ type: "function_call_output", call_id: "call_a", output: "example.txt inspected" },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Task A is done." }] },
seed(),
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Working on task B." }] },
{ type: "compaction_trigger" },
],
}), keyProviderConfig(), { model: "", provider: "" });

expect(res.status).toBe(200);
await res.text();
expect(bodies).toHaveLength(1);
const sent = JSON.parse(bodies[0]!) as { input: unknown[] };
expect(sent.input).toContainEqual(userTurn(DELEGATION));
expect(bodies[0]).not.toContain("[tool output for unknown call]");
});
});
4 changes: 2 additions & 2 deletions tests/responses/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2911,7 +2911,7 @@ describe("OpenAI Responses passthrough sanitization", () => {
}]);
});

test("external task parsing preserves the existing raw passthrough repair", () => {
test("raw passthrough emits external task input as the same user turn the parser does (#6764)", () => {
const adapter = createResponsesPassthroughAdapter({
adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "key" as const, apiKey: "xai-test",
});
Expand All @@ -2925,7 +2925,7 @@ describe("OpenAI Responses passthrough sanitization", () => {
expect(raw).toEqual(original);
const body = JSON.parse(adapter.buildRequest(parsed, meta).body) as { input: unknown[] };
expect(body.input).toEqual([{ type: "message", role: "user", content: [
{ type: "input_text", text: "[tool output for unknown call]\nexternal input" },
{ type: "input_text", text: "external input" },
] }]);
});

Expand Down
Loading