Skip to content

[Responses] Codex task-input envelopes are rejected by translated adapters #3735

Closed

Description

@yrlan-montagnier

Client or integration

Codex App

Provider or upstream service

Translated Responses adapters, reproduced with Kiro

OpenCodex version

2.44.0 / af344a2

Endpoint or capability

POST /v1/responses, Codex task coordination

Current behaviour

Codex task coordination can add a result-shaped external input item to the destination task. The observed envelope has type function_call_output, non-empty id, name, and namespace fields, a representable output, and no call_id field. It represents input supplied by another task rather than the result of a tool call made in the destination task.

The common Responses parser currently treats this envelope as a standard tool result. The translated-adapter guard added by #3471 then correctly sees a missing tool identity and returns HTTP 400 before calling the upstream adapter. This prevents Codex from creating or resuming tasks through translated adapters.

Expected behaviour

Only the complete external task-input envelope described above should be converted to user input before adapter translation. Standard function_call_output or custom_tool_call_output items without a non-empty call_id must continue returning HTTP 400, as deliberately implemented by #3471. Items with call_id remain tool results, and incomplete or malformed external envelopes must also remain invalid.

Minimal redacted request or reproduction

Submit a Responses request through a translated adapter with this item in input:

{
  "type": "function_call_output",
  "id": "fco_redacted",
  "name": "send_message_to_thread",
  "namespace": "codex_app",
  "output": "<redacted external task input>"
}

The destination task fails before the provider is called.

Actual response or error

HTTP 400
tool result requires a non-empty string call_id

Upstream documentation

OpenAI Responses API reference: https://platform.openai.com/docs/api-reference/responses/create

The public Responses contract uses call_id to associate actual function tool outputs with calls. No public specification was found for Codex App's inter-task input envelope; that envelope is a concrete Codex App client requirement observed during task creation, follow-up messaging, and task resumption.

Suggested mapping or implementation notes

Preserve the translated-adapter guard from #3471. Before producing common parser messages, recognize only a function_call_output that has no call_id property, has non-empty string id, name, and namespace fields, and has representable output content. Convert that narrowly identified external item to user input, preserving its supported content exactly and without adding an orphan-tool marker such as [tool output for unknown call].

Do not match specific tool names, codex_app, XML tags, or ID prefixes. Do not repair custom_tool_call_output items, items with call_id: "", incomplete provenance, or malformed output content. Negative tests should keep all of those cases on the existing HTTP 400 path. Passthrough and routed compaction behavior must remain unchanged because they use the raw request body.

Additional context and attachments

Related: #3259 and merged PR #3471. The same follow-up report was previously opened as #3732 and closed only because it did not use a recognized issue template.

