Skip to content

feat: AI Schema Assistant + permission type-annotation support - #157

Merged
samkim merged 53 commits into
mainfrom
ai-schema-assistant
Jul 22, 2026
Merged

feat: AI Schema Assistant + permission type-annotation support#157
samkim merged 53 commits into
mainfrom
ai-schema-assistant

Conversation

@josephschorr

@josephschorr josephschorr commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

Adds an AI Schema Assistant to the Playground: a right-side dock chat panel where an LLM (guided by the spicedb-schema-design skill) reads the current schema / relationships / assertions / expected-relations, edits those documents, and drives the playground through installed tools — running checks, explaining traces, validating, and managing check watches. Also adds first-class editor support for permission type annotations (use typechecking).

The panel is gated behind VITE_AI_ENABLED; the OpenRouter API key stays server-side in the /api/ai proxy and is never exposed to the browser.

Backend (/api/ai)

  • Streaming Vercel function that holds the OpenRouter key and runs the model turn server-side, relaying text, tool-use, and completion over SSE.
  • The browser owns the conversation and executes stateful client tools; the server runs the OpenRouter loop, executes read-only server tools (e.g. read_skill_reference) inline, and hands client-tool calls back via an SSE handoff.
  • Per-IP rate limiting, request validation, input caps, and trusted-proxy IP handling.
  • Rate-limit details are surfaced: an upstream 429 becomes a clear "rate limited" message with the retry-after delay (503/529 → "overloaded") instead of an opaque server error.

Client

  • Chat panel with a generic tool registry — each tool declares its schema, executor, optional rich-render metadata, and model-facing redaction, so the controller has no per-tool special-casing.
  • Tools: run_check, explain_check (renders the --explain debug trace inline), run_validation, edit_document (renders a diff), check-watch CRUD, and open_tab_to_line.
  • Rich artifacts: document diffs and collapsible check-debug traces reuse the existing trace renderer.
  • IndexedDB revision history with per-message Undo that restores the pre-turn checkpoint; chat + history reset when a new example or shared playground is loaded.
  • Markdown rendering with GFM tables, fenced code blocks, live status/spinner, and success/error states.

Chat-panel polish

  • Paragraph breaks between text blocks from separate model round-trips (client- and server-tool boundaries) so replies don't run together.
  • Completed-turn tool chips and change diffs collapse into "Actions taken" / "Changes made" expandos when there's more than one (chronological order preserved; inline while streaming and on error).
  • Descriptive tool-chip summaries plus a tooltip naming the tool.
  • Schema / relationships code blocks are highlighted with the editor's real Monaco tokenizer and theme (via colorize), with a graceful fallback.

Editor: permission type annotations

  • Bumps @authzed/spicedb-parser-js to ^1.2.0, which parses the return-type annotation on permissions (permission view: user | group = ...).
  • Extends the Monaco DSL tokenizer to highlight the : type | type annotation. Go-to-definition and semantic tokens required no changes — the parser returns each annotation type as a resolvable type reference, so the existing providers handle it.

Testing

  • Unit (169) and browser (37) suites pass; tsc, lint, format, and production build are clean.
  • Coverage includes the server turn loop, rate-limit mapping, the tool registry/controller, the text-separator behavior, the chat collapse behavior, and the DSL tokenizer + colorize path.

Configuration

  • VITE_AI_ENABLED — show/hide the assistant UI.
  • OPENROUTER_API_KEY — server-side only (dev: .env; prod: Vercel env). Optional AI_MODEL, AI_MAX_TOKENS, AI_MAX_ROUND_TRIPS, and rate-limit settings.

@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
playground Ready Ready Preview, Comment Jul 21, 2026 11:19pm

Request Review

- api/ai.ts Vercel handler + framework-agnostic core (api/_lib/aiHandler) with
  SSE streaming, hybrid server/client tool loop, and step/rate guards
- read_skill_reference server tool + vendored spicedb-schema-design skill text
  (api/_skill, generated by scripts/sync-skill.ts)
- zod request schema, system-prompt/tool assembly with collision guard
- Upstash-backed rate limiter (dev no-op), dev-server /api/ai route
- frontend aiApiEndpoint/aiEnabled config
…ler loop

- frontend tool registry (zod schemas → Anthropic wire) + 8 client tools:
  edit_document, run_check, run_validation, open_tab_to_line, check-watch CRUD
- shared src/services/wasmRunners.ts; check.ts/validation.ts refactored to
  delegate to it (no duplicated WASM orchestration)
- SSE stream client, conversation store, and useAssistantController driving the
  hybrid handoff loop against /api/ai
- editor reveal store for programmatic jump-to-line
- oxfmt formatting sweep across the AI assistant files
- collapsible right-side dock (RightDock + activity bar) hosting Assistant and
  History panels, gated by VITE_AI_ENABLED
- AssistantPanel: streaming markdown, tool-activity chips, diff cards, per-message
  undo, New chat, rate-limit banner
- IndexedDB-backed revision History (30 revisions) with restore; debounced
  snapshotter + AI/manual/restore sources; wired into useAssistantController
