From a73bb160f252f022d740c0a33ed665462a56c887 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 13:04:51 +0900 Subject: [PATCH 1/5] fix(responses): preserve complete external task-input envelopes Co-authored-by: Yrlan <71253160+yrlan-montagnier@users.noreply.github.com> --- .../020_task_input.md | 17 +++-- .../content/docs/guides/sub-agent-surface.md | 12 ++++ .../src/content/docs/reference/adapters.md | 13 ++++ src/responses/parser.ts | 8 +++ src/responses/task-input.ts | 36 ++++++++++ structure/04_transports-and-sidecars.md | 10 +++ .../openai-responses-passthrough.test.ts | 18 +++++ .../responses-compaction-routing.test.ts | 71 +++++++++++++++++++ 8 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/responses/task-input.ts diff --git a/devlog/_plan/260906_release_244_followups/020_task_input.md b/devlog/_plan/260906_release_244_followups/020_task_input.md index 4bef84a463..ae73aaf816 100644 --- a/devlog/_plan/260906_release_244_followups/020_task_input.md +++ b/devlog/_plan/260906_release_244_followups/020_task_input.md @@ -1,11 +1,11 @@ # External Codex task-input envelopes -Depends on policy; class C3. Fix public issue #3735, observed on baseline dev. Preserve the existing unpaired-tool HTTP 400 guard from #3471. +Depends on policy; class C4 for protocol admission. Fix public issue #3735, observed on baseline dev. Preserve the existing unpaired-tool HTTP 400 guard from #3471. ## Diff-level change map -- MODIFY src/responses/parser.ts at function_call_output classification before tool lookup: route only a complete external task-input envelope to an Ocx user message. Eligibility: type function_call_output, no call_id property (including inherited properties for direct parser calls), nonempty string id/name/namespace, nonempty fully representable text/image output. Do not require specific names, prefixes, namespaces or XML content. Existing standard tool results and custom_tool_call_output keep current path. -- NEW small src/responses/task-input.ts only if predicate/content conversion would make parser more complex: pure recognition returning supported Ocx user content or undefined, no request mutation/network/storage. Reuse existing content converters only when they preserve every accepted output part and reject invalid mixed arrays rather than silently drop them. -- MODIFY tests/responses/responses-parser.test.ts and/or existing malformed-content/parser-agent-message file: narrow positive and negative fixtures. If new test file necessary, MODIFY scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. +- MODIFY src/responses/parser.ts at function_call_output classification before tool lookup: route only a complete external task-input envelope to an Ocx user message. Eligibility: type function_call_output, no call_id property (including inherited properties for direct helper calls), nonempty string id/name/namespace, nonempty fully representable text/image output. Do not require specific names, prefixes, namespaces or XML content. Existing standard tool results and custom_tool_call_output keep current path. +- NEW src/responses/task-input.ts: pure recognition returning supported Ocx user content or undefined, no request mutation/network/storage. Reuse existing content converters only when they preserve every accepted output part and reject invalid mixed arrays rather than silently drop them. +- MODIFY tests/responses/responses-parser.test.ts, tests/responses/responses-compaction-routing.test.ts and tests/responses/openai-responses-passthrough.test.ts with narrow positive/negative fixtures. No new test file or layout registry entry is needed. - MODIFY docs-site/src/content/docs/reference/adapters.md and docs-site/src/content/docs/guides/sub-agent-surface.md and structure/04_transports-and-sidecars.md: describe external task input as user-supplied task coordination, not fabricated tool completion. Keep passthrough/compaction raw-body contracts. Before: result-shaped external task input enters toolResult branch with undefined call id, then translated-adapter guard returns 400. After: the complete external shape enters user message with intact supported text/images; malformed/orphan tool results still fail. No secret or raw logged transcript is copied to tests. @@ -19,3 +19,12 @@ No-op leaves current task creation unusable; configuration cannot distinguish th ## Source follow-up folded at roadmap lock Author yrlan-montagnier (Yrlan), GitHub id 71253160: preserve Co-authored-by: Yrlan <71253160+yrlan-montagnier@users.noreply.github.com>. Posted helper may manufacture an encrypted-content-omitted marker that makes encrypted-only input look usable; reject encrypted-only and mixed opaque/unsupported input, never use placeholder text as eligibility. Keep every pre-existing #3471 regression, adding tests rather than replacing them. Add tests/responses/responses-compaction-routing.test.ts and tests/responses/openai-responses-passthrough.test.ts to explicit remote verification. Prefer a dedicated small predicate over relocating passthrough helpers unless byte-for-byte behavior is proved. + +## Task-input cycle P refresh at 25c8d2b4e +The preceding D landed policy #3739 and actual Maintain/Admin settings. Issue #3735 is still open and the author has no open PR; retain the account-linked Yrlan trailer. Source parser at lines 150-160 currently recognizes only message/agent_message as the continuation conversation boundary. Compute the optional external content once near effectiveType and include a recognized envelope in that existing boundary predicate. In the function_call_output branch, clear pendingReasoning, emit a user message and continue; leave the ordinary result branch and core guard unchanged. + +Concrete new leaf: src/responses/task-input.ts exports externalTaskInputContent(item: unknown): string | OcxContentPart[] | undefined. It imports only type OcxContentPart and existing isObj/inputContentParts. Require exact function_call_output, no call_id property, nonblank id/name/namespace, and a nonblank string or fully supported array. Array parts are input_text/text/output_text with string text or input_image with nonblank string image_url and optional auto/low/high/original detail. Normalize output_text to input_text before calling the existing input converter; original image detail maps to high by that converter. Require at least one nonblank text or usable image. Reject any unsupported/opaque/malformed member, invalid detail or file-id-only reference as a whole; placeholder text never establishes eligibility. Preserve accepted text bytes, order and image references; no raw-body mutation or helper relocation from passthrough. + +Field chain: external JSON shape -> pure leaf validation -> parser user message + existing _continuationConversationMessageIndex -> translated adapter's existing user-content serialization. No new persisted field/schema/config. Passthrough and compact use unchanged raw body. Tests include pending reasoning reset and previous_response_id boundary index=0 for a new envelope without a replay prefix, alongside all old #3471 controls. + +Dispatch: main owns new leaf, parser, endpoint/passthrough regressions and English/structure docs; a bounded worker owns only tests/responses/responses-parser.test.ts. Independent A/C reviewer reads named leaf/parser boundaries. No local tests/typecheck/build; remote ci.yml runtime/gates and existing parser/compaction/passthrough suites provide proof. Parser leaves add no core/Lab dependency. No-op/configuration cannot fix this shape; existing input converter is reused behind strict validation. diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 9e8ac4a894..7a044881ab 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -32,6 +32,18 @@ Start with **base**. Choose **v1** when cross-provider delegation must work pred only when you specifically want its newer session model across every catalog entry. ::: +## External task input + +Codex can deliver a task's initial input or follow-up in a result-shaped envelope +without a `call_id`. On translated routes, OpenCodex recognizes only the complete +`function_call_output` shape with nonblank `id`, `name` and `namespace` and supported +text/image output, then treats it as a user turn. This also starts the new conversation +boundary during continuation and clears pending reasoning from the preceding turn. + +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). + ## How it works The selected mode controls the `multi_agent_version` field in every catalog entry Codex reads: diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index a87e046308..c67fff2b99 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -23,6 +23,19 @@ adapter own retries/timeouts, while `runTurn` supports transports that cannot be HTTP fetch followed by one response stream. [`bridge.ts`](/reference/architecture/#the-bridge) then turns the events into Responses SSE. +## External task input on translated Responses routes + +Codex task coordination can deliver input as `function_call_output` with nonblank +`id`, `name` and `namespace` fields and no `call_id` property. OpenCodex maps this +complete envelope to a user message before adapter translation. Its output must be +nonblank text or a fully supported array of text and `input_image` URL parts. Text +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. + ## `openai-chat` **Targets:** OpenAI **Chat Completions** (`POST {baseUrl}/chat/completions`; a trailing `/chat/completions` or `/` on `baseUrl` is stripped first) and every compatible diff --git a/src/responses/parser.ts b/src/responses/parser.ts index c60f441406..a81a693a4a 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -25,6 +25,7 @@ import { toolSearchDescription, toolSearchParameters } from "./tool-search-compa import { isObj, inputContentParts, outputTextOf, outputToToolResultContent, toolOutputContainsEncryptedContent } from "./parser-content"; import { mapToolChoice, buildTools, customToolNamespaces } from "./parser-tools"; import { parseTextFormat } from "./parser-text-format"; +import { externalTaskInputContent } from "./task-input"; /** * Wrap a remembered proxy-side signature as provider metadata for a replayed tool call. @@ -146,6 +147,7 @@ export function parseRequest( const item = data.input[inputIndex]; const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined); const itemRole = (item as { role?: string }).role; + const externalTaskInput = effectiveType === "function_call_output" ? externalTaskInputContent(item) : undefined; // Raw protocol items do not map one-to-one onto context messages. Capture the boundary while // both representations are available so later metadata can stay before conversation in both. if ( @@ -154,6 +156,7 @@ export function parseRequest( && continuationConversationMessageIndex === undefined && ( effectiveType === "agent_message" + || externalTaskInput !== undefined || (effectiveType === "message" && (itemRole === "user" || itemRole === "assistant")) ) ) { @@ -429,6 +432,11 @@ export function parseRequest( } if (effectiveType === "function_call_output") { + if (externalTaskInput !== undefined) { + pendingReasoning.length = 0; + messages.push({ role: "user", content: externalTaskInput, timestamp: now }); + continue; + } const output = item as { call_id: string; output?: string | unknown[] }; attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning); pendingReasoning.length = 0; diff --git a/src/responses/task-input.ts b/src/responses/task-input.ts new file mode 100644 index 0000000000..e72973ab90 --- /dev/null +++ b/src/responses/task-input.ts @@ -0,0 +1,36 @@ +import type { OcxContentPart } from "../types"; +import { inputContentParts, isObj } from "./parser-content"; + +type TaskInputBlock = + | { type: "input_text" | "output_text" | "text"; text: string } + | { type: "input_image"; image_url: string; detail?: string }; + +const imageDetails = new Set(["auto", "low", "high", "original"]); + +function nonBlank(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function supportedBlock(value: unknown): value is TaskInputBlock { + if (!isObj(value)) return false; + if (value.type === "input_text" || value.type === "output_text" || value.type === "text") { + return typeof value.text === "string"; + } + if (value.type !== "input_image" || !nonBlank(value.image_url)) return false; + return value.detail === undefined || (typeof value.detail === "string" && imageDetails.has(value.detail)); +} + +/** Recognize Codex external task input without repairing ordinary orphaned tool results. */ +export function externalTaskInputContent(item: unknown): string | OcxContentPart[] | undefined { + if (!isObj(item) || item.type !== "function_call_output" || "call_id" in item) return undefined; + if (!nonBlank(item.id) || !nonBlank(item.name) || !nonBlank(item.namespace)) return undefined; + const output = item.output; + if (typeof output === "string") return nonBlank(output) ? output : undefined; + if (!Array.isArray(output) || output.length === 0 || !output.every(supportedBlock)) return undefined; + if (!output.some(block => block.type === "input_image" || nonBlank(block.text))) return undefined; + // Validate the entire array first: the general converter intentionally drops unknown + // blocks, while a partial external task would silently lose the caller's input. + return inputContentParts(output.map(block => + block.type === "output_text" ? { ...block, type: "input_text" } : block, + )); +} diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index c26bc56577..192b5cbd4f 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -833,6 +833,16 @@ with seam heartbeats between bounded units. None of these clocks is a total gene ## Reasoning and tool-result compatibility +`src/responses/task-input.ts` recognizes complete external Codex task-input envelopes +before translated Responses adapters: `function_call_output`, no `call_id` property, +nonblank `id`/`name`/`namespace`, and fully representable nonempty text/image output. +`parser.ts` emits a user turn, clears pending reasoning and includes that turn in the +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. + Native OpenAI passthrough sanitizes routed reasoning history so `reasoning` input items do not send non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 9697ba6656..0277da71d4 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -2495,6 +2495,24 @@ describe("OpenAI Responses passthrough sanitization", () => { }]); }); + test("external task parsing preserves the existing raw passthrough repair", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "key" as const, apiKey: "xai-test", + }); + const raw = { + model: "grok-4.6", + input: [{ type: "function_call_output", id: "external-fixture", name: "handoff_input", namespace: "task_inbox", output: "external input" }], + }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + expect(parsed.context.messages).toMatchObject([{ role: "user", content: "external input" }]); + 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" }, + ] }]); + }); + test("api-key mode keeps stateful tool outputs with call_id intact", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index e6f46ab3c7..537dc3f911 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1631,6 +1631,77 @@ describe("computer screenshot output translation boundary", () => { }); }); +describe("external task-input envelopes (#3735)", () => { + const external = (output: unknown = "external task input") => ({ + type: "function_call_output", id: "external-fixture", name: "handoff_input", namespace: "task_inbox", output, + }); + const body = (item: Record) => ({ + model: "gw/model", stream: false, input: [item], + }); + + test("sends a complete envelope as user text without an orphan-tool marker", async () => { + const captured: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ id: "chat_external", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); + }) as typeof fetch; + const res = await handleResponses(compactionRequest(body(external(" preserve this input\n"))), + keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + await res.text(); + expect(captured).toHaveLength(1); + expect(captured[0]!.messages).toEqual([{ role: "user", content: " preserve this input\n" }]); + expect(JSON.stringify(captured)).not.toContain("[tool output for unknown call]"); + }); + + test("preserves ordered text and image content through translation", async () => { + const captured: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ id: "chat_external_image", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); + }) as typeof fetch; + const res = await handleResponses(compactionRequest(body(external([ + { type: "output_text", text: "inspect " }, + { type: "input_image", image_url: "https://example.com/task.png", detail: "original" }, + { type: "input_text", text: " then continue" }, + ]))), keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + await res.text(); + expect(captured).toHaveLength(1); + expect(captured[0]!.messages).toEqual([{ role: "user", content: [ + { type: "text", text: "inspect " }, + { type: "image_url", image_url: { url: "https://example.com/task.png", detail: "high" } }, + { type: "text", text: " then continue" }, + ] }]); + }); + + const invalid: Array<[string, Record]> = [ + ["empty call id", { ...external(), call_id: "" }], + ["null call id", { ...external(), call_id: null }], + ["numeric call id", { ...external(), call_id: 42 }], + ["incomplete metadata", { ...external(), namespace: "" }], + ["custom output", { ...external(), type: "custom_tool_call_output" }], + ["blank output", external(" ")], + ["empty output array", external([])], + ["opaque output", external([{ type: "encrypted_content", encrypted_content: "opaque-fixture" }])], + ["mixed opaque output", external([{ type: "input_text", text: "retained input" }, { type: "encrypted_content", encrypted_content: "opaque-fixture" }])], + ["malformed image", external([{ type: "input_image", image_url: 42 }])], + ]; + for (const [name, item] of invalid) { + test(`rejects ${name} before upstream work`, async () => { + let fetches = 0; + globalThis.fetch = (async () => { fetches++; throw new Error("invalid envelope reached upstream"); }) as typeof fetch; + const res = await handleResponses(compactionRequest(body(item)), + keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(400); + const error = await res.json() as { error?: { message?: string } }; + expect(error.error?.message).toBe("tool result requires a non-empty string call_id"); + expect(fetches).toBe(0); + expect(JSON.stringify(error)).not.toContain("retained input"); + }); + } +}); + describe("unpaired tool result boundary (#3259)", () => { function unpairedBody(item: Record): Record { return { From 79810cf3f4fa7dda9478ef7b5993b32acd7604cb Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 13:06:34 +0900 Subject: [PATCH 2/5] test(responses): cover external task input and retained rejection boundaries Co-authored-by: Yrlan <71253160+yrlan-montagnier@users.noreply.github.com> --- tests/responses/responses-parser.test.ts | 216 +++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 83e5687a0b..0debca2d0a 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { buildResponseJSON } from "../../src/bridge"; import { parseRequest } from "../../src/responses/parser"; +import { externalTaskInputContent } from "../../src/responses/task-input"; import { buildTools } from "../../src/responses/parser-tools"; import { parseTextFormat } from "../../src/responses/parser-text-format"; import { buildToolBridgeMaps } from "../../src/server/responses"; @@ -936,6 +937,221 @@ describe("unpaired tool result boundary (#3259)", () => { }); }); +describe("external task-input envelopes (#3735)", () => { + const parseFrozen = (input: unknown[], extra: Record = {}) => { + const body = Object.freeze({ + model: "test-model", + ...extra, + input: Object.freeze(input.map((item) => Object.freeze(item as object))), + }); + const before = JSON.stringify(body); + const parsed = parseRequest(body); + expect(parsed._rawBody).toBe(body); + expect(JSON.stringify(body)).toBe(before); + return parsed; + }; + + test.each([ + { + name: "arbitrary metadata names preserve output whitespace", + item: { + type: "function_call_output", + id: "rsrc.1", + name: "Launch Task", + namespace: "agent.workspace", + output: " keep ", + }, + content: " keep ", + }, + { + name: "ordered text and original image keep order and map detail to high", + item: { + type: "function_call_output", + id: "img_1", + name: "view", + namespace: "tools", + output: [ + { type: "input_text", text: "caption" }, + { type: "input_image", image_url: "https://example.com/a.png", detail: "original" }, + ], + }, + content: [ + { type: "text", text: "caption" }, + { type: "image", imageUrl: "https://example.com/a.png", detail: "high" }, + ], + }, + { + name: "output_text normalizes through input content parts", + item: { + type: "function_call_output", + id: "txt_1", + name: "note", + namespace: "ns", + output: [{ type: "output_text", text: "from output_text" }], + }, + content: "from output_text", + }, + ])("$name", ({ item, content }) => { + const parsed = parseFrozen([item]); + expect(parsed.context.messages).toMatchObject([{ role: "user", content }]); + expect(parsed.context.messages.some((message) => message.role === "toolResult")).toBe(false); + }); + + test("complete metadata with a valid call_id stays a tool result", () => { + const parsed = parseFrozen([{ + type: "function_call_output", + call_id: "call_keep", + id: "task_1", + name: "Launch Task", + namespace: "agent.workspace", + output: "ok", + }]); + expect(parsed.context.messages).toMatchObject([{ + role: "toolResult", + toolCallId: "call_keep", + content: "ok", + }]); + }); + + test.each([ + { name: "missing id", item: { type: "function_call_output", name: "n", namespace: "ns", output: "ok" } }, + { name: "blank id", item: { type: "function_call_output", id: " ", name: "n", namespace: "ns", output: "ok" } }, + { name: "missing name", item: { type: "function_call_output", id: "i", namespace: "ns", output: "ok" } }, + { name: "blank name", item: { type: "function_call_output", id: "i", name: "", namespace: "ns", output: "ok" } }, + { name: "missing namespace", item: { type: "function_call_output", id: "i", name: "n", output: "ok" } }, + { name: "blank namespace", item: { type: "function_call_output", id: "i", name: "n", namespace: "\t", output: "ok" } }, + { name: "empty call_id", item: { type: "function_call_output", call_id: "", id: "i", name: "n", namespace: "ns", output: "ok" } }, + { name: "null call_id", item: { type: "function_call_output", call_id: null, id: "i", name: "n", namespace: "ns", output: "ok" } }, + { name: "number call_id", item: { type: "function_call_output", call_id: 1, id: "i", name: "n", namespace: "ns", output: "ok" } }, + { name: "custom_tool_call_output", item: { type: "custom_tool_call_output", id: "i", name: "n", namespace: "ns", output: "ok" } }, + { + name: "encrypted-only", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "encrypted_content", encrypted_content: "blob" }], + }, + }, + { + name: "mixed unsupported", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [ + { type: "input_text", text: "visible" }, + { type: "encrypted_content", encrypted_content: "blob" }, + ], + }, + }, + { + name: "malformed text", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "input_text", text: 1 }], + }, + }, + { + name: "malformed image", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "input_image", image_url: 1 }], + }, + }, + { + name: "invalid detail", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "input_image", image_url: "https://example.com/a.png", detail: "ultra" }], + }, + }, + { + name: "file_id-only image", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "input_image", file_id: "file-1" }], + }, + }, + { name: "blank output", item: { type: "function_call_output", id: "i", name: "n", namespace: "ns", output: " " } }, + { name: "empty output", item: { type: "function_call_output", id: "i", name: "n", namespace: "ns", output: "" } }, + { name: "empty array", item: { type: "function_call_output", id: "i", name: "n", namespace: "ns", output: [] } }, + ])("$name stays off the user path", ({ item }) => { + const parsed = parseFrozen([item]); + expect(parsed.context.messages.some((message) => message.role === "user")).toBe(false); + expect(parsed.context.messages.some((message) => message.role === "toolResult")).toBe(true); + }); + + test("own and inherited call_id properties are helper-ineligible", () => { + const base = { + type: "function_call_output", + id: "task_1", + name: "n", + namespace: "ns", + output: "ok", + }; + expect(externalTaskInputContent(base)).toBe("ok"); + expect(externalTaskInputContent({ ...base, call_id: undefined })).toBeUndefined(); + expect(externalTaskInputContent(Object.assign(Object.create({ call_id: "proto" }), base))).toBeUndefined(); + }); + + test("previous_response_id with only a valid envelope starts continuation at 0", () => { + const parsed = parseFrozen([{ + type: "function_call_output", + id: "task_1", + name: "Launch Task", + namespace: "agent.workspace", + output: "next task", + }], { previous_response_id: "resp_1" }); + expect(parsed._continuationConversationMessageIndex).toBe(0); + expect(parsed.context.messages).toMatchObject([{ role: "user", content: "next task" }]); + }); + + test("reasoning before a valid envelope does not leak into a later assistant", () => { + const parsed = parseFrozen([ + { + type: "reasoning", + id: "rs_stale", + summary: [{ type: "summary_text", text: "stale thinking" }], + }, + { + type: "function_call_output", + id: "task_1", + name: "Launch Task", + namespace: "agent.workspace", + output: "next task", + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "done" }], + }, + ]); + expect(parsed.context.messages).toMatchObject([ + { role: "user", content: "next task" }, + { role: "assistant", content: [{ type: "text", text: "done" }] }, + ]); + const assistant = parsed.context.messages.find((message) => message.role === "assistant"); + expect(assistant && "content" in assistant ? assistant.content : []).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: "thinking", thinking: "stale thinking" })]), + ); + }); +}); + test("parser leaf seams preserve tool and format contracts without importing the request parser", () => { const tools = buildTools([{ type: "function", name: "missing_parameters" }]); expect(tools?.[0]?.name).toBe("missing_parameters"); From e274094b23bf7c028f6adf34a459110763067a81 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 13:07:04 +0900 Subject: [PATCH 3/5] docs(responses): record task-input implementation and proof boundary --- .../021_task_input_implementation.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 devlog/_plan/260906_release_244_followups/021_task_input_implementation.md diff --git a/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md b/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md new file mode 100644 index 0000000000..3a2fdb1191 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md @@ -0,0 +1,19 @@ +# External task input implementation + +The pure task-input leaf validates the complete external envelope before using the +existing input-content converter. It accepts text and URL-backed images, rejects +partial or opaque arrays as a whole, and preserves accepted content order. The +parser uses the result for both the continuation boundary and a user turn that +clears pending reasoning. Ordinary tool results, the core call-id guard and raw +passthrough handling remain unchanged. + +Existing unpaired-result regressions remain in place. Added parser cases cover +shape/content controls, original image detail, frozen input, continuation and +reasoning separation; HTTP cases exercise accepted text/images and rejected +envelopes before upstream work. A passthrough case verifies the existing raw +orphan-output behavior alongside the new parsed user representation. + +The implementation preserves Yrlan's contributor attribution from the public +issue and supplied proposal. Protocol/security review and hosted CI are recorded +on the fixing PR and source-bound cycle receipt. No local test suite, typecheck, +build or live Kiro request is part of this validation. From 815f112198c721b728011f4453bfe6fb66a078a8 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 13:17:20 +0900 Subject: [PATCH 4/5] test(responses): distinguish opaque fixtures from normalized plaintext --- .../021_task_input_implementation.md | 6 +++++ .../responses-compaction-routing.test.ts | 27 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md b/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md index 3a2fdb1191..df34b94500 100644 --- a/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md +++ b/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md @@ -17,3 +17,9 @@ The implementation preserves Yrlan's contributor attribution from the public issue and supplied proposal. Protocol/security review and hosted CI are recorded on the fixing PR and source-bound cycle receipt. No local test suite, typecheck, build or live Kiro request is part of this validation. + +The first hosted run exposed two invalid HTTP test stimuli: short text in an +encrypted_content slot follows the existing plaintext normalization path before +the parser. The negative fixtures now use synthetic ciphertext-shaped content +with an explicit classifier check; a separate positive control retains plaintext +slot compatibility. The 400/no-upstream assertions and production logic are unchanged. diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 537dc3f911..51f76ab12c 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -9,6 +9,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { looksLikeBackendCiphertext } from "../../src/server/responses/encrypted-payload"; import * as adapterResolveModule from "../../src/server/adapter-resolve"; import * as visionModule from "../../src/vision"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -1632,6 +1633,9 @@ describe("computer screenshot output translation boundary", () => { }); describe("external task-input envelopes (#3735)", () => { + // Synthetic charset/length fixture: short plaintext in this slot is deliberately + // normalized to input_text before parsing, so it cannot exercise opaque rejection. + const opaqueOutput = `g${"A".repeat(127)}`; const external = (output: unknown = "external task input") => ({ type: "function_call_output", id: "external-fixture", name: "handoff_input", namespace: "task_inbox", output, }); @@ -1639,6 +1643,10 @@ describe("external task-input envelopes (#3735)", () => { model: "gw/model", stream: false, input: [item], }); + test("opaque negative fixtures survive the plaintext-slot classifier", () => { + expect(looksLikeBackendCiphertext(opaqueOutput)).toBe(true); + }); + test("sends a complete envelope as user text without an orphan-tool marker", async () => { const captured: Array> = []; globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { @@ -1675,6 +1683,21 @@ describe("external task-input envelopes (#3735)", () => { ] }]); }); + test("retains existing plaintext-slot normalization before task-input admission", async () => { + const captured: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ id: "chat_plaintext_slot", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); + }) as typeof fetch; + const res = await handleResponses(compactionRequest(body(external([ + { type: "encrypted_content", encrypted_content: "plaintext task" }, + ]))), keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + await res.text(); + expect(captured).toHaveLength(1); + expect(captured[0]!.messages).toEqual([{ role: "user", content: "plaintext task" }]); + }); + const invalid: Array<[string, Record]> = [ ["empty call id", { ...external(), call_id: "" }], ["null call id", { ...external(), call_id: null }], @@ -1683,8 +1706,8 @@ describe("external task-input envelopes (#3735)", () => { ["custom output", { ...external(), type: "custom_tool_call_output" }], ["blank output", external(" ")], ["empty output array", external([])], - ["opaque output", external([{ type: "encrypted_content", encrypted_content: "opaque-fixture" }])], - ["mixed opaque output", external([{ type: "input_text", text: "retained input" }, { type: "encrypted_content", encrypted_content: "opaque-fixture" }])], + ["opaque output", external([{ type: "encrypted_content", encrypted_content: opaqueOutput }])], + ["mixed opaque output", external([{ type: "input_text", text: "retained input" }, { type: "encrypted_content", encrypted_content: opaqueOutput }])], ["malformed image", external([{ type: "input_image", image_url: 42 }])], ]; for (const [name, item] of invalid) { From b7e67d84d9574e64ae5a8229464892eda44a224d Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 14:34:09 +0900 Subject: [PATCH 5/5] fix(responses): align stateful external-task guidance in raw replay --- .../260906_release_244_followups/000_plan.md | 2 +- .../260906_stateful_task_guidance/000_plan.md | 30 ++++++++++ .../010_raw_boundary.md | 38 ++++++++++++ .../011_implementation.md | 16 +++++ .../content/docs/guides/sub-agent-surface.md | 2 + src/server/responses/collaboration.ts | 3 +- structure/04_transports-and-sidecars.md | 2 + .../multi-agent-compat.test.ts | 60 +++++++++++++++++++ 8 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 devlog/_plan/260906_stateful_task_guidance/000_plan.md create mode 100644 devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md create mode 100644 devlog/_plan/260906_stateful_task_guidance/011_implementation.md diff --git a/devlog/_plan/260906_release_244_followups/000_plan.md b/devlog/_plan/260906_release_244_followups/000_plan.md index 92752f01cf..2c71c7cfda 100644 --- a/devlog/_plan/260906_release_244_followups/000_plan.md +++ b/devlog/_plan/260906_release_244_followups/000_plan.md @@ -19,6 +19,7 @@ Baseline dev: af344a28eabcee09a5e04c48ab897449792719c2, version 2.44.0. Latest p | roadmap | this unit | Lock all decade designs; docs only | | policy | 010_policy.md | Establish truthful maintainer integration authority | | task-input | 020_task_input.md | Shared Responses parser contract | +| task-guidance | ../260906_stateful_task_guidance/010_raw_boundary.md | Review follow-up: align stored raw guidance before Kiro resumes | | kiro-results | 030_kiro_results.md | Consume parsed tool-result sequence | | opaque-recovery | 040_opaque_recovery.md | Retry and terminal semantics on composed routing | | combo-recovery | 050_combo_recovery.md | Route recoverable parsed payloads | @@ -32,4 +33,3 @@ One work-phase is one PABCD cycle. Publish short dependency stacks; use merge co ## Evidence boundaries #3735/#3734 are public current-SHA reports; independently inspect code, author local-pass statements remain reports. Kiro proof is recorded-log shape plus synthetic CI tests, never a live quota-consuming request. #3644 has a network A/B report and landed diagnostic #3693; do not claim a Windows runtime reproduction from mocked tests. Detailed private logs are never committed. - diff --git a/devlog/_plan/260906_stateful_task_guidance/000_plan.md b/devlog/_plan/260906_stateful_task_guidance/000_plan.md new file mode 100644 index 0000000000..b7fd48e7b9 --- /dev/null +++ b/devlog/_plan/260906_stateful_task_guidance/000_plan.md @@ -0,0 +1,30 @@ +# Stateful external-task guidance consistency + +Parent PR #3743 recognizes a complete external task-input envelope as a user turn +and starts the parsed continuation boundary there. Its review identified the +remaining raw insertion predicate in collaboration.ts, which still recognizes +only ordinary user/assistant messages and agent_message. In a stateful delta, +generated guidance can therefore precede the task in parsed messages but follow +it in the stored raw input; reparsing changes the delivered order. + +This C4 protocol/replay follow-up is a separate PABCD work-phase before Kiro +implementation resumes. The Kiro phase remains open with no code changes; the +goalplan gained an additional criterion and an explicit focus cursor, without +marking any unfinished task complete or weakening existing criteria. + +Archetype: spec-satisfaction repair. Goal: the same conversational boundary in +parsed and raw stateful representations. Non-goals: new envelope forms, broader +tool-output repair, stateless insertion changes, auth changes or live Kiro. +Verifier: hosted ci.yml runtime/type/privacy gates and focused regression cases +in tests/codex-integration/multi-agent-compat.test.ts. No local test suite, +typecheck or build. Stop only after exact-head CI and independent review pass, +parent review is resolved and its verified head is ready for the Kiro cascade. + +Resources inherit the authorized release loop: existing repository/GitHub access, +requested xai/grok-4.6 reviewers, no new credentials or purchases, no fixed model +cost cap, bounded processes and status waits. Main owns code/FSM/GitHub actions; +reviewers are read-only. Reclaim failed dispatches; no implicit phase movement. +Design and final source/CI evidence reside in this unit and the bound goalplan. + +The complete implementation map is 010_raw_boundary.md. Apply the verified delta +to parent #3743, then refresh the saved Kiro branch from that parent before B. diff --git a/devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md b/devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md new file mode 100644 index 0000000000..b896ea3bb2 --- /dev/null +++ b/devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md @@ -0,0 +1,38 @@ +# Align the stateful raw conversation boundary + +## Exact diff map +- MODIFY src/server/responses/collaboration.ts: import the existing pure + externalTaskInputContent helper. In isConversationalItem, recognize a complete + external task envelope with helper(item) !== undefined, alongside existing + agent_message and user/assistant message handling. Do not duplicate its shape + validator or alter statefulRawInsertionIndex's replay-prefix/fallback logic. +- MODIFY tests/codex-integration/multi-agent-compat.test.ts near injectDeveloperMessage: + stateful external envelope alone and after a leading ordinary call result must + receive guidance before the external task in both parsed context and raw input. + Reparse the stored raw body and compare role/content order. Add an expanded + replay-prefix case so historical external inputs are not selected as the new + boundary. Keep ordinary stateful protocol, compaction and guidance-dedup tests. +- MODIFY docs-site/src/content/docs/guides/sub-agent-surface.md and + structure/04_transports-and-sidecars.md: distinguish unchanged payload content + from intentional generated-guidance placement; both representations use the + same complete-envelope boundary during stateful injection. + +Before: parsed [developer, user] while raw [external-envelope, developer]. +After: parsed [developer, user], raw [developer, external-envelope], and reparsed +role/content order agrees. Leading protocol results remain before guidance; +historical replay-prefix items remain in place. + +## Activation and boundary proof +The new predicate executes only when stateful guidance inspects raw input. Tests +set previous_response_id, invoke the real injector and assert raw/parsed/reparsed +arrays. Ordinary tool outputs with call_id remain protocol items because the +shared helper rejects them. Invalid/partial/opaque envelopes retain their current +classification; the complete validator is already covered by parent regressions. + +No persisted schema, configuration or role changes. Existing input shape -> shared +validation -> raw insertion index -> stored raw input -> later parser is the full +data flow. The helper remains pure and adds no optional subsystem dependency. +Review uses the actual diff; all runtime checks execute in GitHub Actions. + +## A audit amendment +Use the parse-time previous_response_id pattern from multi-agent-compat.test.ts:1075-1089 for envelope-alone and leading-result cases. The raw body must contain that field before parseRequest and retain it during reparse; do not copy the post-hoc parsed.previousResponseId assignment fixture at1029. For historical-prefix coverage use the1043-1072 pattern with explicit _replayPrefixLen and _continuationConversationMessageIndex, and put an old external envelope inside that preserved prefix. Assert parsed boundary before injection as well as raw/parsed/reparsed ordering. This closes the auditor's false-green fixture concern. diff --git a/devlog/_plan/260906_stateful_task_guidance/011_implementation.md b/devlog/_plan/260906_stateful_task_guidance/011_implementation.md new file mode 100644 index 0000000000..1bd823fba8 --- /dev/null +++ b/devlog/_plan/260906_stateful_task_guidance/011_implementation.md @@ -0,0 +1,16 @@ +# Implementation and verification boundary + +The raw conversational-item predicate now reuses externalTaskInputContent, matching +the parsed continuation predicate without another envelope validator. Replay-prefix +skipping and the existing fallback remain unchanged. + +Three new cases parse with previous_response_id already in the raw body, exercise +external input alone or after a real protocol result, preserve a historical external +envelope in the replay prefix, and compare raw/parsed/reparsed role-content order. +They retain the stateful field during reparse and assert the initial parsed boundary, +avoiding a fixture that could accidentally validate stateless behavior. + +Apply this review fix to #3743. Source review and exact-head hosted CI are recorded +on that PR and in the cycle receipt; no local test suite or live Kiro request is run. +After verification, resolve the review and refresh the preserved Kiro branch before +its implementation cycle continues. diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 7a044881ab..81905c4ac1 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -39,6 +39,8 @@ without a `call_id`. On translated routes, OpenCodex recognizes only the complet `function_call_output` shape with nonblank `id`, `name` and `namespace` and supported text/image output, then treats it as a user turn. This also starts the new conversation boundary during continuation and clears pending reasoning from the preceding turn. +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 diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index d7ecde193f..8d8ce42e7f 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -7,6 +7,7 @@ import { resolveEnvValue, } from "../../config"; import { parseRequest } from "../../responses/parser"; +import { externalTaskInputContent } from "../../responses/task-input"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; @@ -558,7 +559,7 @@ function leadingDeveloperPrefixLength(items: readonly unknown[]): number { function isConversationalItem(item: unknown): boolean { if (!isRecord(item)) return false; - if (item.type === "agent_message") return true; + if (item.type === "agent_message" || externalTaskInputContent(item) !== undefined) return true; const type = item.type ?? (typeof item.role === "string" ? "message" : undefined); return type === "message" && (item.role === "user" || item.role === "assistant"); } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 192b5cbd4f..dbc4a4618b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -842,6 +842,8 @@ 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. +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. Native OpenAI passthrough sanitizes routed reasoning history so `reasoning` input items do not send non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs diff --git a/tests/codex-integration/multi-agent-compat.test.ts b/tests/codex-integration/multi-agent-compat.test.ts index ac5956c5c7..9430ab657a 100644 --- a/tests/codex-integration/multi-agent-compat.test.ts +++ b/tests/codex-integration/multi-agent-compat.test.ts @@ -1089,6 +1089,66 @@ describe("injectDeveloperMessage", () => { expect(parsed.context.messages.map(message => message.role)).toEqual(["toolResult", "developer", "user"]); }); + for (const withLeadingResult of [false, true]) { + test(`aligns raw and parsed external-task guidance with leading result=${withLeadingResult}`, () => { + const leading: Record[] = withLeadingResult ? [{ + type: "function_call_output", call_id: "call_1", id: "result_fixture", + name: "exec", namespace: "functions", output: "previous tool output", + }] : []; + const external = { + type: "function_call_output", id: "external_fixture", name: "handoff_input", + namespace: "task_inbox", output: "current task", + }; + const rawInput: Record[] = [...leading, external]; + const raw = { model: "gpt-5.5", previous_response_id: "resp_remote", input: rawInput }; + const parsed = parseRequest(raw); + expect(parsed._continuationConversationMessageIndex).toBe(leading.length); + + injectDeveloperMessage(parsed, guidance); + + expect(raw.previous_response_id).toBe("resp_remote"); + expect(rawInput).toEqual([...leading, generatedItem(), external]); + expect(parsed.context.messages.map(message => message.role)).toEqual([ + ...(withLeadingResult ? ["toolResult"] : []), "developer", "user", + ]); + const reparsed = parseRequest(raw); + expect(reparsed.context.messages.map(({ role, content }) => ({ role, content }))).toEqual( + parsed.context.messages.map(({ role, content }) => ({ role, content })), + ); + }); + } + + test("keeps historical external tasks inside the replay prefix before changed guidance", () => { + const guidanceA = "A"; + const guidanceB = "B"; + const task = (id: string, output: string) => ({ + type: "function_call_output", id, name: "handoff_input", namespace: "task_inbox", output, + }); + const current = task("current_external", "current task"); + const rawInput = [ + generatedItem(guidanceA), task("previous_external", "previous task"), + { type: "message", role: "assistant", content: "done" }, current, + ]; + const history = structuredClone(rawInput.slice(0, 3)); + const raw = { model: "gpt-5.5", previous_response_id: "resp_remote", input: rawInput }; + const parsed = parseRequest(raw); + parsed._replayPrefixLen = 3; + parsed._continuationConversationMessageIndex = 3; + + injectDeveloperMessage(parsed, guidanceB); + + expect(raw.previous_response_id).toBe("resp_remote"); + expect(rawInput.slice(0, 3)).toEqual(history); + expect(rawInput.slice(3)).toEqual([generatedItem(guidanceB), current]); + expect(parsed.context.messages.map(message => message.role)).toEqual([ + "developer", "user", "assistant", "developer", "user", + ]); + const reparsed = parseRequest(raw); + expect(reparsed.context.messages.map(({ role, content }) => ({ role, content }))).toEqual( + parsed.context.messages.map(({ role, content }) => ({ role, content })), + ); + }); + test("keeps raw and parsed stateful placement aligned across reconstructed compaction history", () => { const rawInput = [ { type: "message", role: "user", content: "current turn" },