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
This commit is contained in:
Jeremy 2026-04-05 13:23:30 -05:00
parent a6b7febc5e
commit 0742cf3493
2 changed files with 113 additions and 28 deletions

View file

@ -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" },
)),
}),

View file

@ -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)
// ═══════════════════════════════════════════════════════════════════════════