Skip to content

fix: stop handoff tools 400ing on a first push into an empty org - #51

Open
dhruva-vapi wants to merge 2 commits into
feat/paginate-list-endpointsfrom
fix/tool-assistant-circular-dep
Open

fix: stop handoff tools 400ing on a first push into an empty org#51
dhruva-vapi wants to merge 2 commits into
feat/paginate-list-endpointsfrom
fix/tool-assistant-circular-dep

Conversation

@dhruva-vapi

@dhruva-vapi dhruva-vapi commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Linear ticket

https://linear.app/vapi/issue/PRISM-1382/gitops-fix-handoff-tools-400ing-on-a-first-push-into-an-empty-org

Fixes PRISM-1382

Value

A first push into an empty org failed outright whenever the repo contained a handoff or transfer tool:

PATCH /tool/d787c351… → 400 Assistant with ID "clinical-stage-1-a4598432" not found

The push aborted partway, leaving the org half-configured, and the error named an assistant rather than the tool — so the cause read as an assistant problem when it was a push-ordering one. Onboarding a customer now works on the first push instead of requiring someone to know the two-step workaround.

Tools are applied before assistants, because assistants reference tools. A handoff tool references an assistant, which inverts the dependency for that subset — a genuine cycle. The engine already resolved it in two passes; one half of that machinery was missing.

The cycle, and how the two passes break it

sequenceDiagram
  participant E as Push engine
  participant V as Vapi API

  Note over E,V: tools are applied first, because assistants reference tools
  E->>V: PATCH tool — destinations point at an assistant slug
  V-->>E: 400 Assistant with ID "clinical-stage-1" not found

  rect rgba(166,51,40,0.10)
  Note over E: before — applyTool rethrows, the whole push aborts,<br/>the org is left half-configured
  end

  rect rgba(31,122,92,0.10)
  Note over E: after — omit the destinations key entirely
  E->>V: PATCH tool — no destinations key sent
  V-->>E: 200
  E->>V: POST assistants
  V-->>E: assistant uuids
  E->>V: PATCH tool — destinations now resolved to uuids
  V-->>E: 200
  end
Loading

The second pass already existed. Only the create path stripped unresolved destinations, so the update path sent a raw slug and died before the linking pass could run.

Note on scope: #52 (advisory conflict timing) merged into this branch as part of the stack, so this PR's diff now also carries src/pull.ts conflict-report changes, tests/conflict-timing.test.ts, and a sync-behavior.md section that the description above does not cover. That change has its own description, diagram, and rationale in #52 — it is not new or unreviewed work, it simply landed one level down the stack rather than into main.

Evidence of value

The create path already stripped unresolved assistant destinations, and updateToolAssistantRefs already PATCHes the real destinations once every assistant exists. The update path did not: it sent the raw slug, the API rejected it, and because the tool apply rethrows, the run aborted before the linking pass could execute. The update payload now omits the whole destinations key when any entry is unresolved, so the existing linking pass sets the real value.

Omitting rather than filtering is the load-bearing choice. Vapi PATCH replaces the keys it receives, so sending a filtered array would wipe destinations that are live on the dashboard whenever the referenced assistant is merely untracked locally — a --type tools push, for instance. An absent key is left untouched.

Six new tests in tests/tool-assistant-cycle.test.ts (plus seven from #52 in tests/conflict-timing.test.ts) cover an unresolved reference, a resolved one, a reference carrying a trailing YAML comment, a partially-resolved array (must omit, not filter), a tool with no destinations, and non-assistant destination types.

API Changes (including webhooks + events)

  • Is this changing the public API?

    • Yes
    • No
  • If yes, is it backward‐compatible?

    • Yes
    • No

Non backward-compatible changes might break customers' agents. Please proceed with care and notify the team.

Testing plan

npm run build and npm test are green (the 20 failures in the suite are pre-existing on main and unrelated — upsertState merge semantics).

Verification that matters most is a live one: push a repo containing a handoff tool into a fresh empty org and confirm the run completes, then confirm the tool's destinations on the dashboard point at the correct assistant UUID after the linking pass. Until this merges, the workaround is npm run push -- <org> --type assistants followed by a full push.

Regression signal: any 400 Assistant with ID "<slug>" not found on a tool PATCH, or a handoff tool whose dashboard destinations are empty after a successful push.

@dhruva-vapi
dhruva-vapi requested a review from vtkovapi August 1, 2026 19:12
Tools are applied before assistants, because assistants reference tools. A
handoff/transfer tool references an assistant, inverting the dependency for
that subset — a genuine cycle.

The create path already handled it: unresolved assistant destinations are
stripped, and updateToolAssistantRefs links them once every assistant exists.
The update path sent the raw slug instead, so the API answered
400 "Assistant with ID <slug> not found" and the push aborted before the
linking pass could run.

The update payload now omits the whole destinations key when any entry is
unresolved. Omitting rather than filtering matters: PATCH replaces the keys it
receives, so a filtered array would wipe destinations that are live on the
dashboard whenever the referenced assistant is merely untracked locally.
@dhruva-vapi
dhruva-vapi force-pushed the fix/tool-assistant-circular-dep branch from a3ec456 to 3437e60 Compare August 1, 2026 19:17
@dhruva-vapi
dhruva-vapi changed the base branch from main to feat/paginate-list-endpoints August 1, 2026 19:17
A both-diverged report showed three 8-character hash prefixes and nothing
else, which tells an operator nothing about which side to keep. Each entry now
also carries the dashboard's updatedAt, the local file's mtime, and which is
newer.

It stays advisory and the engine still refuses to choose, because none of these
timestamps is authoritative:

- updatedAt is bumped by our own pushes, so a newer dashboard often just means
  you pushed recently rather than that a teammate changed something.
- local mtime is reset by git clone and git checkout, so on a fresh checkout
  every file looks edited seconds ago.
- later does not mean supersedes. An edit to the prompt and an edit to the voice
  both deserve to survive; last-write-wins would discard one silently.

Sub-minute gaps report as within a minute of each other rather than naming a
winner, since clock skew is the same order of magnitude as the gap. Nothing is
persisted: a previous schema stored lastPulledAt and it was deliberately dropped
in favour of content hashes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant