From 9616b02c584a8191aee44da899a74f1ba01fed07 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 5 Apr 2026 22:10:42 -0500 Subject: [PATCH] fix(gsd): coerce plain-string provides field to array in complete-slice (#3585) LLMs sometimes pass simple string-array fields (provides, keyFiles, etc.) as a plain string instead of a single-element array, causing TypeBox schema validation to reject the call before the execute function's coercion logic can run. Fix by accepting Union([Array, String]) in the schema and adding wrapArray() coercion for all 8 simple array fields in the execute function. --- .../extensions/gsd/bootstrap/db-tools.ts | 38 ++++++++++++------- .../complete-slice-string-coercion.test.ts | 36 ++++++++++++++++++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 2926c1b29..ed92c4eb6 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -804,27 +804,39 @@ export function registerDbTools(pi: ExtensionAPI): void { return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""]; }; const coerced = { ...params }; - coerced.filesModified = (params.filesModified ?? []).map((f: any) => { + // Coerce simple string-array fields: LLMs sometimes pass a plain string + // instead of a single-element array (#3585). + const wrapArray = (v: any): any[] => + v == null ? [] : Array.isArray(v) ? v : [v]; + coerced.provides = wrapArray(params.provides); + coerced.keyFiles = wrapArray(params.keyFiles); + coerced.keyDecisions = wrapArray(params.keyDecisions); + coerced.patternsEstablished = wrapArray(params.patternsEstablished); + coerced.observabilitySurfaces = wrapArray(params.observabilitySurfaces); + coerced.requirementsSurfaced = wrapArray(params.requirementsSurfaced); + coerced.drillDownPaths = wrapArray(params.drillDownPaths); + coerced.affects = wrapArray(params.affects); + coerced.filesModified = wrapArray(params.filesModified).map((f: any) => { if (typeof f !== "string") return f; const [path, description] = splitPair(f); return { path, description }; }); - coerced.requires = (params.requires ?? []).map((r: any) => { + coerced.requires = wrapArray(params.requires).map((r: any) => { if (typeof r !== "string") return r; const [slice, provides] = splitPair(r); return { slice, provides }; }); - coerced.requirementsAdvanced = (params.requirementsAdvanced ?? []).map((r: any) => { + coerced.requirementsAdvanced = wrapArray(params.requirementsAdvanced).map((r: any) => { if (typeof r !== "string") return r; const [id, how] = splitPair(r); return { id, how }; }); - coerced.requirementsValidated = (params.requirementsValidated ?? []).map((r: any) => { + coerced.requirementsValidated = wrapArray(params.requirementsValidated).map((r: any) => { if (typeof r !== "string") return r; const [id, proof] = splitPair(r); return { id, proof }; }); - coerced.requirementsInvalidated = (params.requirementsInvalidated ?? []).map((r: any) => { + coerced.requirementsInvalidated = wrapArray(params.requirementsInvalidated).map((r: any) => { if (typeof r !== "string") return r; const [id, what] = splitPair(r); return { id, what }; @@ -884,14 +896,14 @@ export function registerDbTools(pi: ExtensionAPI): void { deviations: Type.Optional(Type.String({ description: "Deviations from the slice plan, or 'None.'" })), knownLimitations: Type.Optional(Type.String({ description: "Known limitations or gaps, or 'None.'" })), followUps: Type.Optional(Type.String({ description: "Follow-up work discovered during execution, or 'None.'" })), - keyFiles: Type.Optional(Type.Array(Type.String(), { description: "Key files created or modified" })), - keyDecisions: Type.Optional(Type.Array(Type.String(), { description: "Key decisions made during this slice" })), - patternsEstablished: Type.Optional(Type.Array(Type.String(), { description: "Patterns established by this slice" })), - observabilitySurfaces: Type.Optional(Type.Array(Type.String(), { description: "Observability surfaces added" })), - provides: Type.Optional(Type.Array(Type.String(), { description: "What this slice provides to downstream slices" })), - requirementsSurfaced: Type.Optional(Type.Array(Type.String(), { description: "New requirements surfaced" })), - 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" })), + keyFiles: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Key files created or modified" })), + keyDecisions: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Key decisions made during this slice" })), + patternsEstablished: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Patterns established by this slice" })), + observabilitySurfaces: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Observability surfaces added" })), + provides: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "What this slice provides to downstream slices" })), + requirementsSurfaced: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "New requirements surfaced" })), + drillDownPaths: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Paths to task summaries for drill-down" })), + affects: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Downstream slices affected" })), requirementsAdvanced: Type.Optional(Type.Array( Type.Union([ Type.Object({ diff --git a/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts b/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts index 229860ba9..5dbdae3e8 100644 --- a/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts @@ -124,6 +124,42 @@ describe("verificationEvidence sentinel coercion (#3565)", () => { }); }); +// ─── wrapArray coercion unit tests (#3585) ────────────────────────────── + +describe("wrapArray coercion for simple string-array fields (#3585)", () => { + /** + * The wrapArray coercion logic extracted from db-tools.ts sliceCompleteExecute. + * Duplicated here so we can unit-test it directly. + */ + function wrapArray(v: any): any[] { + return v == null ? [] : Array.isArray(v) ? v : [v]; + } + + test("null returns empty array", () => { + assert.deepEqual(wrapArray(null), []); + }); + + test("undefined returns empty array", () => { + assert.deepEqual(wrapArray(undefined), []); + }); + + test("plain string wraps into single-element array", () => { + assert.deepEqual( + wrapArray("Validated Tech UI flows and Portal self-service flows"), + ["Validated Tech UI flows and Portal self-service flows"], + ); + }); + + test("array passes through unchanged", () => { + const arr = ["item1", "item2"]; + assert.deepEqual(wrapArray(arr), arr); + }); + + test("empty array passes through unchanged", () => { + assert.deepEqual(wrapArray([]), []); + }); +}); + // ─── Handler integration with coerced params ───────────────────────────── describe("handleCompleteSlice with coerced string arrays (#3565)", () => {