From 0742cf3493e328f1ded63c1fa7ffa7a38d055459 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 5 Apr 2026 13:23:30 -0500 Subject: [PATCH] fix(gsd): coerce string arrays to objects in complete-slice/task tools (#3565) LLMs sometimes pass plain strings instead of the expected object shape for array fields like filesModified and requires, causing TypeBox validation to reject the input before the execute function runs. This adds Type.Union schemas to accept both formats and normalizes strings to objects with sensible defaults in the execute functions. Closes #3565 --- .../extensions/gsd/bootstrap/db-tools.ts | 99 +++++++++++++------ .../gsd/tests/complete-slice.test.ts | 42 ++++++++ 2 files changed, 113 insertions(+), 28 deletions(-) diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 3f6f9d998..ac67ba546 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -704,8 +704,14 @@ export function registerDbTools(pi: ExtensionAPI): void { }; } try { + // Coerce string items to objects for verificationEvidence (#3541). + const coerced = { ...params }; + coerced.verificationEvidence = (params.verificationEvidence ?? []).map((v: any) => + typeof v === "string" ? { command: v, exitCode: 0, verdict: "pass", durationMs: 0 } : v, + ); + const { handleCompleteTask } = await import("../tools/complete-task.js"); - const result = await handleCompleteTask(params, process.cwd()); + const result = await handleCompleteTask(coerced, process.cwd()); if ("error" in result) { return { content: [{ type: "text" as const, text: `Error completing task: ${result.error}` }], @@ -761,12 +767,15 @@ export function registerDbTools(pi: ExtensionAPI): void { keyDecisions: Type.Optional(Type.Array(Type.String(), { description: "List of key decisions made during this task" })), blockerDiscovered: Type.Optional(Type.Boolean({ description: "Whether a plan-invalidating blocker was discovered" })), verificationEvidence: Type.Optional(Type.Array( - Type.Object({ - command: Type.String({ description: "Verification command that was run" }), - exitCode: Type.Number({ description: "Exit code of the command" }), - verdict: Type.String({ description: "Pass/fail verdict (e.g. '✅ pass', '❌ fail')" }), - durationMs: Type.Number({ description: "Duration of the command in milliseconds" }), - }), + Type.Union([ + Type.Object({ + command: Type.String({ description: "Verification command that was run" }), + exitCode: Type.Number({ description: "Exit code of the command" }), + verdict: Type.String({ description: "Pass/fail verdict (e.g. '✅ pass', '❌ fail')" }), + durationMs: Type.Number({ description: "Duration of the command in milliseconds" }), + }), + Type.String({ description: "Fallback: verification summary string" }), + ]), { description: "Array of verification evidence entries" }, )), }), @@ -787,8 +796,27 @@ export function registerDbTools(pi: ExtensionAPI): void { }; } try { + // Coerce string items to objects for fields where LLMs sometimes pass + // plain strings instead of the expected { key, value } shape (#3541). + const coerced = { ...params }; + coerced.filesModified = (params.filesModified ?? []).map((f: any) => + typeof f === "string" ? { path: f, description: "" } : f, + ); + coerced.requires = (params.requires ?? []).map((r: any) => + typeof r === "string" ? { slice: r, provides: "" } : r, + ); + coerced.requirementsAdvanced = (params.requirementsAdvanced ?? []).map((r: any) => + typeof r === "string" ? { id: r, how: "" } : r, + ); + coerced.requirementsValidated = (params.requirementsValidated ?? []).map((r: any) => + typeof r === "string" ? { id: r, proof: "" } : r, + ); + coerced.requirementsInvalidated = (params.requirementsInvalidated ?? []).map((r: any) => + typeof r === "string" ? { id: r, what: "" } : r, + ); + const { handleCompleteSlice } = await import("../tools/complete-slice.js"); - const result = await handleCompleteSlice(params, process.cwd()); + const result = await handleCompleteSlice(coerced, process.cwd()); if ("error" in result) { return { content: [{ type: "text" as const, text: `Error completing slice: ${result.error}` }], @@ -850,38 +878,53 @@ export function registerDbTools(pi: ExtensionAPI): void { drillDownPaths: Type.Optional(Type.Array(Type.String(), { description: "Paths to task summaries for drill-down" })), affects: Type.Optional(Type.Array(Type.String(), { description: "Downstream slices affected" })), requirementsAdvanced: Type.Optional(Type.Array( - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - how: Type.String({ description: "How it was advanced" }), - }), + Type.Union([ + Type.Object({ + id: Type.String({ description: "Requirement ID" }), + how: Type.String({ description: "How it was advanced" }), + }), + Type.String({ description: "Fallback: 'ID — how' string" }), + ]), { description: "Requirements advanced by this slice" }, )), requirementsValidated: Type.Optional(Type.Array( - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - proof: Type.String({ description: "What proof validates it" }), - }), + Type.Union([ + Type.Object({ + id: Type.String({ description: "Requirement ID" }), + proof: Type.String({ description: "What proof validates it" }), + }), + Type.String({ description: "Fallback: 'ID — proof' string" }), + ]), { description: "Requirements validated by this slice" }, )), requirementsInvalidated: Type.Optional(Type.Array( - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - what: Type.String({ description: "What changed" }), - }), + Type.Union([ + Type.Object({ + id: Type.String({ description: "Requirement ID" }), + what: Type.String({ description: "What changed" }), + }), + Type.String({ description: "Fallback: 'ID — what' string" }), + ]), { description: "Requirements invalidated or re-scoped" }, )), filesModified: Type.Optional(Type.Array( - Type.Object({ - path: Type.String({ description: "File path" }), - description: Type.String({ description: "What changed" }), - }), + Type.Union([ + Type.Object({ + path: Type.String({ description: "File path" }), + description: Type.String({ description: "What changed" }), + }), + Type.String({ description: "Fallback: file path string" }), + ]), { description: "Files modified with descriptions" }, )), requires: Type.Optional(Type.Array( - Type.Object({ - slice: Type.String({ description: "Dependency slice ID" }), - provides: Type.String({ description: "What was consumed from it" }), - }), + Type.Union([ + Type.Object({ + slice: Type.String({ description: "Dependency slice ID" }), + provides: Type.String({ description: "What was consumed from it" }), + }), + Type.String({ description: "Fallback: slice ID string" }), + ]), { description: "Upstream slice dependencies consumed" }, )), }), diff --git a/src/resources/extensions/gsd/tests/complete-slice.test.ts b/src/resources/extensions/gsd/tests/complete-slice.test.ts index ed5073ff8..72273fdc9 100644 --- a/src/resources/extensions/gsd/tests/complete-slice.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice.test.ts @@ -406,6 +406,48 @@ console.log('\n=== complete-slice: handler with missing roadmap ==='); cleanup(dbPath); } +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: Handler accepts string coercion for object arrays (#3541) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== complete-slice: handler accepts string-coerced arrays (#3541) ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); + + const { basePath } = createTempProject(); + + // Set up DB state + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); + + // Simulate LLM passing strings instead of objects — coerced before handler + const params = makeValidSliceParams(); + const coerced = { ...params }; + coerced.filesModified = ['src/foo.ts', 'src/bar.ts'].map((f: string) => + ({ path: f, description: '' }), + ); + coerced.requires = ['S00'].map((r: string) => + ({ slice: r, provides: '' }), + ); + coerced.requirementsAdvanced = ['R001'].map((r: string) => + ({ id: r, how: '' }), + ); + + const result = await handleCompleteSlice(coerced, basePath); + assertTrue(!('error' in result), 'handler should succeed with coerced string arrays'); + if (!('error' in result)) { + // Verify SUMMARY.md renders without crashing on coerced fields + const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8'); + assertMatch(summaryContent, /src\/foo\.ts/, 'summary should list coerced file path'); + assertMatch(summaryContent, /R001/, 'summary should list coerced requirement'); + } + + cleanupDir(basePath); + cleanup(dbPath); +} + // ═══════════════════════════════════════════════════════════════════════════ // complete-slice: step 13 specifies write tool for PROJECT.md (#2946) // ═══════════════════════════════════════════════════════════════════════════