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:
parent
a6b7febc5e
commit
0742cf3493
2 changed files with 113 additions and 28 deletions
|
|
@ -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" },
|
||||
)),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue