From e0cdd4cbdea378c02d6711a797309e8f950576b0 Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Sat, 15 Aug 2026 14:57:39 -0700 Subject: [PATCH 1/3] fix(coding-agent): cover OpenAI plan schema --- .../test/update-plan-tool.test.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/update-plan-tool.test.ts b/packages/coding-agent/test/update-plan-tool.test.ts index 2576dc6be..62289a629 100644 --- a/packages/coding-agent/test/update-plan-tool.test.ts +++ b/packages/coding-agent/test/update-plan-tool.test.ts @@ -165,7 +165,7 @@ describe("update_plan tool", () => { ).rejects.toThrow("Plan item 1 status must be pending, in_progress, or completed."); }); - test("publishes a DeepSeek-compatible object schema", () => { + test("publishes a provider-compatible object schema", () => { const definition = createUpdatePlanToolDefinition(); const parameters = definition.parameters as { type?: string; @@ -241,6 +241,45 @@ describe("update_plan tool", () => { expect(result.stopReason).toBe("stop"); }); + test("sends the object schema through an OpenAI Responses interaction", async () => { + const model = getModel("openai", "gpt-5-mini"); + expect(model).toBeDefined(); + if (!model) throw new Error("Expected the OpenAI model fixture"); + let payload: unknown; + + const result = await streamSimple( + model, + { + messages: [{ role: "user", content: "Plan this task", timestamp: Date.now() }], + tools: [createUpdatePlanTool()], + }, + { + apiKey: "test-key", + fetch: async () => + new Response( + `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + })}\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + onPayload: (requestPayload) => { + payload = requestPayload; + }, + }, + ).result(); + + const sentPayload = payload as { + tools?: Array<{ name?: string; parameters?: { type?: string } }>; + }; + const updatePlan = sentPayload.tools?.find((tool) => tool.name === "update_plan"); + expect(updatePlan?.parameters?.type).toBe("object"); + expect(result.stopReason).toBe("stop"); + }); + test("marks both tool representations as sequential", () => { expect(createUpdatePlanToolDefinition().executionMode).toBe("sequential"); expect(createUpdatePlanTool().executionMode).toBe("sequential"); From a444f00f352c81cbf3253d39d5f9993e9a1c0ed1 Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Sat, 15 Aug 2026 15:26:32 -0700 Subject: [PATCH 2/3] fix(coding-agent): flatten update plan schema --- .../src/core/tools/update-plan.ts | 35 ++++++------ .../test/update-plan-tool.test.ts | 54 +++++++++---------- 2 files changed, 41 insertions(+), 48 deletions(-) diff --git a/packages/coding-agent/src/core/tools/update-plan.ts b/packages/coding-agent/src/core/tools/update-plan.ts index ba7161253..d23d6be15 100644 --- a/packages/coding-agent/src/core/tools/update-plan.ts +++ b/packages/coding-agent/src/core/tools/update-plan.ts @@ -41,28 +41,23 @@ const updatePlanItemSchema = Type.Object( { additionalProperties: false }, ); -const updatePlanSchema = Type.Unsafe({ - type: "object", - anyOf: [ - Type.Object( - { - plan: Type.Array(updatePlanItemSchema, { - minItems: 1, - description: "Full ordered plan snapshot.", - }), - explanation: Type.Optional(Type.String({ description: "Reason for a material plan change." })), - }, - { additionalProperties: false }, +const updatePlanSchema = Type.Object( + { + plan: Type.Optional( + Type.Array(updatePlanItemSchema, { + minItems: 1, + description: "Full ordered plan snapshot. Provide exactly one operation: plan or clear.", + }), ), - Type.Object( - { - clear: Type.Literal(true, { description: "Explicitly abandon and clear the current plan." }), - explanation: Type.Optional(Type.String({ description: "Reason for clearing the plan." })), - }, - { additionalProperties: false }, + clear: Type.Optional( + Type.Literal(true, { + description: "Explicitly abandon and clear the current plan. Provide exactly one operation: plan or clear.", + }), ), - ], -}); + explanation: Type.Optional(Type.String({ description: "Reason for changing or clearing the plan." })), + }, + { additionalProperties: false }, +); export const updatePlanToolSystemPromptContribution = { snippet: "track progress with an ordered task checklist", diff --git a/packages/coding-agent/test/update-plan-tool.test.ts b/packages/coding-agent/test/update-plan-tool.test.ts index 62289a629..b70e12f84 100644 --- a/packages/coding-agent/test/update-plan-tool.test.ts +++ b/packages/coding-agent/test/update-plan-tool.test.ts @@ -169,43 +169,38 @@ describe("update_plan tool", () => { const definition = createUpdatePlanToolDefinition(); const parameters = definition.parameters as { type?: string; - anyOf?: Array<{ - additionalProperties?: boolean; - properties?: { - clear?: { const?: boolean }; - explanation?: { type?: string }; - plan?: { - minItems?: number; - items?: { - additionalProperties?: boolean; - properties?: { - status?: { enum?: string[] }; - }; + additionalProperties?: boolean; + properties?: { + clear?: { const?: boolean }; + explanation?: { type?: string }; + plan?: { + minItems?: number; + items?: { + additionalProperties?: boolean; + properties?: { + status?: { enum?: string[] }; }; }; }; - required?: string[]; - }>; + }; + required?: string[]; }; - const update = parameters.anyOf?.[0]; - const clear = parameters.anyOf?.[1]; expect(parameters.type).toBe("object"); - expect(parameters.anyOf).toHaveLength(2); - expect(update?.required).toEqual(["plan"]); - expect(update?.additionalProperties).toBe(false); - expect(update?.properties?.plan?.minItems).toBe(1); - expect(update?.properties?.plan?.items?.additionalProperties).toBe(false); - expect(update?.properties?.plan?.items?.properties?.status?.enum).toEqual([ + expect(parameters.additionalProperties).toBe(false); + expect(parameters.required).toBeUndefined(); + for (const keyword of ["oneOf", "anyOf", "allOf", "enum", "const", "not"]) { + expect(parameters).not.toHaveProperty(keyword); + } + expect(parameters.properties?.plan?.minItems).toBe(1); + expect(parameters.properties?.plan?.items?.additionalProperties).toBe(false); + expect(parameters.properties?.plan?.items?.properties?.status?.enum).toEqual([ "pending", "in_progress", "completed", ]); - expect(update?.properties?.explanation?.type).toBe("string"); - expect(clear?.required).toEqual(["clear"]); - expect(clear?.additionalProperties).toBe(false); - expect(clear?.properties?.clear?.const).toBe(true); - expect(clear?.properties?.explanation?.type).toBe("string"); + expect(parameters.properties?.clear?.const).toBe(true); + expect(parameters.properties?.explanation?.type).toBe("string"); }); test("sends the object schema through a DeepSeek interaction", async () => { @@ -273,10 +268,13 @@ describe("update_plan tool", () => { ).result(); const sentPayload = payload as { - tools?: Array<{ name?: string; parameters?: { type?: string } }>; + tools?: Array<{ name?: string; parameters?: Record & { type?: string } }>; }; const updatePlan = sentPayload.tools?.find((tool) => tool.name === "update_plan"); expect(updatePlan?.parameters?.type).toBe("object"); + for (const keyword of ["oneOf", "anyOf", "allOf", "enum", "const", "not"]) { + expect(updatePlan?.parameters).not.toHaveProperty(keyword); + } expect(result.stopReason).toBe("stop"); }); From e26fd314588075df3bb98b8ea5d2e2b24e4bcf43 Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Sat, 15 Aug 2026 15:39:48 -0700 Subject: [PATCH 3/3] fix(coding-agent): flatten agent action schema --- .../coding-agent/src/core/tools/subagents.ts | 48 +++++++++---------- .../test/suite/subagent-actions.test.ts | 21 ++++++-- .../test/tool-schema-compat.test.ts | 31 ++++++++++++ 3 files changed, 73 insertions(+), 27 deletions(-) create mode 100644 packages/coding-agent/test/tool-schema-compat.test.ts diff --git a/packages/coding-agent/src/core/tools/subagents.ts b/packages/coding-agent/src/core/tools/subagents.ts index 822f1b993..8c13fdd5a 100644 --- a/packages/coding-agent/src/core/tools/subagents.ts +++ b/packages/coding-agent/src/core/tools/subagents.ts @@ -21,26 +21,17 @@ const targetSchema = Type.String({ pattern: SUBAGENT_NAME_PATTERN.source, }); -const agentActionSchema = Type.Union( - [ - Type.Object( - { - target: targetSchema, - action: Type.Literal("message"), - message: Type.String({ description: "Message for the child agent", minLength: 1 }), - wake: Type.Optional(Type.Boolean({ default: false })), - }, - { additionalProperties: false }, - ), - Type.Object( - { - target: targetSchema, - action: Type.Literal("interrupt"), - }, - { additionalProperties: false }, - ), - ], - { type: "object" }, +const agentActionSchema = Type.Object( + { + target: targetSchema, + action: Type.Unsafe({ + type: "string", + enum: ["message", "interrupt"], + }), + message: Type.Optional(Type.String({ description: "Message for the child agent", minLength: 1 })), + wake: Type.Optional(Type.Boolean({ default: false })), + }, + { additionalProperties: false }, ); const agentsSchema = Type.Object( @@ -161,16 +152,25 @@ export function createAgentActionTool( if (!SUBAGENT_NAME_PATTERN.test(request.target)) { throw new Error(`Invalid subagent name: ${request.target}`); } + let normalized: AgentActionRequest; if (request.action === "interrupt") { if ("message" in request || "wake" in request) { throw new Error("Interrupt actions do not accept message fields"); } - } else if (request.message.trim().length === 0) { - throw new Error("Subagent message must be non-empty"); + normalized = { target: request.target, action: request.action }; + } else { + const message = request.message; + if (message === undefined || message.trim().length === 0) { + throw new Error("Subagent message must be non-empty"); + } + normalized = { + target: request.target, + action: request.action, + message, + wake: request.wake ?? false, + }; } - const normalized: AgentActionRequest = - request.action === "message" ? { ...request, wake: request.wake ?? false } : request; const details = await action(normalized); return { content: [{ type: "text", text: `Completed ${details.action} action for ${details.target}` }], diff --git a/packages/coding-agent/test/suite/subagent-actions.test.ts b/packages/coding-agent/test/suite/subagent-actions.test.ts index 6c06704dc..58922c09f 100644 --- a/packages/coding-agent/test/suite/subagent-actions.test.ts +++ b/packages/coding-agent/test/suite/subagent-actions.test.ts @@ -73,13 +73,25 @@ describe("subagent actions", () => { const tool = createAgentActionTool(action); expect(tool.executionMode).toBe("parallel"); - expect(tool.parameters).toMatchObject({ type: "object", anyOf: expect.any(Array) }); + expect(tool.parameters).toMatchObject({ + type: "object", + additionalProperties: false, + properties: { + target: { type: "string" }, + action: { type: "string", enum: ["message", "interrupt"] }, + message: { type: "string" }, + wake: { type: "boolean" }, + }, + }); + for (const keyword of ["oneOf", "anyOf", "allOf", "enum", "const", "not"]) { + expect(tool.parameters).not.toHaveProperty(keyword); + } expect(Check(tool.parameters, { target: "worker", action: "message", message: "hello" })).toBe(true); expect(Check(tool.parameters, { target: "worker", action: "message", message: "hello", wake: true })).toBe(true); expect(Check(tool.parameters, { target: "worker", action: "interrupt" })).toBe(true); expect(Check(tool.parameters, { target: "Invalid", action: "interrupt" })).toBe(false); - expect(Check(tool.parameters, { target: "worker", action: "interrupt", wake: true })).toBe(false); - expect(Check(tool.parameters, { target: "worker", action: "interrupt", message: "no" })).toBe(false); + expect(Check(tool.parameters, { target: "worker", action: "interrupt", wake: true })).toBe(true); + expect(Check(tool.parameters, { target: "worker", action: "interrupt", message: "no" })).toBe(true); const result = await executeAction(tool, { target: "worker", action: "message", message: privateMessage }); expect(action).toHaveBeenCalledWith({ @@ -106,6 +118,9 @@ describe("subagent actions", () => { await expect(executeAction(tool, { target: "worker", action: "message", message: " " })).rejects.toThrow( "Subagent message must be non-empty", ); + await expect(executeAction(tool, { target: "worker", action: "message" } as AgentActionRequest)).rejects.toThrow( + "Subagent message must be non-empty", + ); await expect( executeAction(tool, { target: "worker", action: "interrupt", wake: true } as AgentActionRequest), ).rejects.toThrow("Interrupt actions do not accept message fields"); diff --git a/packages/coding-agent/test/tool-schema-compat.test.ts b/packages/coding-agent/test/tool-schema-compat.test.ts new file mode 100644 index 000000000..26da3395a --- /dev/null +++ b/packages/coding-agent/test/tool-schema-compat.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { createAllToolDefinitions } from "../src/core/tools/index.ts"; +import { createAgentActionTool, createAgentsTool, createSpawnAgentTool } from "../src/core/tools/subagents.ts"; + +const PROHIBITED_ROOT_KEYWORDS = ["oneOf", "anyOf", "allOf", "enum", "const", "not"] as const; + +describe("built-in tool schema compatibility", () => { + test("publishes object-rooted schemas without top-level composition keywords", () => { + const definitions = [ + ...Object.values(createAllToolDefinitions("/workspace")), + createSpawnAgentTool(() => {}), + createAgentActionTool(async () => { + throw new Error("Not executed by schema test"); + }), + createAgentsTool(async () => ({ timedOut: false, agents: [] })), + ]; + + for (const definition of definitions) { + const parameters = definition.parameters as Record; + expect({ name: definition.name, type: parameters.type }).toEqual({ + name: definition.name, + type: "object", + }); + for (const keyword of PROHIBITED_ROOT_KEYWORDS) { + expect(parameters, `${definition.name} must not use ${keyword} at the schema root`).not.toHaveProperty( + keyword, + ); + } + } + }); +});