The initial broad repair was rejected during local review because it would have reversed #3471. The narrowed implementation above was validated with 127 parser and compaction-routing tests, 160 passthrough tests, typecheck, and git diff --check. After restarting the proxy on the local fix, live Codex checks covered task creation, a real exec call, a follow-up after deleting the exec input file, and a same-directory fork. The source task retained values from its earlier tool result, and the fork reproduced all three markers from inherited history together with the source-thread delegation envelope. Archive/unarchive and resuming the originally affected task were also exercised. The broad changed-test suite was not rerun because a previous run exhausted system memory and the Linux kernel terminated Chromium; affected suites were run independently.

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    toolstool_calls, MCP, web-search / sidecar tools
    on Sep 6, 2026
  2. yrlan-montagnier commented on Sep 6, 2026

    @yrlan-montagnier
    ContributorAuthor

    I implemented and validated a narrow local patch against base commit af344a28eabcee09a5e04c48ab897449792719c2 (the current origin/dev at the time of testing).

    The patch preserves the guard introduced by merged PR #3471. It recognizes external Codex task input only when all of these conditions hold:

    • exact type function_call_output;
    • the call_id property is absent, rather than empty;
    • id, name, and namespace are non-blank strings;
    • the output is representable and contains usable text or an image.

    That envelope becomes ordinary user input before translated-adapter validation. It is not given an orphan-tool marker. Standard or malformed outputs still become tool results with an empty identity and therefore keep the HTTP 400 behavior from #3471. The shared conversion helper also removes duplicate passthrough repair logic without changing that path's marker behavior.

    Local patch
    diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts
    index 1faa9c0cb..f44f84a91 100644
    --- a/src/adapters/openai-responses.ts
    +++ b/src/adapters/openai-responses.ts
    @@ -6,6 +6,7 @@ import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
     import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_HEADER } from "../codex/forward-transport-headers";
     import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../responses/compaction";
     import { collectResponsesToolGroups } from "../responses/tool-groups";
    +import { isRepairableToolOutput, orphanedToolOutputContent } from "../responses/tool-output-repair";
     import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy";
     import { decodeServerSentEvents } from "../lib/sse-decoder";
     import { debugProviderDiagnostic } from "../lib/debug";
    @@ -898,73 +899,6 @@ function repairOversizedReplayCallIds(body: unknown): unknown {
       return changed ? { ...body, input } : body;
     }
     
    -/** Flatten a Responses tool-output `output` value (string or content-part array) to plain text. */
    -function toolOutputText(output: unknown): string {
    -  if (typeof output === "string") return output;
    -  if (!Array.isArray(output)) return JSON.stringify(output ?? "");
    -  return output.map(part => {
    -    if (!isPlainObject(part)) return "";
    -    if (typeof part.text === "string") return part.text;
    -    if (part.type === "refusal" && typeof part.refusal === "string") return `[refusal] ${part.refusal}`;
    -    return "";
    -  }).filter(Boolean).join("\n");
    -}
    -
    -/** True when an output can be losslessly represented as user-message content. */
    -function isRepairableToolOutput(output: unknown): output is string | Record<string, unknown>[] {
    -  if (typeof output === "string") return true;
    -  if (!Array.isArray(output)) return false;
    -  return output.every(part => {
    -    if (!isPlainObject(part)) return false;
    -    if (typeof part.type !== "string") return false;
    -    if (["output_text", "text", "input_text"].includes(part.type)) {
    -      return typeof part.text === "string";
    -    }
    -    if (part.type === "refusal") return typeof part.refusal === "string";
    -    if (part.type === "encrypted_content") return typeof part.encrypted_content === "string";
    -    if (part.type !== "input_image") return false;
    -    const imageUrl = part.image_url;
    -    const fileId = part.file_id;
    -    const imageUrlIsString = typeof imageUrl === "string";
    -    const fileIdIsString = typeof fileId === "string";
    -    const hasUsableSource = (imageUrlIsString && imageUrl.length > 0)
    -      || (fileIdIsString && fileId.length > 0);
    -    const validSource = hasUsableSource
    -      && (part.image_url === undefined || imageUrlIsString)
    -      && (part.file_id === undefined || fileIdIsString);
    -    const validDetail = part.detail === undefined
    -      || (typeof part.detail === "string"
    -        && ["auto", "low", "high", "original"].includes(part.detail));
    -    return validSource && validDetail;
    -  });
    -}
    -
    -/** Convert orphaned tool output to user-message content without discarding valid images. */
    -function orphanedToolOutputContent(output: unknown, callId = ""): Record<string, unknown>[] {
    -  const marker = `[tool output for ${callId || "unknown call"}]`;
    -  if (typeof output !== "string" && !Array.isArray(output)) {
    -    return [{ type: "input_text", text: marker }];
    -  }
    -  if (!Array.isArray(output)) {
    -    return [{ type: "input_text", text: `${marker}\n${toolOutputText(output)}` }];
    -  }
    -
    -  const content: Record<string, unknown>[] = [{ type: "input_text", text: marker }];
    -  for (const part of output) {
    -    if (!isPlainObject(part)) continue;
    -    if (part.type === "input_image") {
    -      content.push(part);
    -    } else if (part.type === "encrypted_content" && typeof part.encrypted_content === "string") {
    -      content.push({ type: "input_text", text: "[encrypted content omitted]" });
    -    } else if (typeof part.text === "string") {
    -      content.push({ type: "input_text", text: part.text });
    -    } else if (part.type === "refusal" && typeof part.refusal === "string") {
    -      content.push({ type: "input_text", text: `[refusal] ${part.refusal}` });
    -    }
    -  }
    -  return content;
    -}
    -
     /** True when a Responses tool output item is present but carries no usable content. */
     function isToolOutputEmpty(output: unknown): boolean {
       if (typeof output === "string") return output.trim() === "";
    diff --git a/src/responses/parser.ts b/src/responses/parser.ts
    index c60f44140..8ecaa0a74 100644
    --- a/src/responses/parser.ts
    +++ b/src/responses/parser.ts
    @@ -21,6 +21,7 @@ import { decodeReasoningEnvelope } from "./reasoning-envelope";
     import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
     import { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
     import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat";
    +import { externalToolInputContent, isExternalToolInputItem } from "./tool-output-repair";
     
     import { isObj, inputContentParts, outputTextOf, outputToToolResultContent, toolOutputContainsEncryptedContent } from "./parser-content";
     import { mapToolChoice, buildTools, customToolNamespaces } from "./parser-tools";
    @@ -429,12 +430,22 @@ export function parseRequest(
           }
     
           if (effectiveType === "function_call_output") {
    -        const output = item as { call_id: string; output?: string | unknown[] };
    -        attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning);
    +        const output = item as { call_id?: unknown; output?: string | unknown[] };
    +        const callId = typeof output.call_id === "string" ? output.call_id : "";
    +        if (isExternalToolInputItem(item)) {
    +          pendingReasoning.length = 0;
    +          messages.push({
    +            role: "user",
    +            content: inputContentParts(externalToolInputContent(output.output)),
    +            timestamp: now,
    +          });
    +          continue;
    +        }
    +        attachPendingReasoningToCallOwner(messages, callId, pendingReasoning);
             pendingReasoning.length = 0;
    -        const toolInfo = findToolById(messages, output.call_id);
    +        const toolInfo = findToolById(messages, callId);
             messages.push({
    -          role: "toolResult", toolCallId: output.call_id,
    +          role: "toolResult", toolCallId: callId,
               toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
               content: outputToToolResultContent(output.output), isError: false, timestamp: now,
               ...(toolOutputContainsEncryptedContent(output.output) ? { containsEncryptedContent: true } : {}),
    @@ -443,12 +454,13 @@ export function parseRequest(
           }
     
           if (effectiveType === "custom_tool_call_output") {
    -        const output = item as { call_id: string; output: string | unknown[] };
    -        attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning);
    +        const output = item as { call_id?: unknown; output?: string | unknown[] };
    +        const callId = typeof output.call_id === "string" ? output.call_id : "";
    +        attachPendingReasoningToCallOwner(messages, callId, pendingReasoning);
             pendingReasoning.length = 0;
    -        const toolInfo = findToolById(messages, output.call_id);
    +        const toolInfo = findToolById(messages, callId);
             messages.push({
    -          role: "toolResult", toolCallId: output.call_id,
    +          role: "toolResult", toolCallId: callId,
               toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
               // Same payload shape as function_call_output (codex-rs FunctionCallOutputPayload):
               // string or content items — normalize arrays instead of leaking raw wire blocks.
    diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts
    index d1c3e8f1e..d2855ef87 100644
    --- a/src/server/responses/core.ts
    +++ b/src/server/responses/core.ts
    @@ -5446,14 +5446,15 @@ async function handleResponsesInner(
         }
       }
     
    -  // Tool results are PAIRED by call_id. parseRequest writes it into OcxToolResultMessage.toolCallId
    -  // (parser.ts:738/752) without validating it, because inputItemSchema's permissive catch-all
    -  // (schema.ts:106) accepts a tool item whose strict schema failed only for a missing call_id. A
    +  // Tool results are PAIRED by call_id. The result-shaped envelope used by Codex task coordination
    +  // carries its own item/tool identity and is preserved by parseRequest as external user input.
    +  // The schema catch-all can still admit a malformed standard output whose strict schema failed
    +  // together with its call_id, leaving an unusable OcxToolResultMessage. A
       // translating adapter then consumes `toolCallId: string` holding undefined: kiro-wire.ts:32
       // TypeErrors, ollama-native.ts:334 throws, and anthropic.ts:775 sends
       // "[tool_result without adjacent tool_use: undefined]" upstream (issue #3259).
       //
    -  // This CANNOT move into the schema. parseRequest (:2812) runs before the passthrough branch
    +  // This CANNOT move into the schema. parseRequest runs before the passthrough branch
       // (:3719), so a parse-time rejection would also kill forward/key passthrough and routed
       // compaction — paths that never read context.messages, build from _rawBody, and already
       // degrade an unpaired output to "[tool output for unknown call]" on their own.
    diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts
    index e6f46ab3c..8383d5f02 100644
    --- a/tests/responses/responses-compaction-routing.test.ts
    +++ b/tests/responses/responses-compaction-routing.test.ts
    @@ -1655,7 +1655,35 @@ describe("unpaired tool result boundary (#3259)", () => {
         },
       } as unknown as OcxConfig);
     
    -  test("a translating adapter rejects a call_id-less tool result with 400 and sends nothing upstream", async () => {
    +  test("a translating adapter preserves a Codex external-input envelope", async () => {
    +    const bodies: string[] = [];
    +    globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
    +      bodies.push(String(init?.body ?? ""));
    +      return jsonResponse({
    +        id: "msg_1", type: "message", role: "assistant", model: "claude",
    +        content: [{ type: "text", text: "ok" }], stop_reason: "end_turn",
    +        usage: { input_tokens: 1, output_tokens: 1 },
    +      });
    +    }) as typeof fetch;
    +
    +    const res = await handleResponses(
    +      compactionRequest(unpairedBody({
    +        type: "function_call_output", id: "fco_delegation",
    +        name: "create_thread", namespace: "codex_app",
    +        output: "<codex_delegation>Inspect the adapter.</codex_delegation>",
    +      })),
    +      anthropicConfig(),
    +      { model: "", provider: "" },
    +    );
    +
    +    expect(res.status).toBe(200);
    +    expect(bodies).toHaveLength(1);
    +    expect(bodies[0]).toContain("<codex_delegation>Inspect the adapter.</codex_delegation>");
    +    expect(bodies[0]).not.toContain("[tool output for unknown call]");
    +    expect(bodies[0]).not.toContain("undefined");
    +  });
    +
    +  test("a malformed call_id-less output is rejected before any upstream request", async () => {
         let fetches = 0;
         globalThis.fetch = (async () => {
           fetches += 1;
    @@ -1663,32 +1691,34 @@ describe("unpaired tool result boundary (#3259)", () => {
         }) as typeof fetch;
     
         const res = await handleResponses(
    -      compactionRequest(unpairedBody({ type: "function_call_output", output: "bootstrap result" })),
    +      compactionRequest(unpairedBody({
    +        type: "function_call_output", output: [{ type: "input_image", image_url: 42 }],
    +      })),
           anthropicConfig(),
           { model: "", provider: "" },
         );
    -
         expect(res.status).toBe(400);
    -    const json = await res.json() as { error?: { message?: string; type?: string; code?: string } };
    +    const json = await res.json() as { error?: { message?: string } };
         expect(json.error?.message).toBe("tool result requires a non-empty string call_id");
    -    expect(json.error?.type).toBe("invalid_request_error");
    -    expect(json.error?.code).toBe("invalid_request_error");
    -    // The tool output itself must never be interpolated into a client-visible message.
    -    expect(JSON.stringify(json)).not.toContain("bootstrap result");
         expect(fetches).toBe(0);
       });
     
    -  test("an empty-string call_id is rejected identically (it can never pair)", async () => {
    +  test("a standard string output without call_id remains rejected before upstream", async () => {
    +    let fetches = 0;
         globalThis.fetch = (async () => {
    +      fetches += 1;
           throw new Error("the guard must reject before any upstream request");
         }) as typeof fetch;
     
         const res = await handleResponses(
    -      compactionRequest(unpairedBody({ type: "custom_tool_call_output", call_id: "", output: "x" })),
    +      compactionRequest(unpairedBody({ type: "function_call_output", output: "x" })),
           anthropicConfig(),
           { model: "", provider: "" },
         );
         expect(res.status).toBe(400);
    +    const json = await res.json() as { error?: { message?: string } };
    +    expect(json.error?.message).toBe("tool result requires a non-empty string call_id");
    +    expect(fetches).toBe(0);
       });
     
       test("a paired tool result on the same translating route still reaches the upstream", async () => {
    diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts
    index 83e5687a0..0794089ab 100644
    --- a/tests/responses/responses-parser.test.ts
    +++ b/tests/responses/responses-parser.test.ts
    @@ -897,19 +897,25 @@ describe("unpaired tool result boundary (#3259)", () => {
           | { toolCallId?: unknown; content?: unknown }
           | undefined;
     
    -  test("a function_call_output with no call_id still parses, and yields an unusable toolCallId", () => {
    -    // This is the state src/server/responses/core.ts guards on. `toolCallId` is declared
    -    // `string` (src/types/request.ts:168) but is undefined here — the schema catch-all
    -    // (schema.ts:106) accepted the item and parser.ts:738 assigned it unchecked.
    -    const result = toolResultOf({ type: "function_call_output", output: "bootstrap result" });
    -    expect(result).toBeDefined();
    -    expect(typeof result?.toolCallId).not.toBe("string");
    +  test("a Codex external-input envelope becomes user input", () => {
    +    const parsed = parseRequest(delegationHistory({
    +      type: "function_call_output",
    +      id: "fco_delegation",
    +      name: "create_thread",
    +      namespace: "codex_app",
    +      output: "<codex_delegation>Inspect the adapter.</codex_delegation>",
    +    }));
    +
    +    expect(parsed.context.messages.find(message => message.role === "toolResult")).toBeUndefined();
    +    expect(parsed.context.messages.at(-1)).toMatchObject({
    +      role: "user",
    +      content: "<codex_delegation>Inspect the adapter.</codex_delegation>",
    +    });
       });
     
    -  test("an empty-string call_id is equally unusable", () => {
    -    // findToolById (parser.ts:328) matches by identity, so "" can never pair. The guard
    -    // must treat it exactly like undefined.
    +  test("an empty-string call_id remains an invalid tool result", () => {
         const result = toolResultOf({ type: "function_call_output", call_id: "", output: "x" });
    +    expect(result).toBeDefined();
         expect(result?.toolCallId).toBe("");
       });
     
    @@ -918,10 +924,64 @@ describe("unpaired tool result boundary (#3259)", () => {
         expect(result).toMatchObject({ toolCallId: "call_1", content: "ok" });
       });
     
    -  test("custom_tool_call_output has the identical hole (parser.ts:752)", () => {
    +  test("a custom output without call_id remains an invalid tool result", () => {
         const result = toolResultOf({ type: "custom_tool_call_output", output: "x" });
         expect(result).toBeDefined();
    -    expect(typeof result?.toolCallId).not.toBe("string");
    +    expect(result?.toolCallId).toBe("");
    +  });
    +
    +  test("valid unidentified output preserves structured image content", () => {
    +    const imageUrl = "data:image/png;base64,AAAA";
    +    const parsed = parseRequest(delegationHistory({
    +      type: "function_call_output",
    +      id: "fco_external_image",
    +      name: "send_message_to_thread",
    +      namespace: "codex_app",
    +      output: [
    +        { type: "input_text", text: "screenshot" },
    +        { type: "input_image", image_url: imageUrl, detail: "original" },
    +        { type: "encrypted_content", encrypted_content: "opaque" },
    +      ],
    +    }));
    +    expect(parsed.context.messages.at(-1)).toMatchObject({
    +      role: "user",
    +      content: [
    +        { type: "text", text: "screenshot" },
    +        { type: "image", imageUrl, detail: "high" },
    +        { type: "text", text: "[encrypted content omitted]" },
    +      ],
    +    });
    +  });
    +
    +  test("malformed unidentified output remains rejectable instead of becoming user input", () => {
    +    const result = toolResultOf({
    +      type: "function_call_output",
    +      output: [{ type: "input_image", image_url: 42 }],
    +    });
    +    expect(result).toBeDefined();
    +    expect(result?.toolCallId).toBe("");
    +  });
    +
    +  test("a result-shaped item with incomplete external provenance remains rejectable", () => {
    +    const result = toolResultOf({
    +      type: "function_call_output", id: "fco_incomplete", name: "create_thread", output: "x",
    +    });
    +    expect(result).toBeDefined();
    +    expect(result?.toolCallId).toBe("");
    +  });
    +
    +  test("blank external provenance or content remains rejectable", () => {
    +    for (const item of [
    +      { type: "function_call_output", id: " ", name: "create_thread", namespace: "codex_app", output: "x" },
    +      { type: "function_call_output", id: "fco_1", name: " ", namespace: "codex_app", output: "x" },
    +      { type: "function_call_output", id: "fco_1", name: "create_thread", namespace: " ", output: "x" },
    +      { type: "function_call_output", id: "fco_1", name: "create_thread", namespace: "codex_app", output: " " },
    +      { type: "function_call_output", id: "fco_1", name: "create_thread", namespace: "codex_app", output: [] },
    +    ]) {
    +      const result = toolResultOf(item);
    +      expect(result).toBeDefined();
    +      expect(result?.toolCallId).toBe("");
    +    }
       });
     
       test("tolerances unrelated to call_id stay intact", () => {
    diff --git a/src/responses/tool-output-repair.ts b/src/responses/tool-output-repair.ts
    new file mode 100644
    index 000000000..326ff773f
    --- /dev/null
    +++ b/src/responses/tool-output-repair.ts
    @@ -0,0 +1,82 @@
    +import { isObj } from "./parser-content";
    +
    +/** True when a Responses tool output can be represented without losing supported content. */
    +export function isRepairableToolOutput(output: unknown): output is string | Record<string, unknown>[] {
    +  if (typeof output === "string") return true;
    +  if (!Array.isArray(output)) return false;
    +  return output.every(part => {
    +    if (!isObj(part) || typeof part.type !== "string") return false;
    +    if (["output_text", "text", "input_text"].includes(part.type)) {
    +      return typeof part.text === "string";
    +    }
    +    if (part.type === "refusal") return typeof part.refusal === "string";
    +    if (part.type === "encrypted_content") return typeof part.encrypted_content === "string";
    +    if (part.type !== "input_image") return false;
    +
    +    const imageUrl = part.image_url;
    +    const fileId = part.file_id;
    +    const imageUrlIsString = typeof imageUrl === "string";
    +    const fileIdIsString = typeof fileId === "string";
    +    const hasUsableSource = (imageUrlIsString && imageUrl.length > 0)
    +      || (fileIdIsString && fileId.length > 0);
    +    const validSource = hasUsableSource
    +      && (part.image_url === undefined || imageUrlIsString)
    +      && (part.file_id === undefined || fileIdIsString);
    +    const validDetail = part.detail === undefined
    +      || (typeof part.detail === "string"
    +        && ["auto", "low", "high", "original"].includes(part.detail));
    +    return validSource && validDetail;
    +  });
    +}
    +
    +/**
    + * Codex task coordination delivers external input in a result-shaped envelope. Unlike an actual
    + * Responses tool result, the envelope identifies its source item and tool but has no `call_id`
    + * because the destination task did not make the call. Keep the shape narrow so malformed standard
    + * tool results still reach the translating-route guard added for issue #3259.
    + */
    +export function isExternalToolInputItem(item: unknown): item is Record<string, unknown> {
    +  return isObj(item)
    +    && item.type === "function_call_output"
    +    && !("call_id" in item)
    +    && typeof item.id === "string"
    +    && item.id.trim().length > 0
    +    && typeof item.name === "string"
    +    && item.name.trim().length > 0
    +    && typeof item.namespace === "string"
    +    && item.namespace.trim().length > 0
    +    && isRepairableToolOutput(item.output)
    +    && externalToolInputContent(item.output).some(part =>
    +      part.type === "input_image"
    +      || (typeof part.text === "string" && part.text.trim().length > 0));
    +}
    +
    +/** Preserve external Codex task input as user content without inventing tool-result context. */
    +export function externalToolInputContent(output: unknown): Record<string, unknown>[] {
    +  if (typeof output === "string") return [{ type: "input_text", text: output }];
    +  if (!Array.isArray(output)) return [];
    +
    +  const content: Record<string, unknown>[] = [];
    +  for (const part of output) {
    +    if (!isObj(part)) continue;
    +    if (part.type === "input_image") {
    +      content.push(part);
    +    } else if (part.type === "encrypted_content" && typeof part.encrypted_content === "string") {
    +      content.push({ type: "input_text", text: "[encrypted content omitted]" });
    +    } else if (typeof part.text === "string") {
    +      content.push({ type: "input_text", text: part.text });
    +    } else if (part.type === "refusal" && typeof part.refusal === "string") {
    +      content.push({ type: "input_text", text: `[refusal] ${part.refusal}` });
    +    }
    +  }
    +  return content;
    +}
    +
    +/** Convert an unidentified or orphaned tool output into Responses user-message content. */
    +export function orphanedToolOutputContent(output: unknown, callId = ""): Record<string, unknown>[] {
    +  const marker = `[tool output for ${callId || "unknown call"}]`;
    +  if (typeof output === "string") return [{ type: "input_text", text: `${marker}\n${output}` }];
    +  const content = externalToolInputContent(output);
    +  content.unshift({ type: "input_text", text: marker });
    +  return content;
    +}

    Validation:

    • bun test tests/responses/responses-parser.test.ts tests/responses/responses-compaction-routing.test.ts: 127 pass, 0 fail
    • bun test tests/responses/openai-responses-passthrough.test.ts: 160 pass, 0 fail
    • bun run typecheck: passed
    • git diff --check: passed
    • Live Codex/Kiro checks: task creation, real exec, follow-up after deleting the read file, and a fork that recovered inherited context

    The patch is currently uncommitted, so this comment records the exact proposed implementation rather than pointing to a branch that may change.

  3. Ingwannu commented on Sep 6, 2026

    @Ingwannu
    Owner

    Confirmed the current parser behavior at af344a28eabcee09a5e04c48ab897449792719c2: your complete external envelope becomes role: toolResult with an undefined call id. The translated-route guard in src/server/responses/core.ts then rejects that shape. A control containing an actual call_id remains a tool result. These were isolated parser checks plus source tracing, not a fresh live task-creation test.

    Please turn the supplied local patch into a separate focused PR against dev. Preserve #3471's existing missing/empty-call-id rejection and keep passthrough/compaction raw-body behavior unchanged.

    One concrete issue in the posted helper needs adjustment before review: isRepairableToolOutput() accepts encrypted_content, and externalToolInputContent() replaces it with the non-empty string [encrypted content omitted]. As a result, an envelope whose output contains only encrypted content passes isExternalToolInputItem()'s meaningful-content check and becomes fabricated user input despite having no recoverable task text. That contradicts the proposed representable-content boundary. Do not let placeholder text establish eligibility; reject unsupported/opaque-only envelopes and add a regression for them. Explicitly test mixed supported/unsupported content as well so partial conversion cannot silently drop task input.

    This is a narrower issue than ordinary orphan-tool repair. The reported client requirement is useful evidence, but arbitrary id/name/namespace strings are structural data, not authentication or proof of upstream provenance. Please document the compatibility assumption and keep the negative matrix in the PR. No approval or merge is implied by this confirmation.

  4. lidge-jun commented on Sep 6, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 76 / 80

    이 이슈는 Codex App이 다른 태스크에서 넘긴 입력을 function_call_output 모양으로 넣는데, call_id가 없어서 번역 어댑터 가드에 걸리는 문제입니다. 지금 dev(HEAD af344a28e, 이슈가 찍은 것과 동일 SHA)에서 src/responses/parser.ts는 function_call_output을 보통 도구 결과로 파싱합니다. #3471이 src/server/responses/core.ts에 넣은 가드는 번역 경로에서 call_id가 비어 있으면 HTTP 400 tool result requires a non-empty string call_id를 냅니다. 공개 Responses 계약의 진짜 도구 결과에는 이게 맞지만, Codex 태스크 조율 envelope는 id/name/namespace/output만 있고 call_id 프로퍼티 자체가 없습니다. 그래서 Kiro 같은 번역 어댑터로 태스크 생성·재개가 막힙니다. 패스스루·라우티드 컴팩션은 raw body를 쓰므로 이 구멍과 무관하다는 설명도 이슈에 있습니다.

    보고자는 #3471 가드를 유지한 채, call_id 키가 없고 id/name/namespace가 비어 있지 않으며 output이 표현 가능할 때만 user input으로 바꾸는 좁은 패치를 댓글에 올렸습니다. src/responses/tool-output-repair.ts를 새로 두고 parser·openai-responses·core 주석·테스트를 고칩니다. OWNER(Ingwannu)도 HEAD에서 재현을 확인했고, 별도 집중 PR로 dev에 내라고 요청했습니다. 동시에 구체적 결함도 짚었습니다. isRepairableToolOutput이 encrypted_content를 허용하고 externalToolInputContent가 [encrypted content omitted] 자리표시로 바꿔, encrypted-only envelope가 “의미 있는 내용” 검사를 통과해 가짜 user input이 됩니다. 자리표시 문자로 자격을 주지 말고, opaque-only는 거부하며, 지원/미지원 혼합 회귀도 넣으라고 했습니다. id/name/namespace는 구조 데이터일 뿐 인증이 아니라고도 못 박았습니다.

    우선순위 76인 이유다. Codex 태스크 조율은 핵심 클라이언트 경로이고, #3259/#3471 불변식과 충돌하지 않게 좁혀야 하는 호환 버그입니다. 재현·패치·메인테이너 확인이 이미 있어 “조사 대기”가 아니라 “PR 착수” 단계입니다. tool-output-repair.ts는 HEAD에 아직 없습니다. 이전 템플릿 없는 #3732는 닫혔고 이 이슈가 정식 후속입니다.

    경로/심볼 - src/responses/parser.ts function_call_output - 현재 call_id 없는 항목도 toolResult로 파싱
    경로/심볼 - src/server/responses/core.ts #3471 가드 - 번역 어댑터에서 빈/없는 call_id → 400
    경로/심볼 - 제안 src/responses/tool-output-repair.ts isExternalToolInputItem - call_id 키 부재 + id/name/namespace + repairable output
    경로/심볼 - Ingwannu 피드백 - encrypted-only가 placeholder로 통과하는 구멍. mixed content 회귀 필요
    경로 - 이슈 본문 음성 JSON - type=function_call_output, id/name/namespace/output, call_id 없음
    경로/심볼 - 관련 #3259 / 머지 #3471 / 닫힌 #3732

    메인테이너의 판단이 필요한 지점

    • external envelope 판별에 id/name/namespace 구조만으로 충분한지(보고·Ingwannu 모두 “인증 아님”에 동의)
    • encrypted-only·malformed·call_id:""·custom_tool_call_output 음성 행렬을 PR 필수 체크리스트로 고정할지
    • 패스스루/컴팩션 raw-body 불변식을 리뷰 게이트에 명시할지

    너의 추천
    Ingwannu가 요청한 대로 encrypted-only 수정 + 음성/양성 회귀를 넣은 집중 PR을 dev에 열어 주세요. 이 이슈는 PR 링크가 오면 그 PR로 추적하고, 머지 후 닫으면 됩니다. 광범위 orphan-repair로 #3471을 되돌리는 방향은 거부합니다.

    이 댓글은 grok-bot이 작성했습니다

  5. lidge-jun commented on Sep 6, 2026

    @lidge-jun
    Owner

    Implemented in #3743 and now on dev, including the stateful raw/parsed guidance-order follow-up. Exact-head Linux/macOS CI passed at b7e67d8: https://github.com/lidge-jun/opencodex/actions/runs/34014313740. Existing missing-call-id rejection and raw passthrough controls remain covered. No live Kiro call was made; validation used synthetic protocol regressions and the recorded evidence boundary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportstoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions