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.
This commit is contained in:
Jeremy 2026-04-05 22:10:42 -05:00
parent c9d358b8fe
commit 9616b02c58
2 changed files with 61 additions and 13 deletions

View file

@ -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({

View file

@ -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)", () => {