- EditorDisplay subscribes to the reveal store for jump-to-line
- FullPlayground wires the dock + history recorder into the app shell
…ing and empty diffs

- working spinner (Thinking…/Working…) while a turn is in flight
- per-message Done/Failed footer with error text (replaces the global banner)
- open_tab_to_line activates a doc in whichever split group hosts it, via
  showDocument — was duplicating right-hand-split docs onto the left
- suppress empty diff cards from no-op edits (before === after) + DiffCard
  fallback for whitespace/order-only changes
…lighting

- Markdown renders fenced code blocks with a language-tag header; SpiceDB
  schema blocks (```zed/schema/spicedb) get lightweight keyword/operator/
  comment highlighting; other langs render as labeled monospace
- system prompt asks the agent to tag snippets by document type
  (```zed / ```relationships / ```yaml)
- browser test covering the code-block label + highlighted keyword
- DataStore gains registerReloadListener, fired by load() for new-document loads
  (examples, shares, blank reset) but NOT for restores (load's isRestore opt)
- restoreRevision passes isRestore so in-session undo keeps chat/history
- FullPlayground resets the assistant store + reseeds history on reload
The /s/<share> route loads into its own datastore instance and navigates away,
so the FullPlayground reload listener never sees it and history reloads from
IndexedDB on mount. ShareLoader now resets the chat and sets a cross-route flag
(sessionReset) that the history recorder consumes after hydrate — discarding the
previous document's persisted revisions and starting fresh for the share.
- explain_check runs a permission check, returns a compact text trace to the
  model, and renders the CheckDebugTrace tree in chat by reusing the existing
  CheckDebugTraceView (collapsible per node = the trace-subset view)
- generalize the controller: AssistantTool gains render()/redactFromModel
  metadata, so edit_document (diff) and explain_check (trace) declare their rich
  rendering + model redaction instead of the controller special-casing by name
- DisplayMessage.diffs -> generic artifacts[] (diff | trace); persist bumped to
  v2 with a reset migrate + defensive reads (old persisted 'diffs' shape)
- message-level Undo now gated on the message having an edit artifact
Bump @authzed/spicedb-parser-js to ^1.2.0, which parses the optional
return-type annotation on permissions (`permission view: user = ...`,
the `use typechecking` feature). Extend the Monaco tokenizer to
highlight the `: type | type` annotation; go-to-definition and
semantic tokens work unchanged because the parser now returns each
annotation type as a resolvable type reference.
samkim and others added 25 commits July 21, 2026 16:18
New visitors have no persisted rightdock state, so they now land on
the assistant panel open instead of a closed dock. Returning visitors
keep whatever open/closed state they last left it in, since the
persisted value still overrides this default once rehydrated.

Also fixes DockActivityBar's browser tests, which needed a
TooltipProvider ancestor after the tooltip labels were added.
Only the EventSourceMessage type is needed here (runStream uses the
EventSourceParserStream transform, not createParser directly) — a
leftover from the plan's own code block. oxlint --type-aware flagged
the unused import.
aiHandler.ts still imports buildSystemBlocks/buildToolDefs from
./anthropic.js until Task 4 rewrites it. Deleting the file in Task 2
broke module resolution for the whole vitest config (vite.config.ts ->
dev-api/index.ts -> api/ai.ts -> aiHandler.ts -> the missing import),
which crashes vitest's own startup rather than just failing aiHandler's
tests as the plan assumed — no tests could run at all, for any file,
until this was restored. The actual deletion now belongs in Task 4,
where aiHandler.ts's import is repointed to ./openrouter.js.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
baseArgs.client.finalMessage()'s inline { role: "assistant", ... }
widens to `role: string` when assigned to an un-annotated const, which
doesn't satisfy OpenRouterFinalMessage's literal-union type — a bug in
the plan's own code block, not introduced by the Task 4 implementer.
An Anthropic API key can't authenticate against OpenRouter — the
fallback silently attempted to and would only surface as an unclear
401 from OpenRouter, not a clean config error. Flagged in Task 4's
review. Uses OPENROUTER_API_KEY directly, matching what Task 5 was
always going to do (the env var itself isn't renamed until Task 5).
…add 502 mapping

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g test

Flagged by the final whole-branch review: the streaming test asserted
method/headers but never the body, so nothing verified stream:true or
that model/messages/tools actually serialize into the request. This
was the highest-value cheap check given the live-API smoke test is
deferred (no OPENROUTER_API_KEY available locally).
… errors

OpenRouter's own docs disagree with each other on whether a mid-stream
SSE error's error.code is a numeric HTTP-status mirror or a string
error type (streaming-page examples show a string like "server_error";
the general errors reference says numeric only) — checked directly
against https://openrouter.ai/docs/api_reference/streaming and
https://openrouter.ai/docs/api-reference/errors, which contradict each
other even under a strict verbatim-quote prompt. Rather than trust
either, normalizeErrorCode() accepts a real number, parses a numeric
string, and falls back to a generic 500 for a non-numeric string
instead of comparing a string against describeTurnError's numeric
status checks, which would silently miscompare every one of them.

This was the Important finding from the final whole-branch review: the
error-shape assumption was unverified against the live API. The user
has since run a manual smoke test successfully against the real
OpenRouter API, which exercises the happy path this depends on.
…mple

The user quoted the actual docs text directly, confirming error.code
is a string ("server_error") once tokens have streamed and HTTP 200 is
already committed — not a docs contradiction, just two different error
paths (pre-stream errors mirror the real HTTP status as a number;
mid-stream errors use a synthetic string type since there's no status
left to mirror). Tightened the code comment accordingly and added a
regression test using the exact verbatim example from OpenRouter's
docs, so accumulateChunk is pinned to observed reality per the final
whole-branch review's recommendation.
…shape change

Old persisted conversations (Anthropic content-blocks) were never
invalidated when ChatMessage's shape changed to OpenAI-style
tool_calls/tool-role messages, so returning users would get a 400 on
their next message in an old thread. Bumps the persist version so the
existing migrate() reset (same pattern as v2) kicks in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OpenAI's tool-role messages have no dedicated failure flag (unlike
Anthropic's is_error on tool_result blocks), so a tool that fails
without throwing and without a self-descriptive JSON payload (e.g. no
ok/error field) could read as a normal successful result to the
model. Prefixing failing results with 'Error:' restores an
unambiguous signal regardless of the tool's own result shape.
…guments

parseArguments used to silently substitute {} on a JSON parse failure,
masking truncated/corrupted tool-call arguments as if the tool were
simply called with no input — a server tool would then execute with
{} and return a confusing 'unknown reference' error, and a client
tool's handoff would ship input:{} for the frontend's zod validation
to reject with a generic 'required' message. Now a parse failure is
detected before dispatch and produces an honest, explicit tool_result
describing exactly what went wrong, without ever executing the tool
or handing it to the client.
…oggle

The badge was only dismissed by clicking the toggle button, but the
assistant panel now opens by default for first-time visitors, so the
badge could show indefinitely on an already-open panel. Subscribing
to useAssistantStore's message count (the same global store instance
everywhere, so no cross-instance sync issue like useDismissible would
have) clears it the moment the user sends their first message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…or codes

The raw-fetch client had no retry logic at all, unlike the Anthropic
SDK it replaced (which retried network errors and 429/5xx with
backoff by default) — a momentary blip that used to self-heal now
fails on the first attempt. Adds bounded retry-with-backoff to the
connection-establishment fetch only (never mid-stream, since a
partially-streamed response can't be safely restarted).

Separately, a mid-stream inline error's code field is confirmed (per
OpenRouter's own docs) to sometimes be a semantic string like
"server_error", not a numeric HTTP status — normalizeErrorCode
already handled this safely by falling back to a generic status, but
silently discarded the original string. It's now preserved in the
error message for anyone debugging a real incident.
The OpenRouter migration collapsed the system message into one plain
uncached string, dropping the pre-migration Anthropic-native
cache_control optimization that avoided re-billing/re-processing the
large static skill-overview + instructions text on every request.
OpenRouter passes cache_control through to Anthropic models via its
unified endpoint (verified against OpenRouter's docs) using the same
content-parts-array shape Anthropic uses natively — splitting the
system message into a cached static prefix and an uncached dynamic
state suffix restores the original caching architecture.
…alformed calls

Keying parsed arguments by tool_call.id in a Map meant two calls
sharing an id (never guaranteed unique) would silently overwrite each
other, letting a malformed call execute with a different call's
arguments and emitting duplicate tool_call_id responses that break
the next API round trip. Now paired by array position instead.

Separately, malformed calls are resolved entirely server-side and
never reached executeClientTool, the only place that reports tool
activity to the UI — a malformed call that would have targeted a
client tool now produced no visible indicator at all. The backend
now flags these via an optional malformedClientToolCalls field on the
handoff event so the frontend can report activity without
re-executing anything (the tool_result itself was already included).
This also restores the pre-existing behavior of ending the turn via
handoff for any client-named call, valid or malformed, instead of
silently continuing the loop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fetchWithRetry discarded a retryable-status response's body without
reading or canceling it before looping to retry, leaking the
connection back to the pool under sustained transient failures.
Separately, normalizeErrorCode's Number.isFinite(Number(code)) check
treated an empty/whitespace error.code string as the numeric value 0
(a finite number) instead of falling back to the intended generic
500 — extracted a shared parseNumericCode() used by both
normalizeErrorCode and accumulateChunk's message-annotation check so
the two can't drift out of sync with each other again.
The string branch validated Number.isFinite before accepting a
parsed value, but the number branch returned its input unchecked —
if it were ever NaN, normalizeErrorCode's `?? 500` fallback wouldn't
catch it (?? only replaces null/undefined, not NaN), returning NaN
itself. Not reachable via the current caller (RawChunk is always
built from JSON.parse, which can't produce NaN), but the asymmetry
between the two branches of one function meant to be the single
source of truth for this validation was a real inconsistency.

Found by a low-effort review of the two prior fix commits.
@samkim
samkim merged commit bb0ed23 into main Jul 22, 2026
5 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 22, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants