feat: AI Schema Assistant + permission type-annotation support - #157
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
samkim
force-pushed
the
ai-schema-assistant
branch
from
July 17, 2026 17:44
8f7655d to
47bd4e6
Compare
samkim
force-pushed
the
ai-schema-assistant
branch
from
July 21, 2026 23:13
9b1ea89 to
5e00133
Compare
samkim
force-pushed
the
ai-schema-assistant
branch
from
July 21, 2026 23:16
5e00133 to
aba9865
Compare
- 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.
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>
…nAI-shaped messages
…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
force-pushed
the
ai-schema-assistant
branch
from
July 21, 2026 23:18
aba9865 to
84907a1
Compare
samkim
approved these changes
Jul 22, 2026
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Summary
Adds an AI Schema Assistant to the Playground: a right-side dock chat panel where an LLM (guided by the
spicedb-schema-designskill) 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/aiproxy and is never exposed to the browser.Backend (
/api/ai)read_skill_reference) inline, and hands client-tool calls back via an SSEhandoff.retry-afterdelay (503/529 → "overloaded") instead of an opaque server error.Client
run_check,explain_check(renders the--explaindebug trace inline),run_validation,edit_document(renders a diff), check-watch CRUD, andopen_tab_to_line.Chat-panel polish
colorize), with a graceful fallback.Editor: permission type annotations
@authzed/spicedb-parser-jsto^1.2.0, which parses the return-type annotation on permissions (permission view: user | group = ...).: type | typeannotation. 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
tsc, lint, format, and production build are clean.Configuration
VITE_AI_ENABLED— show/hide the assistant UI.OPENROUTER_API_KEY— server-side only (dev:.env; prod: Vercel env). OptionalAI_MODEL,AI_MAX_TOKENS,AI_MAX_ROUND_TRIPS, and rate-limit settings.