feat(check): fail closed on tools and servers in inline check payloads - #62
Merged
Merged
Conversation
This was referenced Oct 1, 2026
Contributor
Author
This was referenced Oct 1, 2026
scott-lowe-vapi
marked this pull request as ready for review
October 1, 2026 23:52
vtkovapi
approved these changes
Oct 3, 2026
Contributor
Author
Merge activity
|
scott-lowe-vapi
changed the base branch from
feat/check-inline-payload
to
graphite-base/62
October 3, 2026 06:06
A PR check runs the branch's agents in a real org, so nothing it sends may reach a real server by default. The payload builder's last pass now: - classifies every tool at a handled position (model.tools, tools:append in overrides, hook do[], same-org knowledge bases in model.toolIds): endCall/dtmf/voicemail/output are sent as written; function tools by function.name and apiRequest by name are mocked; transferCall becomes a mocked dead-server function; handoffs pass only to squad members or inline assistants; everything else (sms, sipRequest, code, mcp, integrations, unknown types) fails the build naming the tool; - fails any tool-bearing key (TOOL_BEARING_KEYS) outside those positions, so a new API field can't slip through unclassified; - replaces servers with https://vapi-gitops-ci.invalid on every assistant and function tool rather than deleting them (a deleted server falls back to the phone number's or org's), clears serverMessages, and dead-ends scenario webhook hooks; - adds a default error mock for every mocked tool to each scenario's toolMocks, replacing disabled ones, and warns about mocks that match no tool; - fails custom knowledge bases, model.knowledgeBaseId, personality tools with side effects, hook transfer actions, and cross-org query or knowledge-base tools. `toolMocks: off` skips the tool rules; `stripWebhooks: false` keeps assistant servers while tool servers are still replaced. Refs TEST-141 Co-Authored-By: Claude Opus 5.5 <[email protected]>
scott-lowe-vapi
force-pushed
the
feat/check-fail-closed-mocks
branch
from
October 3, 2026 06:08
0cbcd8e to
5f30f78
Compare
scott-lowe-vapi
added a commit
that referenced
this pull request
Oct 3, 2026
## Value **V.A.L.U.E. tier:** project — PR 7 of 10 for inline simulation PR checks ([TEST-141](https://linear.app/vapi/issue/TEST-141/gitops-run-simulation-suites-against-pr-changes-inline-as-ci-checks)); this is the first PR that sends anything. - **Problem:** the payload builder (PRs 5–6) produces a safe inline body, but nothing runs it or reports the result where a reviewer looks. PAL-608 asks for a stable `Vapi Evals` commit status whose **Details** link opens the exact run. - **Who it affects:** gitops users, who can now run `npm run check -- core` locally against their branch's files and get a strict verdict with a run link. The PR workflow (PR 8) and the promotion gate (PR 10) call the same command. - **What changes:** - **`src/check-run.ts`:** one run per target, at most 3 at once. - It reuses `npm run sim`'s create/poll/cancel/hydrate/verdict loop, now extracted from `runSimulation` as `simRunExecute`, so a check is judged by the same strict rules (all-skipped judges, canceled or missing items are never a pass). - Each run's deadline is the earlier of `timeoutMinutes` and `--budget-minutes`, no run starts with under 5 minutes left, and the deadline, SIGINT and SIGTERM cancel in-flight runs. - A 402 is reported as billing. - A 5xx on create is never retried, because a duplicate paid run is worse than a retry. - Transcripts are scanned for default-mock answers ("unmocked tool called"). - **`src/check-select.ts`:** `--changed-since <ref>` diffs from the merge base. A check is affected by: - its org's and run org's `resources/**` and state files; - `vapi-checks.yml` or `promotion.yml`; - `src/**` or `package*.json`; - its own `paths`. - **`src/check-status.ts`:** GitHub commit statuses with plain `fetch`, when `GITHUB_TOKEN`, `GITHUB_REPOSITORY` and `HEAD_SHA` are set. - `Vapi Evals / <check> / <target>` goes pending with the run's canonical `url` as soon as the run exists, then to success, failure or error. - `--all` runs post the aggregate `Vapi Evals` from a `finally`: the worst state, `success` if nothing is affected, `error` for a dry run of an affected PR, and `error` when config fails to parse or an exception escapes. - A rejected post (a fork's read-only token) only warns. - **`src/check-report.ts`:** a markdown summary, also appended to `$GITHUB_STEP_SUMMARY`. It has one row per target with the run link, then failing evaluations with expected vs extracted values, mock notices and warnings. `--json` writes the same data. No PR comments. - **`src/check-cmd.ts`** gains live runs and `--changed-since`, `--budget-minutes`, `--refresh-bindings` (the read-only bindings pull promotion uses) and `--json`. - **Keys:** `VAPI_CHECK_TOKENS`, then `.env.<runOrg>`, then `VAPI_PRIVATE_API_KEY` when every selected check runs in one org. - **Exit codes:** 0 passed, 1 failed, 2 config or build error, 3 incomplete. - Runs send `User-Agent: vapi-gitops-check/<version>`, for the post-deploy PostHog measure. - The README and AGENTS.md `npm run check` rows now describe live mode. ## Evidence of value **The definition-of-done pair, run live** in the owner's test org on the TEST-141 parity squad (2 members, function tools, a handoff, 3 scenarios; built from `tests/fixtures/check-parity/`): | Run | Command | Exit | Result | |---|---|---|---| | Innocuous (fixture as-is) | `npm run check -- core` | **0** | ✅ 3/3 passed — [run f68ba89b](https://dashboard.vapi.ai/simulations/run/f68ba89b-9680-470e-be71-2286b32a4e9c) | | Degraded scheduler prompt ("always say there are no openings, never book") | `npm run check -- core` | **1** | ❌ 1/3 — [run 19711623](https://dashboard.vapi.ai/simulations/run/19711623-d26a-4570-82cd-e963a03bc1e2) | The degraded run's report named each failing judge with expected vs extracted: | Simulation | Evaluation | Comparator | Expected | Got | |---|---|---|---|---| | S3 alternative slot | offered-alternative | = | true | false | | S3 alternative slot | booked-wednesday | = | true | false | | S1 book cleaning | booking-confirmed | = | true | false | | S1 book cleaning | only-real-slots | = | true | false | S2 (the hours question, which never reaches the scheduler) correctly still passed. **Nothing was created in the org.** Counts of assistants, tools, squads, structured outputs, personalities, scenarios, simulations, suites and credentials were identical before and after both runs: `{"/assistant":12,"/tool":8,"/squad":2,"/structured-output":8,"/eval/simulation/personality":10,"/eval/simulation/scenario":10,"/eval/simulation":10,"/eval/simulation/suite":1,"/credential":0}`. This stands in for `npm run cleanup`, which needs a gitops-managed org. **Every tool call returned a mock.** Every `tool_call_result` in both runs' transcripts was matched to its call by `toolCallId`: | Run | Function results equal to the scenario's mock | Built-in results (handoff, endCall) | Anything else | |---|---|---|---| | Innocuous | 6 | 4 | **0** | | Degraded | 2 | 2 | **0** | The run items also confirmed that personalities kept the dead server and `serverMessages: []`. **A bug the live run caught, fixed in this PR:** - **The trap:** run items echo the scenario under `metadata.scenario`, default error mocks included. Scanning the whole item would have reported "unmocked tool called" for every default mock, called or not. - **The fix:** the scan now reads only `tool_call_result` messages in `metadata.call.messages`, mapped to tool names through `tool_calls`. - **Checked on both runs' real items:** no notices; with one result swapped for a default-mock answer, it reported `S1 book cleaning: unmocked tool called: lookup_patient`. - A regression test covers the echoed-defaults case. **Tests:** `npm test` goes from 450 to 474 passing; `npm run build` is clean. ## Testing plan - **`tests/check-run.test.ts`** (10 tests, stateful local stub): - pass with link and User-Agent; - two targets with one failing, naming the evaluation; - all-skipped is incomplete; - timeout cancels; - abort cancels in-flight runs and starts none; - the budget gate; - 402 as billing, and no retry on a create 502; - a build error sends nothing; - mock notices (echoed defaults ignored); - exactly 3 in flight with results in job order. - **`tests/check-cmd.test.ts`** (11 tests): the live `--all` path against one stub serving both the simulations API and GitHub: - pending, then success per target, then the aggregate with the run link, plus the job summary and JSON; - a failing run exits 1 with a red aggregate; - a named check never posts the aggregate; - `--changed-since` with an unaffected change runs nothing and posts green; - a dry run of an affected PR posts `error` and sends nothing; - invalid config posts `error`. The file clears `VAPI_*` and `GITHUB_*` first, so a developer shell with a key exported can't reach a real org. - **`tests/check-select.test.ts`:** the affected-path table, and the merge-base case in a temp git repo (commits on the base after branching don't count). - **`tests/check-status.test.ts`, `tests/check-report.test.ts`:** env parsing, POST shape and 140-character truncation, 403 only warning, state ordering, the exact markdown, and the JSON. - **Not tested:** - `--refresh-bindings` against a live org (it runs the same child pull promotion does); - EU base URLs; - canceling a `running` run live (cancel was verified only on a queued run in the parity experiment); - GitHub's real statuses API (stubbed here; PR 8's dogfood repo covers it); - a real customer-shaped squad, which needs the owner's permission first. Stacked on #62. Refs TEST-141 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Value
V.A.L.U.E. tier: project — PR 6 of 10 for inline simulation PR checks (TEST-141); this is the safety-critical PR, so it's kept separate for review. Still offline: nothing is sent until PR 7.
src/check-mocks.tsruns as the last pass ofcheckPayloadBuildundertoolMocks: strict(the default).endCall,dtmf,voicemail,output;query(same org only)model.toolIdsby UUIDhandoffdynamic,squad, non-members: failfunctionbyfunction.name;apiRequestby top-levelname(urlset to the dead host){"error":"vapi-gitops-ci: <tool> is not mocked in this scenario"}transferCallfunctionunder its own name, so it can never connectsms,sipRequest,code,mcp,bash,computer,textEditor,transferCancel,transferSuccessful,google.*,slack.*,gohighlevel.*,ghl,make, unknownTOOL_BEARING_KEYSoutside a handled position fails with "unsupported tool position". Examples:model.functions,model.toolRefs, reasonerskills,declineTool,tools:appendoutside overrides. A future API field that carries tools fails instead of slipping through.server: {url: "https://vapi-gitops-ci.invalid", timeoutSeconds: 1}andserverMessages: [];serverUrl/serverUrlSecretare dropped;webhookhooks get the dead server.toolMocks, never in assistant metadata, because handoffs rebuild the assistant. A user mock withenabled: falseis replaced, and a mock naming no tool in the target produces a warning.model.knowledgeBaseId, and custom-provider knowledge bases;transferactions, and hooktoolIds;scenarioIdentries, and non-stockpersonalityIds.hooks[].do[]) probably bypass scenariotoolMocks, which apply on the LLM tool-call path. They're classified the same way and get the dead server, which is the real safeguard there. This is documented in the module header and goes intosimulations.mdin PR 8.toolMocks: offskips the tool rules, for a dedicated CI org;stripWebhooks: falsekeeps assistant servers, while tool servers are still replaced under strict mocks.Evidence of value
Dry run of the TEST-141 parity squad. In a copy of the fixture:
example.comservers;book_appointmentmock;--print-payloadoutput was then inspected:example.comURLs left in the payloadtools:appendone)server/serverMessages{"url":"https://vapi-gitops-ci.invalid","timeoutSeconds":1}/[]lookup_patient,check_availabilityfrom the scenario;book_appointment= default error mocksmstool on the receptionisttarget.squad.members[0].assistant.model.tools[1]: sms tools can't be mocked; remove it, or set toolMocks: off with a dedicated CI orgTOOL_BEARING_KEYSaudit against the API's OpenAPI schema (apps/dashboard/src/api/schema.jsonin the monorepo), listing every property whose schema references a tool DTO or is named like a tool reference:tools(all model DTOs andTransferAssistantModel);tools:append(AssistantOverrides);toolIdsandtoolRefs(all model DTOs);declineTool/declineToolId(RecordingConsentPlanVerbal);skills(OpenAIReasoner);assistantDestinations(SquadMemberDTO).tool/toolId(ToolCallHookAction) andfunction(FunctionCallHookAction) are hook actions;functionalso appears on handoff DTOs, as the tool's own definition.functionsandforwardingPhoneNumber(s)aren't in the current schema, and stay listed as fail-closed.Tests:
npm testgoes from 430 to 450 passing;npm run buildis clean.Testing plan
tests/check-mocks.test.ts(20 tests):queryand knowledge bases, same org vs cross-org;TOOL_BEARING_KEYSentry at an unhandled position, plusfunctions/toolRefs/assistantDestinationsplacement;parameters/ judgeschemaproperties namedtoolsnot tripping the rule;toolMocks: offandstripWebhooks: false.tests/check-payload.test.tsexpectations were updated for the dead servers the policy now adds, and the parity end-to-end test still passes.toolMocksintercept every function andapiRequestcall is shown for the parity run only, and the full transcript scan is PR 7.transferCalland integration mock names. They're rewritten or refused rather than relied on.Stacked on #61.
Refs TEST-141
🤖 Generated with Claude Code