diff --git a/improvements.md b/improvements.md index ef4ca0e..161b1c9 100644 --- a/improvements.md +++ b/improvements.md @@ -1680,9 +1680,11 @@ open: state entry before the apply pass, and the reference then hits the resolver's silent drop. - Names push never resolves still go unchecked: `hooks[].do[].toolId` and - `artifactPlan.structuredOutputIds` inside overrides, `model.toolRefs` at any - depth, and `toolIds` in a squad's inline `members[].assistant`. Only - `toolIds` inside overrides are checked. + `artifactPlan.structuredOutputIds` inside overrides (except a squad's + `membersOverrides.artifactPlan`, which push resolves and validate checks + like any other reference, #8), `model.toolRefs` at any depth, and `toolIds` + in a squad's inline `members[].assistant`. Only `toolIds` inside overrides + are checked. - Annotations carry a file but no line, so GitHub shows them at the top of the file, and it keeps at most 10 per type per step. diff --git a/src/canonical.ts b/src/canonical.ts index 5c49b62..aad0844 100644 --- a/src/canonical.ts +++ b/src/canonical.ts @@ -13,6 +13,7 @@ // ───────────────────────────────────────────────────────────────────────────── import { replaceCredentialRefs } from "./credentials.ts"; +import { membersOverridesArtifactPlan } from "./resolver.ts"; import type { ResourceType, StateFile } from "./types.ts"; export interface VapiResource { @@ -108,6 +109,23 @@ export function resolveReferencesToResourceIds( resolved.artifactPlan = artifactPlan; } + // Resolve structuredOutputIds in a squad's membersOverrides.artifactPlan + const membersArtifactPlan = membersOverridesArtifactPlan(resolved); + if ( + membersArtifactPlan && + Array.isArray(membersArtifactPlan.structuredOutputIds) + ) { + resolved.membersOverrides = { + ...(resolved.membersOverrides as Record), + artifactPlan: { + ...membersArtifactPlan, + structuredOutputIds: membersArtifactPlan.structuredOutputIds.map( + (uuid: string) => structuredOutputsMap.get(uuid) ?? uuid, + ), + }, + }; + } + // Resolve assistantIds in structured outputs (API returns camelCase) if (Array.isArray(resolved.assistantIds)) { resolved.assistant_ids = (resolved.assistantIds as string[]).map( diff --git a/src/push.ts b/src/push.ts index c22f41d..32db2af 100644 --- a/src/push.ts +++ b/src/push.ts @@ -2058,6 +2058,7 @@ async function main(): Promise { if (squads.length > 0) { console.log("\n👥 Applying squads...\n"); // Auto-resolve missing assistant dependencies (recursively resolves tools/SOs) + // and structured outputs named in membersOverrides.artifactPlan for (const squad of squads) { const refs = extractReferencedIds( squad.data as Record, @@ -2065,6 +2066,9 @@ async function main(): Promise { for (const assistantId of refs.assistants) { await ensureAssistantExists(assistantId, depCtx); } + for (const outputId of refs.structuredOutputs) { + await ensureStructuredOutputExists(outputId, depCtx); + } } for (const squad of squads) { try { diff --git a/src/resolver.ts b/src/resolver.ts index e3ef263..9e3d3c8 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -175,6 +175,18 @@ export function resolveSimulationIds( .filter((id): id is string => id !== null); } +// A squad's `membersOverrides.artifactPlan`, or undefined when absent. It +// names structured outputs the same way an assistant's `artifactPlan` does. +export function membersOverridesArtifactPlan( + data: Record, +): Record | undefined { + const overrides = data.membersOverrides; + if (!overrides || typeof overrides !== "object") return undefined; + const artifactPlan = (overrides as Record).artifactPlan; + if (!artifactPlan || typeof artifactPlan !== "object") return undefined; + return artifactPlan as Record; +} + export function resolveReferences( data: Record, state: StateFile, @@ -209,6 +221,18 @@ export function resolveReferences( ); } + // Resolve structuredOutputIds in a squad's membersOverrides.artifactPlan + const membersArtifactPlan = membersOverridesArtifactPlan(resolved); + if ( + membersArtifactPlan && + Array.isArray(membersArtifactPlan.structuredOutputIds) + ) { + membersArtifactPlan.structuredOutputIds = resolveStructuredOutputIds( + membersArtifactPlan.structuredOutputIds as string[], + state, + ); + } + // Resolve assistant_ids in structured outputs if (Array.isArray(resolved.assistant_ids)) { resolved.assistantIds = resolveAssistantIds( @@ -386,6 +410,17 @@ export function extractReferencedIds( } } + // Check membersOverrides.artifactPlan.structuredOutputIds in squads + const membersArtifactPlan = membersOverridesArtifactPlan(data); + if ( + membersArtifactPlan && + Array.isArray(membersArtifactPlan.structuredOutputIds) + ) { + structuredOutputs.push( + ...(membersArtifactPlan.structuredOutputIds as string[]).map(cleanId), + ); + } + // Check assistant_ids in structured outputs if (Array.isArray(data.assistant_ids)) { assistants.push(...(data.assistant_ids as string[]).map(cleanId)); diff --git a/src/validate-refs.ts b/src/validate-refs.ts index 38a41b4..b2b0b93 100644 --- a/src/validate-refs.ts +++ b/src/validate-refs.ts @@ -22,8 +22,9 @@ import type { ValidationFinding } from "./validate.ts"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -// Push never resolves references inside these objects, so a name there -// reaches the API as-is. +// Push never resolves tool references inside these objects, so a tool name +// there reaches the API as-is. (A squad's membersOverrides.artifactPlan +// structuredOutputIds is the one override field push does resolve.) const OVERRIDE_KEYS = new Set([ "assistantOverrides", "membersOverrides", diff --git a/tests/members-overrides-refs.test.ts b/tests/members-overrides-refs.test.ts new file mode 100644 index 0000000..55ba4e2 --- /dev/null +++ b/tests/members-overrides-refs.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveReferencesToResourceIds } from "../src/canonical.ts"; +import { extractReferencedIds, resolveReferences } from "../src/resolver.ts"; +import type { StateFile } from "../src/types.ts"; + +// A squad's membersOverrides.artifactPlan.structuredOutputIds names structured +// outputs the same way an assistant's artifactPlan does (issue #8): push sends +// UUIDs, pull writes the names back, and the names count as references. + +const SUMMARY_UUID = "3f2b1c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d"; +const DASHBOARD_UUID = "a0000000-0000-4000-8000-000000000001"; + +function state(): StateFile { + return { + credentials: {}, + assistants: {}, + structuredOutputs: { "call-summary": { uuid: SUMMARY_UUID } }, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + }; +} + +test("push resolves structured output names in membersOverrides.artifactPlan", () => { + const squad = { + name: "Team", + members: [{ assistantId: "intake" }], + membersOverrides: { + artifactPlan: { + structuredOutputIds: ["call-summary ## note", DASHBOARD_UUID], + recordingEnabled: true, + }, + }, + }; + const payload = resolveReferences(squad, state()); + assert.deepEqual(payload.membersOverrides, { + artifactPlan: { + structuredOutputIds: [SUMMARY_UUID, DASHBOARD_UUID], + recordingEnabled: true, + }, + }); + // The local data is not mutated. + assert.deepEqual(squad.membersOverrides.artifactPlan.structuredOutputIds, [ + "call-summary ## note", + DASHBOARD_UUID, + ]); +}); + +test("pull writes membersOverrides.artifactPlan structured outputs back as names", () => { + const platform = { + name: "Team", + members: [{ assistantId: DASHBOARD_UUID }], + membersOverrides: { + artifactPlan: { structuredOutputIds: [SUMMARY_UUID, DASHBOARD_UUID] }, + }, + }; + const local = resolveReferencesToResourceIds(platform, state()); + assert.deepEqual(local.membersOverrides, { + artifactPlan: { structuredOutputIds: ["call-summary", DASHBOARD_UUID] }, + }); + // Round trip: pushing what pull wrote sends the platform's UUIDs again. + const pushed = resolveReferences(local, state()); + assert.deepEqual(pushed.membersOverrides, platform.membersOverrides); +}); + +test("membersOverrides.artifactPlan structured outputs count as references", () => { + const refs = extractReferencedIds({ + members: [{ assistantId: "intake" }], + membersOverrides: { + artifactPlan: { structuredOutputIds: ["call-summary ## note"] }, + }, + }); + assert.deepEqual(refs.structuredOutputs, ["call-summary"]); + assert.deepEqual(refs.assistants, ["intake"]); +}); + +test("squads without membersOverrides.artifactPlan are unchanged", () => { + const squad = { + name: "Team", + members: [{ assistantId: "intake" }], + membersOverrides: { voice: { provider: "vapi", voiceId: "Elliot" } }, + }; + assert.deepEqual(resolveReferences(squad, state()), squad); + assert.deepEqual(resolveReferencesToResourceIds(squad, state()), squad); + assert.deepEqual(extractReferencedIds(squad).structuredOutputs, []); +});