Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
1303f22
feat: AI schema assistant backend — streaming /api/ai proxy
josephschorr Jul 14, 2026
add7a91
feat: AI schema assistant client core — tool registry, tools, control…
josephschorr Jul 15, 2026
64c9b1f
feat: AI schema assistant UI — right-side dock, chat panel, history
josephschorr Jul 15, 2026
1b6ea0f
fix: harden AI assistant error handling, rate-limit IP, and input caps
josephschorr Jul 15, 2026
2bcfa5d
chore: pin Node 22.19.0 via .nvmrc
josephschorr Jul 15, 2026
0fb357f
fix: load .env into dev server process.env; log AI backend errors
josephschorr Jul 15, 2026
ea117f5
fix: keep vite.config an object export so vitest can merge it
josephschorr Jul 15, 2026
7ebdcf0
feat: assistant progress spinner + per-message status; fix tab target…
josephschorr Jul 15, 2026
2fb563c
feat: render assistant code blocks with language labels + schema high…
josephschorr Jul 15, 2026
2e1a37a
feat: single message-level Undo (hover-revealed) instead of per-diff …
josephschorr Jul 15, 2026
7192159
feat: clear AI chat and history when a new document is loaded
josephschorr Jul 15, 2026
d21407c
feat: clear AI chat and history when loading a shared playground
josephschorr Jul 15, 2026
3ddaba2
feat: explain_check tool that renders the check debug trace in chat
josephschorr Jul 15, 2026
c59e13c
feat: render GFM tables in assistant markdown (remark-gfm)
josephschorr Jul 15, 2026
fcfd310
feat: support permission type annotations in the schema editor
josephschorr Jul 15, 2026
085eddb
feat: polish the AI assistant chat panel
josephschorr Jul 15, 2026
c764bad
feat: surface rate-limit details from the AI backend
josephschorr Jul 15, 2026
3f59653
fix: hide the AI assistant dock unless VITE_AI_ENABLED is set
josephschorr Jul 15, 2026
2ac9c9c
fix: keep the history panel available when AI is disabled
josephschorr Jul 15, 2026
4b00fd8
Rename Upstash env vars to match managed env vars
samkim Jul 16, 2026
5706d16
fix: add explicit .js extensions to api/ relative imports
samkim Jul 16, 2026
b03a56f
Fix assistant message overflow
samkim Jul 16, 2026
6365cb3
fix: address AI assistant code-review findings
samkim Jul 16, 2026
7451571
fix: don't let scrollIntoView's return value become an effect's impli…
samkim Jul 16, 2026
a8d9a58
feat: track playground_ai_* posthog events for assistant interactions
samkim Jul 16, 2026
69b9f21
feat: set 60s maxDuration on /api/ai to avoid Vercel's 15s default ti…
samkim Jul 17, 2026
11fe1fd
feat: add tooltips and unread badge to dock activity bar
samkim Jul 20, 2026
8cd0712
feat: scope AI assistant to schema questions, defer rest to docs
samkim Jul 20, 2026
308a3ec
feat: open assistant panel by default for first-time visitors
samkim Jul 20, 2026
78082a4
feat: add a raw-fetch OpenRouter streaming client
samkim Jul 21, 2026
21f0eb0
fix: drop unused createParser import in openrouterClient.ts
samkim Jul 21, 2026
c1790f4
feat: build OpenRouter system message and tool defs, replacing the An…
samkim Jul 21, 2026
70bcf69
fix: restore anthropic.ts/anthropic.test.ts, deleted too early by Task 2
samkim Jul 21, 2026
57243e5
feat: rewrite the /api/ai wire schema for OpenAI-shaped messages
samkim Jul 21, 2026
4bd5494
feat: rewrite the assistant turn loop over OpenRouter-shaped messages
samkim Jul 21, 2026
bd7fa0a
fix: prevent role literal widening in adapter.test.ts's shared fixture
samkim Jul 21, 2026
b517f98
fix: drop non-functional ANTHROPIC_API_KEY fallback in aiRoute.ts
samkim Jul 21, 2026
832dbae
feat: finish the OpenRouter client swap — rename remaining env vars, …
samkim Jul 21, 2026
bfe4c1b
feat: rewrite the assistant wire types for OpenAI-shaped messages
samkim Jul 21, 2026
b632bc4
feat: rewrite the assistant controller's tool-result handling for Ope…
samkim Jul 21, 2026
0bd722c
test: assert the outgoing request body in openrouterClient's streamin…
samkim Jul 21, 2026
1a63283
fix: tolerate both numeric and string error.code shapes in mid-stream…
samkim Jul 21, 2026
f432e3f
docs+test: pin the mid-stream error handling to the confirmed doc exa…
samkim Jul 21, 2026
4bbba57
fix: bump assistant store persist version for the OpenRouter message-…
samkim Jul 21, 2026
8b17d86
fix: prefix failing tool results with 'Error:' for the model
samkim Jul 21, 2026
426b19f
fix: synthesize an explicit failure result for malformed tool-call ar…
samkim Jul 21, 2026
d4f2694
fix: clear the assistant unread badge on first message, not just on t…
samkim Jul 21, 2026
78eac59
fix: retry transient OpenRouter failures, preserve raw mid-stream err…
samkim Jul 21, 2026
588ab7f
feat: restore prompt caching for the static system-prompt prefix
samkim Jul 21, 2026
1303acf
fix: pair tool-call outcomes by position, restore activity chip for m…
samkim Jul 21, 2026
087c0eb
fix: release retried-away connections, fix empty error-code edge case
samkim Jul 21, 2026
7081f4d
fix: guard parseNumericCode's number branch against non-finite input
samkim Jul 21, 2026
84907a1
Formatting fixes
samkim Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22.19.0
71 changes: 71 additions & 0 deletions api/_lib/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";

import { handleAiRequest } from "../ai.js";

const baseArgs = {
ip: "1.2.3.4",
env: { OPENROUTER_API_KEY: "sk-test", AI_ENABLED: "true" } as unknown as NodeJS.ProcessEnv,
client: {
stream: () => ({
on: () => {},
finalMessage: () =>
Promise.resolve({
message: { role: "assistant" as const, content: "hi" },
finish_reason: "stop",
}),
}),
},
};

const validBody = {
messages: [{ role: "user", content: "hi" }],
state: { schema: "", relationships: "", assertions: "", expected: "" },
tools: [],
};

function fakeSink() {
const events: { event: string; data: unknown }[] = [];
return {
send: (event: string, data: unknown) => events.push({ event, data }),
end: vi.fn(),
events,
};
}

describe("handleAiRequest", () => {
it("rejects non-POST", async () => {
const respondError = vi.fn();
const sink = fakeSink();
await handleAiRequest({ ...baseArgs, method: "GET", body: validBody, sink, respondError });
expect(respondError).toHaveBeenCalledWith(405, expect.anything());
});

it("returns 503 when AI is disabled", async () => {
const respondError = vi.fn();
const sink = fakeSink();
await handleAiRequest({
...baseArgs,
env: { ...baseArgs.env, AI_ENABLED: "false" },
method: "POST",
body: validBody,
sink,
respondError,
});
expect(respondError).toHaveBeenCalledWith(503, expect.anything());
});

it("returns 400 on an invalid body", async () => {
const respondError = vi.fn();
const sink = fakeSink();
await handleAiRequest({ ...baseArgs, method: "POST", body: { bad: true }, sink, respondError });
expect(respondError).toHaveBeenCalledWith(400, expect.anything());
});

it("streams a turn for a valid request", async () => {
const respondError = vi.fn();
const sink = fakeSink();
await handleAiRequest({ ...baseArgs, method: "POST", body: validBody, sink, respondError });
expect(respondError).not.toHaveBeenCalled();
expect(sink.events.find((e) => e.event === "done")).toBeDefined();
});
});
320 changes: 320 additions & 0 deletions api/_lib/aiHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
import { describe, expect, it, vi } from "vitest";

import { runAiTurn } from "./aiHandler.js";
import type { OpenRouterFinalMessage, OpenRouterLike } from "./openrouterClient.js";

function fakeClient(
finals: OpenRouterFinalMessage[],
textByCall: string[][] = [],
): OpenRouterLike & { calls: any[] } {
let call = 0;
const calls: any[] = [];
return {
calls,
stream(params) {
calls.push(params);
const idx = call++;
return {
on(event: "text", cb: (d: string) => void) {
if (event === "text") for (const t of textByCall[idx] ?? []) cb(t);
},
finalMessage: () => Promise.resolve(finals[idx]),
};
},
};
}

