diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 726c72808c..8b9f05a6ae 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1351,6 +1351,7 @@ "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", + "responses-code-mode-goal-helpers.test.ts": "responses", "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", diff --git a/src/responses/code-mode-helper-compat.ts b/src/responses/code-mode-helper-compat.ts index e103590c4a..4d4721288a 100644 --- a/src/responses/code-mode-helper-compat.ts +++ b/src/responses/code-mode-helper-compat.ts @@ -92,6 +92,9 @@ export function compileCodeModeHelperInput( } return `const result = await tools.view_image(${JSON.stringify(viewArgs)});\nif (result && result.image_url) { image(result.image_url); } else { text(result); }`; } + if (helperName === "create_goal" || helperName === "get_goal" || helperName === "update_goal") { + return `const result = await tools.${helperName}(${JSON.stringify(args)});\ntext(result);`; + } return `const result = await tools.exec_command(${JSON.stringify(args)});\ntext(result);`; } diff --git a/src/types/tools.ts b/src/types/tools.ts index c0c7b54a63..91d348786c 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -77,6 +77,9 @@ const CODE_MODE_HELPER_TOOL_NAMES = [ "write_stdin", "apply_patch", "view_image", + "create_goal", + "get_goal", + "update_goal", ] as const; /** diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 2b7db0e29a..2c751f58aa 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -210,6 +210,12 @@ The passthrough guard resolves an emitted name through that same `normalizeDecla whatever it admits it must also EMIT under the resolved name. The two halves disagreed once: `normalizeDefaultNamespaceInItem` implemented only the bare-tool case (#4176), so a `default.`-prefixed code-mode helper was admitted as `exec` (#4412) and then relayed verbatim. +The bounded helper vocabulary includes the goal lifecycle calls that Codex advertises inside its +unified `exec` description (`create_goal`, `get_goal`, and `update_goal`). Routed providers that +echo one of those nested names, with or without an invented `default.` prefix, are restored to the +declared `exec` and compiled back to the matching `tools.(...)` call. A genuinely declared +bare goal tool keeps its bare identity, and a catalog declaring neither that tool nor `exec` still +fails closed. `default.view_image` is not a legal Responses tool name, and Codex stores what it receives, so the one relayed item was refused by `^[a-zA-Z0-9_-]+$` on every later replay of that conversation and the task could not be compacted or continued (#5095). The rewrite now falls back to the resolver diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a62ab80eb1..471c139b19 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1183,6 +1183,7 @@ "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", + "responses-code-mode-goal-helpers.test.ts": "responses", "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", diff --git a/tests/responses/responses-code-mode-goal-helpers.test.ts b/tests/responses/responses-code-mode-goal-helpers.test.ts new file mode 100644 index 0000000000..a7b38ec226 --- /dev/null +++ b/tests/responses/responses-code-mode-goal-helpers.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { restoreRoutedCustomCallsInJson } from "../../src/responses/custom-tool-compat"; +import { compileCodeModeHelperInput } from "../../src/responses/code-mode-helper-compat"; +import { undeclaredToolCallNameInResponse } from "../../src/server/responses-undeclared-tool-guard"; +import { normalizeDeclaredToolName } from "../../src/types/tools"; + +const CODE_MODE = new Set(["exec"]); + +describe("code-mode goal helper recovery", () => { + test("maps bare and default-prefixed goal helpers only through a declared exec", () => { + for (const name of ["create_goal", "get_goal", "update_goal"]) { + expect(normalizeDeclaredToolName(name, CODE_MODE)).toBe("exec"); + expect(normalizeDeclaredToolName(`default.${name}`, CODE_MODE)).toBe("exec"); + expect(normalizeDeclaredToolName(`default.${name}`, new Set([name]))).toBe(name); + expect(normalizeDeclaredToolName(`default.${name}`, new Set())).toBe(`default.${name}`); + } + }); + + test("compiles every helper to its matching nested host call", () => { + const cases = [ + ["create_goal", { objective: "ship the fix" }], + ["get_goal", {}], + ["update_goal", { status: "complete" }], + ] as const; + for (const [name, args] of cases) { + expect(compileCodeModeHelperInput(JSON.stringify(args), name)).toBe( + `const result = await tools.${name}(${JSON.stringify(args)});\ntext(result);`, + ); + } + }); + + test("restores default.update_goal as the declared exec and keeps the guard fail-closed", () => { + const source = { + output: [{ + type: "function_call", + id: "fc_goal", + call_id: "call_goal", + name: "default.update_goal", + arguments: JSON.stringify({ status: "complete" }), + }], + }; + const restored = JSON.parse(restoreRoutedCustomCallsInJson( + JSON.stringify(source), + CODE_MODE, + new Set(), + CODE_MODE, + )); + expect(restored.output).toMatchObject([{ + type: "custom_tool_call", + name: "exec", + call_id: "call_goal", + input: 'const result = await tools.update_goal({"status":"complete"});\ntext(result);', + }]); + expect(undeclaredToolCallNameInResponse(restored, CODE_MODE)).toBeUndefined(); + expect(undeclaredToolCallNameInResponse(source, new Set())).toBe("default.update_goal"); + }); +});