From 985e4a248738ec6c2e76683a4306cfe6fa3352cc Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Wed, 6 May 2026 19:07:00 -0700 Subject: [PATCH 1/2] fix: dedup dependency auto-apply to prevent duplicate tool mints (Gap #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted assistant pushes minted duplicate dashboard tools when bootstrap pull stored an existing dashboard tool under a name-slugged state key (e.g. `end-call-67aea057`) instead of the user's original local key. The exact-key lookup in `ensureToolExists` / `ensureStructuredOutputExists` missed and POSTed a fresh duplicate. Each subsequent targeted push repeated the cycle, accumulating dashboard orphans. This adds a name-based dedup check between the exact-key short-circuit and the create path, in two layers: 1. State-side: scan state for any key whose `extractBaseSlug` matches the local payload's slugified name (handles bootstrap-renamed keys). 2. Dashboard-side: lazy-fetch the live `/tool` (and `/structured-output`) list once per push and check for a remote resource with the same canonical name. When >1 distinct UUID matches the same name (real on-dashboard duplicates from prior bug runs), pick the lex-smallest UUID for stable adoption, warn naming the loser UUIDs, and point at `npm run cleanup`. Never mint another duplicate. Adoption flow: - Re-key state to the adopted UUID under the local resourceId. - Drop other state keys pointing at the same UUID and mark them touched, so a subsequent full push doesn't orphan-delete the adopted dashboard resource (Stack J / mergeScoped flushes the deletion). - Route through `applyTool`/`applyStructuredOutput` so the local payload PATCHes the dashboard with the standard drift-check flow, instead of recording a fake `lastPushedHash` that would silently drop a locally-edited dependency. Tests: 12 unit tests for `findExistingResourceByName` covering state-only, dashboard-only, both-agree, ambiguous (state-vs-state, state-vs-dashboard), no-name, exact-key-excluded, no-match. All 114 suites pass. Refs: improvements.md §10 --- src/dep-dedup.ts | 137 +++++++++++++++++++++++++++ src/push.ts | 200 ++++++++++++++++++++++++++++++++++++++++ tests/dep-dedup.test.ts | 179 +++++++++++++++++++++++++++++++++++ 3 files changed, 516 insertions(+) create mode 100644 src/dep-dedup.ts create mode 100644 tests/dep-dedup.test.ts diff --git a/src/dep-dedup.ts b/src/dep-dedup.ts new file mode 100644 index 0000000..32d0ea2 --- /dev/null +++ b/src/dep-dedup.ts @@ -0,0 +1,137 @@ +// Gap #10 — dependency deduplication helpers. +// +// On a targeted assistant push, `ensureToolExists` / `ensureStructuredOutputExists` +// previously skipped auto-create only when (a) the dep id was UUID-shaped, +// (b) `state.tools[depId]` was an exact key match, or (c) we'd already +// auto-applied this id in the current run. Bootstrap pull (pull.ts:269-273) +// stores resources under `-` keys (e.g. `end-call-67aea057`), +// so a local file referencing `b2b-invoice-end-call` would miss the exact-key +// check and POST a duplicate dashboard tool. Repeated targeted pushes +// accumulated orphans on the dashboard. +// +// This module's helpers detect those collisions BEFORE create: +// - `findExistingResourceByName` — match local payload's canonical name +// against existing state entries (renamed/state-only keys) and the live +// dashboard list. Returns the UUID to adopt, plus an `ambiguous` flag +// when multiple distinct UUIDs share the same name (real on-dashboard +// duplicates from prior bug runs — the caller should warn and surface +// the loser UUIDs so a follow-up `npm run cleanup` can prune them). +// +// NOTE on duplication: `slugify` and `extractBaseSlug` here mirror the +// definitions in `src/pull.ts` (lines ~253-278). pull.ts imports config.ts, +// which calls `parseEnvironment()` at module load and `process.exit(1)`s +// without a CLI env arg — making it impossible to import in a unit test. +// This module imports ONLY from `./types.ts` so it stays testable in +// isolation. Five lines duplicated is the right tradeoff; do not "DRY" +// these back into pull.ts. + +import type { ResourceState } from "./types.ts"; + +export interface RemoteResource { + id: string; + name?: string; + function?: { name?: string }; +} + +export interface DedupMatch { + uuid: string; + source: "state" | "dashboard" | "both"; + ambiguous: boolean; + // Other distinct UUIDs we saw under the same canonical name. Empty when + // `ambiguous` is false. Caller should surface these in a warning so the + // user can run `npm run cleanup` to prune the duplicates. + duplicateUuids: string[]; +} + +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-+/g, "-"); +} + +export function extractBaseSlug(resourceId: string): string { + const match = resourceId.match(/^(.*)-([a-f0-9]{8})$/i); + return match?.[1] ?? resourceId; +} + +// Pulls the canonical name from a tool / SO / assistant payload. +// For tools: `function.name` is the canonical name (per pull.ts:261-267). +// For SOs / assistants / etc.: top-level `name`. Top-level wins when both +// are present. +export function extractResourceName( + payload: Record, +): string | undefined { + if (typeof payload.name === "string" && payload.name) return payload.name; + const fn = payload.function as Record | undefined; + if (fn && typeof fn.name === "string" && fn.name) return fn.name; + return undefined; +} + +// Find an existing resource (in state or dashboard) whose canonical name +// matches the local payload's canonical name. Used by ensureToolExists / +// ensureStructuredOutputExists to avoid creating a duplicate when bootstrap +// pull has already stored the same dashboard resource under a different +// state key. +// +// `localResourceId` is the key the engine WANTS to use in state. If a state +// entry already exists under that exact key, the caller short-circuits BEFORE +// calling this — so we exclude it here as a safety belt. +// +// Tiebreaker for >1 distinct UUID matching the same slugified name (i.e. +// real on-dashboard duplicates from prior bug runs): pick lexically smallest +// UUID for stable, deterministic adoption. Set `ambiguous=true` and surface +// the loser UUIDs in `duplicateUuids` so the caller can warn. +export function findExistingResourceByName(args: { + localResourceId: string; + localPayload: Record; + stateSection: Record; + remoteList?: RemoteResource[]; +}): DedupMatch | undefined { + const localName = extractResourceName(args.localPayload); + if (!localName) return undefined; + const localSlug = slugify(localName); + + // uuid -> set of source labels (state:, dashboard:) + const matches = new Map>(); + + for (const [stateKey, entry] of Object.entries(args.stateSection)) { + if (stateKey === args.localResourceId) continue; + if (extractBaseSlug(stateKey) === localSlug) { + const set = matches.get(entry.uuid) ?? new Set(); + set.add(`state:${stateKey}`); + matches.set(entry.uuid, set); + } + } + + for (const remote of args.remoteList ?? []) { + const remoteName = + (typeof remote.name === "string" && remote.name) || remote.function?.name; + if (!remoteName) continue; + if (slugify(remoteName) === localSlug) { + const set = matches.get(remote.id) ?? new Set(); + set.add(`dashboard:${remote.id}`); + matches.set(remote.id, set); + } + } + + if (matches.size === 0) return undefined; + + const sorted = [...matches.keys()].sort(); + const winner = sorted[0]!; + const winnerSources = matches.get(winner)!; + const hasState = [...winnerSources].some((s) => s.startsWith("state:")); + const hasDashboard = [...winnerSources].some((s) => + s.startsWith("dashboard:"), + ); + const source: DedupMatch["source"] = + hasState && hasDashboard ? "both" : hasState ? "state" : "dashboard"; + + return { + uuid: winner, + source, + ambiguous: matches.size > 1, + duplicateUuids: sorted.slice(1), + }; +} diff --git a/src/push.ts b/src/push.ts index d98ba26..f9eb8d2 100644 --- a/src/push.ts +++ b/src/push.ts @@ -16,6 +16,10 @@ import { summarizeFindings, validateResources } from "./validate.ts"; import { checkDriftForUpdate } from "./drift.ts"; import { writeSnapshot } from "./snapshot.ts"; import { mergeScoped } from "./state-merge.ts"; +import { + findExistingResourceByName, + type RemoteResource, +} from "./dep-dedup.ts"; // Map a resource label to its state-file key. Used for snapshotting (Stack H) // — snapshot directories are keyed by the same names the state file uses. @@ -827,6 +831,66 @@ interface DependencyContext { autoApplied: Set; autoAppliedTools: ResourceFile>[]; autoAppliedStructuredOutputs: ResourceFile>[]; + // Stack — Gap #10 dedup. Lazy-fetched at most once per push when an + // auto-apply path needs to verify the tool/SO doesn't already exist on + // the dashboard under a different state key. `undefined` = not yet + // fetched; an empty array means "we tried, dashboard returned 0". + existingRemoteTools?: RemoteResource[]; + existingRemoteStructuredOutputs?: RemoteResource[]; + // Touched sets — adoption needs to mark touched so scoped state-merge + // (#15 / Stack J) persists the adopted UUID under the local resourceId. + touched: TouchedSets; +} + +// Gap #10 — lazy-fetch the live dashboard tool list once per push. Used by +// `ensureToolExists` to detect bootstrap-renamed entries (e.g. local key +// `b2b-invoice-end-call` vs state key `end-call-67aea057`) and live +// dashboard duplicates from prior bug runs before minting another POST. +async function getExistingRemoteTools( + ctx: DependencyContext, +): Promise { + if (ctx.existingRemoteTools !== undefined) return ctx.existingRemoteTools; + if (DRY_RUN) { + // Honor dry-run: no API calls. Empty list = "no dashboard match + // possible" → falls through to existing create-skip log. + ctx.existingRemoteTools = []; + return ctx.existingRemoteTools; + } + try { + const remote = await fetchAllResources("tools"); + ctx.existingRemoteTools = remote as unknown as RemoteResource[]; + } catch (err) { + console.warn( + ` ⚠️ Could not fetch dashboard tools for dedup check: ${ + err instanceof Error ? err.message : String(err) + }. Falling back to state-only dedup.`, + ); + ctx.existingRemoteTools = []; + } + return ctx.existingRemoteTools; +} + +async function getExistingRemoteStructuredOutputs( + ctx: DependencyContext, +): Promise { + if (ctx.existingRemoteStructuredOutputs !== undefined) + return ctx.existingRemoteStructuredOutputs; + if (DRY_RUN) { + ctx.existingRemoteStructuredOutputs = []; + return ctx.existingRemoteStructuredOutputs; + } + try { + const remote = await fetchAllResources("structuredOutputs"); + ctx.existingRemoteStructuredOutputs = remote as unknown as RemoteResource[]; + } catch (err) { + console.warn( + ` ⚠️ Could not fetch dashboard structured outputs for dedup check: ${ + err instanceof Error ? err.message : String(err) + }. Falling back to state-only dedup.`, + ); + ctx.existingRemoteStructuredOutputs = []; + } + return ctx.existingRemoteStructuredOutputs; } async function ensureToolExists( @@ -843,6 +907,73 @@ async function ensureToolExists( const tool = ctx.allTools.find((t) => t.resourceId === toolId); if (!tool) return; + // Gap #10 dedup — before creating, check if a state entry under a + // different key (bootstrap-generated like `end-call-`) or a + // live dashboard tool already represents this same logical tool. + // Adopt instead of mint a duplicate. + const remoteList = await getExistingRemoteTools(ctx); + const match = findExistingResourceByName({ + localResourceId: toolId, + localPayload: tool.data as Record, + stateSection: ctx.state.tools, + remoteList, + }); + if (match) { + if (match.ambiguous) { + const payload = tool.data as Record; + const fn = payload.function as Record | undefined; + const displayName = + (typeof fn?.name === "string" && fn.name) || + (typeof payload.name === "string" && payload.name) || + toolId; + console.warn( + ` ⚠️ Multiple dashboard tools share the name "${displayName}" — adopting ${match.uuid} (lex-smallest). Other UUIDs: ${match.duplicateUuids.join(", ")}. Run \`npm run cleanup -- ${VAPI_ENV}\` to prune duplicates.`, + ); + } + console.log( + ` 🔁 Reusing existing tool: ${toolId} → ${match.uuid} (matched via ${match.source})`, + ); + + // Re-key state to point at the adopted UUID under the local resourceId. + // No hash yet — applyTool below will PATCH with the local payload and + // record the post-PATCH hash, exercising the standard drift-check flow. + upsertState(ctx.state.tools, tool.resourceId, { uuid: match.uuid }); + + // Orphan-deletion guard — drop other state keys pointing at the SAME uuid + // so a subsequent full push doesn't see them as "tracked but no local file" + // and DELETE the dashboard resource we just adopted. Mark them touched + // so mergeScoped (Stack J) flushes the deletion. Do NOT touch entries + // pointing at match.duplicateUuids — those are SEPARATE dashboard duplicates + // and `npm run cleanup` is the right tool for those. + for (const [staleKey, entry] of Object.entries(ctx.state.tools)) { + if (staleKey !== tool.resourceId && entry.uuid === match.uuid) { + delete ctx.state.tools[staleKey]; + ctx.touched.tools.add(staleKey); + } + } + + // Now PATCH the dashboard with the local payload. applyTool's + // upsertResourceWithStateRecovery branch picks PATCH because state.tools + // now has existingUuid set. Drift check fires (no-baseline → log + proceed + // when lastPulledHash is undefined; full check when it isn't). + try { + const uuid = await applyTool(tool, ctx.state); + ctx.autoApplied.add(`tools:${toolId}`); + if (!uuid) return; + upsertState(ctx.state.tools, tool.resourceId, { + uuid, + lastPushedHash: hashPayload(tool.data), + }); + ctx.applied.tools++; + ctx.autoAppliedTools.push(tool); + ctx.touched.tools.add(tool.resourceId); + } catch (error) { + console.error(formatApiError(toolId, error)); + throw error; + } + return; + } + console.log(` 📦 Auto-applying dependency → tool: ${toolId}`); try { const uuid = await applyTool(tool, ctx.state); @@ -854,6 +985,7 @@ async function ensureToolExists( }); ctx.applied.tools++; ctx.autoAppliedTools.push(tool); + ctx.touched.tools.add(tool.resourceId); } catch (error) { console.error(formatApiError(toolId, error)); throw error; @@ -876,6 +1008,72 @@ async function ensureStructuredOutputExists( ); if (!output) return; + // Gap #10 dedup — same as ensureToolExists but against the SO state + // section + dashboard SO list. + const remoteList = await getExistingRemoteStructuredOutputs(ctx); + const match = findExistingResourceByName({ + localResourceId: outputId, + localPayload: output.data as Record, + stateSection: ctx.state.structuredOutputs, + remoteList, + }); + if (match) { + if (match.ambiguous) { + const payload = output.data as Record; + const displayName = + (typeof payload.name === "string" && payload.name) || outputId; + console.warn( + ` ⚠️ Multiple dashboard structured outputs share the name "${displayName}" — adopting ${match.uuid} (lex-smallest). Other UUIDs: ${match.duplicateUuids.join(", ")}. Run \`npm run cleanup -- ${VAPI_ENV}\` to prune duplicates.`, + ); + } + console.log( + ` 🔁 Reusing existing structured output: ${outputId} → ${match.uuid} (matched via ${match.source})`, + ); + + // Re-key state to point at the adopted UUID under the local resourceId. + // No hash yet — applyStructuredOutput below will PATCH with the local + // payload and record the post-PATCH hash, exercising the standard + // drift-check flow. + upsertState(ctx.state.structuredOutputs, output.resourceId, { + uuid: match.uuid, + }); + + // Orphan-deletion guard — drop other state keys pointing at the SAME uuid + // so a subsequent full push doesn't see them as "tracked but no local file" + // and DELETE the dashboard resource we just adopted. Mark them touched + // so mergeScoped (Stack J) flushes the deletion. Do NOT touch entries + // pointing at match.duplicateUuids — those are SEPARATE dashboard duplicates + // and `npm run cleanup` is the right tool for those. + for (const [staleKey, entry] of Object.entries(ctx.state.structuredOutputs)) { + if (staleKey !== output.resourceId && entry.uuid === match.uuid) { + delete ctx.state.structuredOutputs[staleKey]; + ctx.touched.structuredOutputs.add(staleKey); + } + } + + // Now PATCH the dashboard with the local payload. applyStructuredOutput's + // upsertResourceWithStateRecovery branch picks PATCH because + // state.structuredOutputs now has existingUuid set. Drift check fires + // (no-baseline → log + proceed when lastPulledHash is undefined; full + // check when it isn't). + try { + const uuid = await applyStructuredOutput(output, ctx.state); + ctx.autoApplied.add(`structuredOutputs:${outputId}`); + if (!uuid) return; + upsertState(ctx.state.structuredOutputs, output.resourceId, { + uuid, + lastPushedHash: hashPayload(output.data), + }); + ctx.applied.structuredOutputs++; + ctx.autoAppliedStructuredOutputs.push(output); + ctx.touched.structuredOutputs.add(output.resourceId); + } catch (error) { + console.error(formatApiError(outputId, error)); + throw error; + } + return; + } + console.log(` 📦 Auto-applying dependency → structured output: ${outputId}`); try { const uuid = await applyStructuredOutput(output, ctx.state); @@ -887,6 +1085,7 @@ async function ensureStructuredOutputExists( }); ctx.applied.structuredOutputs++; ctx.autoAppliedStructuredOutputs.push(output); + ctx.touched.structuredOutputs.add(output.resourceId); } catch (error) { console.error(formatApiError(outputId, error)); throw error; @@ -1178,6 +1377,7 @@ async function main(): Promise { autoApplied, autoAppliedTools, autoAppliedStructuredOutputs, + touched, }; // Determine which types to check for orphaned deletions diff --git a/tests/dep-dedup.test.ts b/tests/dep-dedup.test.ts new file mode 100644 index 0000000..9f2858e --- /dev/null +++ b/tests/dep-dedup.test.ts @@ -0,0 +1,179 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + findExistingResourceByName, + slugify, + extractBaseSlug, + extractResourceName, +} from "../src/dep-dedup.ts"; + +// Gap #10 — dedup helper coverage. Verifies that bootstrap-renamed state +// entries and live dashboard duplicates are detected before the auto-apply +// path POSTs a fresh duplicate tool / SO. + +test("slugify lowercases and dashes", () => { + assert.equal(slugify("End Call Tool"), "end-call-tool"); + assert.equal(slugify("HandoffToSales"), "handofftosales"); +}); + +test("extractBaseSlug strips uuid8 suffix", () => { + assert.equal(extractBaseSlug("end-call-67aea057"), "end-call"); + assert.equal(extractBaseSlug("end-call"), "end-call"); // no suffix + assert.equal(extractBaseSlug("foo-bar-12345678"), "foo-bar"); +}); + +test("extractResourceName: tool uses function.name", () => { + assert.equal( + extractResourceName({ function: { name: "endCall" } }), + "endCall", + ); +}); + +test("extractResourceName: SO uses top-level name", () => { + assert.equal( + extractResourceName({ name: "post-call-summary" }), + "post-call-summary", + ); +}); + +test("extractResourceName: top-level name wins over function.name", () => { + assert.equal( + extractResourceName({ name: "outer", function: { name: "inner" } }), + "outer", + ); +}); + +test("findExistingResourceByName: state-only match", () => { + const m = findExistingResourceByName({ + localResourceId: "b2b-invoice-end-call", + localPayload: { function: { name: "end-call" } }, + stateSection: { + "end-call-67aea057": { uuid: "uuid-aaa" }, + }, + }); + assert.deepEqual(m, { + uuid: "uuid-aaa", + source: "state", + ambiguous: false, + duplicateUuids: [], + }); +}); + +test("findExistingResourceByName: dashboard-only match", () => { + const m = findExistingResourceByName({ + localResourceId: "end-call", + localPayload: { function: { name: "end-call" } }, + stateSection: {}, + remoteList: [{ id: "uuid-bbb", function: { name: "end-call" } }], + }); + assert.deepEqual(m, { + uuid: "uuid-bbb", + source: "dashboard", + ambiguous: false, + duplicateUuids: [], + }); +}); + +test("findExistingResourceByName: state and dashboard both match same uuid", () => { + const m = findExistingResourceByName({ + localResourceId: "end-call", + localPayload: { function: { name: "end-call" } }, + stateSection: { "end-call-67aea057": { uuid: "uuid-aaa" } }, + remoteList: [{ id: "uuid-aaa", function: { name: "end-call" } }], + }); + assert.equal(m?.source, "both"); + assert.equal(m?.ambiguous, false); +}); + +test("findExistingResourceByName: ambiguous → lex-smallest UUID + duplicates surfaced", () => { + const m = findExistingResourceByName({ + localResourceId: "end-call", + localPayload: { function: { name: "end-call" } }, + stateSection: { + "end-call-67aea057": { uuid: "uuid-zzz" }, + "end-call-16ff08ed": { uuid: "uuid-aaa" }, + }, + }); + assert.equal(m?.uuid, "uuid-aaa"); + assert.equal(m?.ambiguous, true); + assert.deepEqual(m?.duplicateUuids, ["uuid-zzz"]); +}); + +test("findExistingResourceByName: no name on payload → undefined", () => { + const m = findExistingResourceByName({ + localResourceId: "x", + localPayload: {}, + stateSection: { "end-call-67aea057": { uuid: "uuid-aaa" } }, + }); + assert.equal(m, undefined); +}); + +test("findExistingResourceByName: localResourceId exact match excluded (caller's job)", () => { + const m = findExistingResourceByName({ + localResourceId: "end-call-67aea057", + localPayload: { function: { name: "end-call" } }, + stateSection: { "end-call-67aea057": { uuid: "uuid-aaa" } }, + }); + // Helper excludes the exact-key, leaving no match. Caller should have + // short-circuited on the exact-key check before calling. + assert.equal(m, undefined); +}); + +test("findExistingResourceByName: no match → undefined", () => { + const m = findExistingResourceByName({ + localResourceId: "transfer-to-sales", + localPayload: { function: { name: "transfer-to-sales" } }, + stateSection: { "end-call-67aea057": { uuid: "uuid-aaa" } }, + remoteList: [{ id: "uuid-bbb", function: { name: "voicemail" } }], + }); + assert.equal(m, undefined); +}); + +test("findExistingResourceByName: ambiguous across state vs dashboard → lex-smallest, both surfaced", () => { + // State has one UUID under a bootstrap-renamed key, dashboard has a + // distinct UUID under the same canonical name (real on-dashboard + // duplicate from a prior bug run). Both must surface in duplicateUuids + // and the winner must be lex-smallest. Source must be "both" only when + // the SAME uuid appears in both — here the winner appears in only one, + // so source is whichever side it came from. + const m = findExistingResourceByName({ + localResourceId: "b2b-invoice-end-call", + localPayload: { function: { name: "end-call" } }, + stateSection: { "end-call-67aea057": { uuid: "uuid-zzz" } }, + remoteList: [{ id: "uuid-aaa", function: { name: "end-call" } }], + }); + assert.equal(m?.uuid, "uuid-aaa"); + assert.equal(m?.ambiguous, true); + assert.equal(m?.source, "dashboard"); + assert.deepEqual(m?.duplicateUuids, ["uuid-zzz"]); +}); + +test("findExistingResourceByName: empty stateSection AND empty remoteList → undefined", () => { + // Defensive: a fresh push with no prior state and no dashboard population + // must short-circuit to undefined so the auto-create path runs. + const m = findExistingResourceByName({ + localResourceId: "anything", + localPayload: { function: { name: "anything" } }, + stateSection: {}, + remoteList: [], + }); + assert.equal(m, undefined); +}); + +test("findExistingResourceByName: remote payload uses top-level `name` (not function.name)", () => { + // Pull-side / dashboard list payloads for non-tool resources (and some + // tool list endpoints) expose `name` directly instead of nested in + // `function.name`. The dedup helper must recognize both shapes. + const m = findExistingResourceByName({ + localResourceId: "post-call-summary", + localPayload: { name: "post-call-summary" }, + stateSection: {}, + remoteList: [{ id: "uuid-ccc", name: "post-call-summary" }], + }); + assert.deepEqual(m, { + uuid: "uuid-ccc", + source: "dashboard", + ambiguous: false, + duplicateUuids: [], + }); +}); From a9394f1c7a8d9262d179ae22243291cf137b4e93 Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Wed, 6 May 2026 19:08:12 -0700 Subject: [PATCH 2/2] =?UTF-8?q?docs(improvements):=20mark=20=C2=A710=20as?= =?UTF-8?q?=20resolved=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- improvements.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/improvements.md b/improvements.md index c9f078e..6ff0dfd 100644 --- a/improvements.md +++ b/improvements.md @@ -61,7 +61,7 @@ you which stack PR closes the row.** | 7 | Voice edits drop pronunciation-dictionary attachments | Silent regression on Cartesia + 11labs voice edits | #4 | RESOLVED 2026-04-30 (Stack G) | | 8 | Dashboard prompt edits can in-place duplicate the prompt | Two stacked prompt versions = stitched output | None | Partial — Stack D heuristic | | 9 | Provider-specific voice schema mismatch (push 400) | `voice.speed` vs `voice.generationConfig.speed` | None | RESOLVED 2026-04-30 (Stack D + A) | -| 10 | Targeted assistant push mints duplicate tools | Re-pushing assistant duplicates `end-call-*` tools | #4 | Partial | +| 10 | Targeted assistant push mints duplicate tools | Re-pushing assistant duplicates `end-call-*` tools | #4 | RESOLVED 2026-05-06 (#23) | | 11 | Bidirectional SO ↔ assistant lockstep has no validation | One-sided edits silently inconsistent | None | RESOLVED 2026-04-30 (Stack D) | | 12 | State file accumulates UUIDs without source files | Silent gitops drift | None | Partial | | 13 | `.agent/` and `.claude/handoffs/` not gitignored | `git add -A` sweeps PII handoff scratch | None | RESOLVED 2026-04-30 (Stack A) | @@ -533,6 +533,13 @@ cheat-sheet in `docs/learnings/voice-providers.md` (Stack A). ## 10. Targeted assistant pushes can auto-create duplicate tool dependencies +**[RESOLVED 2026-05-06] (#23)** — `ensureToolExists` / +`ensureStructuredOutputExists` now run a name-based dedup check (state-side +via `extractBaseSlug` + dashboard-side via lazy `/tool` list) between the +exact-key short-circuit and the create path. Adoption re-keys state to the +canonical UUID, drops stale duplicate state keys (orphan-deletion guard), +and routes through `applyTool` for the standard PATCH + drift-check flow. + **Discovered:** customer-fork log (Amazon3p #10, 2026-04-29) ### Problem @@ -578,9 +585,15 @@ Before re-pushing an assistant with local tool dependencies, inspect ### Status -**Partial.** `ensureToolExists()` blocks the most common path; the -state-renaming case remains. **Stack C dry-run** surfaces auto-apply -intent before mutation. +**Resolved 2026-05-06 (#23).** Name-based dedup check (state-side + +dashboard-side) added between the exact-key short-circuit and the create +path. Adoption re-keys state to the canonical UUID, removes stale duplicate +state keys (touched-marked so `mergeScoped` flushes the deletion), and +routes through `applyTool` so a PATCH + drift check fires with the local +payload (no fake `lastPushedHash` recorded). Dashboard-side dedup honors +dry-run by skipping the API call. Prior partial mitigation +(`ensureToolExists` exact-key check) remains as the fast path; the new +dedup is the second layer for the bootstrap-renamed case. ---