function collectingSink() {
const events: { event: string; data: any }[] = [];
return {
sink: {
send: (event: string, data: unknown) => events.push({ event, data: data as any }),
end: vi.fn(),
},
events,
};
}

const req = {
messages: [{ role: "user" as const, content: "hi" }],
state: { schema: "", relationships: "", assertions: "", expected: "" },
tools: [{ name: "run_check", description: "d", input_schema: { type: "object" } }],
};
const deps = { model: "anthropic/claude-sonnet-5", maxTokens: 1024, maxRoundTrips: 10 };

describe("runAiTurn", () => {
it("streams text and emits done when the model uses no tools", async () => {
const { sink, events } = collectingSink();
const client = fakeClient(
[{ message: { role: "assistant", content: "hello" }, finish_reason: "stop" }],
[["hel", "lo"]],
);
await runAiTurn(req, { ...deps, client }, sink);

expect(events.filter((e) => e.event === "text").map((e) => e.data.delta)).toEqual([
"hel",
"lo",
]);
const done = events.find((e) => e.event === "done");
expect(done?.data.finish_reason).toBe("stop");
expect(sink.end).toHaveBeenCalledOnce();
});

it("hands off client tool calls without finishing the turn", async () => {
const { sink, events } = collectingSink();
const client = fakeClient([
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "t1",
type: "function",
function: { name: "run_check", arguments: '{"resource":"doc:x"}' },
},
],
},
finish_reason: "tool_calls",
},
]);
await runAiTurn(req, { ...deps, client }, sink);

const handoff = events.find((e) => e.event === "handoff");
expect(handoff).toBeDefined();
expect(handoff!.data.clientToolCalls).toEqual([
{ id: "t1", name: "run_check", input: { resource: "doc:x" } },
]);
expect(handoff!.data.serverToolResults).toEqual([]);
expect(events.find((e) => e.event === "done")).toBeUndefined();
});

it("executes a server tool inline and continues to a final answer", async () => {
const { sink, events } = collectingSink();
const client = fakeClient([
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "s1",
type: "function",
function: { name: "read_skill_reference", arguments: '{"name":"patterns"}' },
},
],
},
finish_reason: "tool_calls",
},
{ message: { role: "assistant", content: "done" }, finish_reason: "stop" },
]);
await runAiTurn(req, { ...deps, client }, sink);

expect(events.find((e) => e.event === "handoff")).toBeUndefined();
expect(events.find((e) => e.event === "done")).toBeDefined();
});

it("separates text between server-tool round-trips with a paragraph break", async () => {
const { sink, events } = collectingSink();
const client = fakeClient(
[
{
message: {
role: "assistant",
content: "Let me check the docs.",
tool_calls: [
{
id: "s1",
type: "function",
function: { name: "read_skill_reference", arguments: '{"name":"patterns"}' },
},
],
},
finish_reason: "tool_calls",
},
{
message: { role: "assistant", content: "Based on the reference." },
finish_reason: "stop",
},
],
[["Let me check the docs."], ["Based on the reference."]],
);
await runAiTurn(req, { ...deps, client }, sink);

expect(events.filter((e) => e.event === "text").map((e) => e.data.delta)).toEqual([
"Let me check the docs.",
"\n\nBased on the reference.",
]);
});

it("returns server results alongside pending client calls for a mixed message", async () => {
const { sink, events } = collectingSink();
const client = fakeClient([
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "s1",
type: "function",
function: { name: "read_skill_reference", arguments: '{"name":"patterns"}' },
},
{ id: "c1", type: "function", function: { name: "run_check", arguments: "{}" } },
],
},
finish_reason: "tool_calls",
},
]);
await runAiTurn(req, { ...deps, client }, sink);

const handoff = events.find((e) => e.event === "handoff")!;
expect(handoff.data.clientToolCalls.map((c: any) => c.id)).toEqual(["c1"]);
expect(handoff.data.serverToolResults.map((r: any) => r.tool_call_id)).toEqual(["s1"]);
});

it("emits a step_limit error when round trips are exhausted", async () => {
const { sink, events } = collectingSink();
const serverOnly: OpenRouterFinalMessage = {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "s",
type: "function",
function: { name: "read_skill_reference", arguments: '{"name":"patterns"}' },
},
],
},
finish_reason: "tool_calls",
};
const client = fakeClient(Array.from({ length: 5 }, () => serverOnly));
await runAiTurn(req, { ...deps, client, maxRoundTrips: 2 }, sink);

expect(events.find((e) => e.event === "error")?.data.code).toBe("step_limit");
});

it("synthesizes a clear failure result for malformed tool-call arguments, without executing the tool", async () => {
const { sink, events } = collectingSink();
const client = fakeClient([
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "s1",
type: "function",
function: { name: "read_skill_reference", arguments: '{"name":"patte' },
},
],
},
finish_reason: "tool_calls",
},
{ message: { role: "assistant", content: "done" }, finish_reason: "stop" },
]);
await runAiTurn(req, { ...deps, client }, sink);

expect(events.find((e) => e.event === "handoff")).toBeUndefined();
expect(events.find((e) => e.event === "done")).toBeDefined();

const secondCallMessages = client.calls[1].messages;
const toolResult = secondCallMessages.find(
(m: any) => m.role === "tool" && m.tool_call_id === "s1",
);
expect(toolResult?.content).toMatch(/Malformed arguments for tool "read_skill_reference"/);
expect(toolResult?.content).toMatch(/not executed/);
});

it("includes a malformed-argument failure alongside a valid client tool call in the same handoff", async () => {
const { sink, events } = collectingSink();
const client = fakeClient([
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "bad1",
type: "function",
function: { name: "read_skill_reference", arguments: "{not json" },
},
{ id: "c1", type: "function", function: { name: "run_check", arguments: "{}" } },
],
},
finish_reason: "tool_calls",
},
]);
await runAiTurn(req, { ...deps, client }, sink);

const handoff = events.find((e) => e.event === "handoff")!;
expect(handoff.data.clientToolCalls.map((c: any) => c.id)).toEqual(["c1"]);
expect(handoff.data.serverToolResults).toHaveLength(1);
expect(handoff.data.serverToolResults[0]).toMatchObject({ tool_call_id: "bad1" });
expect((handoff.data.serverToolResults[0] as any).content).toMatch(/Malformed arguments/);
});

it("does not cross-contaminate arguments between tool calls that share the same id", async () => {
const { sink, events } = collectingSink();
const client = fakeClient([
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "x",
type: "function",
function: { name: "read_skill_reference", arguments: "{not json" },
},
{
id: "x",
type: "function",
function: { name: "run_check", arguments: '{"resource":"doc:x"}' },
},
],
},
finish_reason: "tool_calls",
},
]);
await runAiTurn(req, { ...deps, client }, sink);

const handoff = events.find((e) => e.event === "handoff")!;
// The malformed read_skill_reference call must not be treated as valid,
// and the valid run_check call must not be silently dropped or have its
// arguments merged with the other call sharing the same id.
expect(handoff.data.clientToolCalls).toEqual([
{ id: "x", name: "run_check", input: { resource: "doc:x" } },
]);
expect(handoff.data.serverToolResults).toHaveLength(1);
expect((handoff.data.serverToolResults[0] as any).content).toMatch(
/Malformed arguments for tool "read_skill_reference"/,
);
});

it("hands off (rather than silently continuing) when the only tool call is a malformed client-tool call", async () => {
const { sink, events } = collectingSink();
const client = fakeClient([
{
message: {
role: "assistant",
content: null,
tool_calls: [
{ id: "c1", type: "function", function: { name: "run_check", arguments: "{not json" } },
],
},
finish_reason: "tool_calls",
},
]);
await runAiTurn(req, { ...deps, client }, sink);

const handoff = events.find((e) => e.event === "handoff");
expect(handoff).toBeDefined();
expect(handoff!.data.clientToolCalls).toEqual([]);
expect(handoff!.data.malformedClientToolCalls).toEqual([
{ id: "c1", name: "run_check", error: expect.any(String) },
]);
expect(handoff!.data.serverToolResults[0] as any).toMatchObject({ tool_call_id: "c1" });
});
});
Loading
Loading