Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ this repository: DNS, the cloud's ingress rules, and the host's own firewall.
Bun everywhere. Postgres 17 (plain `postgres:17` since migration 0024 dropped the one
unread `vector` column and the extension with it; 0065 adds `pg_trgm`, which that image
ships, for searching conversations). Hono (server), React 19 + Vite (app),
Drizzle, Tailwind 4, Biome, TanStack Router/Query, CopilotKit runtime v2 /
Drizzle, Tailwind 4, Biome, TanStack Router/Query, CopilotKit's React client /
AG-UI, Playwright (the Bot's browser).

```
Expand Down
543 changes: 18 additions & 525 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion scripts/test-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3154,6 +3154,10 @@ const MANIFEST = resolve(projectRoot, "scripts/test-manifest.json");
* permissive, none naming a licence file the package lacks; a package the store lacks only ever
* another platform's binary; and nothing fetched from outside the registry but the one named.
* 1714 and 17 is 1731.
* And the server takes seventeen, 4680 → 4697, with the info route under /api/copilotkit becoming the server's own
* (copilot.test.ts): its answer whole, the roster read on every ask, a roster that cannot be read,
* every other method on it, the spellings that are not it, no invitation to another origin, and
* the real handler behind the real guard.
*
* `roots` is a partition of the repository rather than a filter: a test file under none of them
* fails the run instead of going uncounted, which is the same silence this whole script exists to
Expand Down Expand Up @@ -3183,7 +3187,7 @@ const GROUPS: readonly {
roots: readonly string[];
processes?: number;
}[] = [
{ name: "server", floor: 4680, roots: ["server"] },
{ name: "server", floor: 4697, roots: ["server"] },
{ name: "app", floor: 2446, roots: ["app"] },
{
name: "agent-computer",
Expand Down
1 change: 0 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
"dependencies": {
"@ag-ui/client": "0.0.57",
"@better-auth/drizzle-adapter": "^1.6.27",
"@copilotkit/runtime": "1.67.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@typesafe-ai/sdk": "0.6.0",
"better-auth": "^1.6.27",
Expand Down
96 changes: 66 additions & 30 deletions server/src/copilot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createHash } from "node:crypto";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
import { CopilotRuntime } from "@copilotkit/runtime/v2";
import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono";
import { Hono } from "hono";
import { DEEP_EFFORT, FIXED_EFFORT } from "../../shared/fixed-effort";
import { textOf } from "../../shared/message-content";
import {
Expand Down Expand Up @@ -906,24 +905,29 @@ export function createRequestAgents(
}

/**
* Mount the CopilotKit endpoint onto the host Hono app.
*
* `agents` is a factory rather than a fixed map so a Bot registered while the server is running is
* reachable on the next request. Resolving once at boot would mean every new Bot needed a restart,
* which is not a property you can explain to somebody who just created one.
* The version the app's CopilotKit client (`@copilotkit/react-core`) is told it is talking to: the
* runtime's own, from when a runtime answered here. The client keeps it and decides nothing by it.
*/
const RUNTIME_VERSION = "1.67.1";

/**
* The CopilotKit endpoint, for the host Hono app to mount.
*
* ONE DOOR OF IT IS OPEN: `info`, which the app's CopilotKit client asks before it settles, for the
* roster its tool registry is built on (`app/src/lib/copilot/provider.tsx`). Every other route the
* runtime knows — a run, a connect, a stop, its thread routes, the rest of its router
* (`fetch-router.mjs`) — is answered as a path nothing is mounted on. They were how a window drove a
* chat turn, and nothing has opened them since the server took the turn (`turns/engine.ts`) and the
* window-driven path left the app (2026-10-05). Closed in the runtime's own hook, on the route its
* own router resolved, rather than by a pattern in front of it: that router reads a route off a
* path's last segments (`/api/copilotkit//x/agent/bot/run` is a run), and a pattern stricter than
* the router it guards is a door with a second entrance.
* roster its tool registry is built on (`app/src/lib/copilot/provider.tsx`). The answer is the one
* `@copilotkit/runtime` 1.67.1 gave, key for key, because that client is what reads it — and it is
* ours now: the runtime was a router, a runner and a telemetry client loaded at every boot to
* answer this one question, and everything else it served was already refused here.
*
* The roster is resolved per request rather than at boot, so a Bot registered while the server is
* running is on the next answer. Resolving once would mean every new Bot needed a restart, which is
* not a property you can explain to somebody who just created one.
*
* So the runtime is given no runner of ours: nothing runs through it, and the one it makes for
* itself is never asked to.
* Every other path under the base — a run, a connect, a stop, the thread routes, anything else — is
* a path nothing is mounted on. They were how a window drove a chat turn, and nothing has opened
* them since the server took the turn (`turns/engine.ts`) and the window-driven path left the app
* (2026-10-05). The route is the exact path: the runtime's router read a route off a path's last
* segments (`/api/copilotkit/x/info` was `info`), and that second entrance left with it.
*/
export function mountCopilotRuntime(
model: RuntimeModel,
Expand Down Expand Up @@ -952,17 +956,49 @@ export function mountCopilotRuntime(
spill,
meter,
);

const runtime = new CopilotRuntime({ agents });
return createCopilotHonoHandler({
runtime,
basePath,
hooks: {
onBeforeHandler: ({ route }) => {
if (route.method === "info") return;
// Thrown, which is how the runtime's handler is told to answer with this instead.
throw Response.json({ code: NOT_FOUND }, { status: 404 });
},
},
});
return (
new Hono()
.basePath(basePath)
.all("/info", async (context) => {
const request = context.req.raw;
// `all` and the method read here, not `get`: Hono answers a HEAD from a GET route, and a
// HEAD was refused here like every other method. No body: the runtime's was a sentence.
if (request.method !== "GET") {
return new Response(null, { status: 405, headers: { Allow: "GET" } });
}
// A roster that cannot be read is thrown to the host app, which logs it and answers its
// own 500. The runtime caught it, logged nothing, and sent the error's message to the page.
const roster = await agents({ request });
return new Response(
JSON.stringify({
version: RUNTIME_VERSION,
agents: Object.fromEntries(
Object.entries(roster).map(([id, agent]) => [
id,
{
name: id,
description: agent.description,
className: agent.constructor.name,
},
]),
),
audioFileTranscriptionEnabled: false,
mode: "sse",
threadEndpoints: {
list: true,
inspect: true,
mutations: false,
realtimeMetadata: false,
},
suggestions: true,
a2uiEnabled: false,
openGenerativeUIEnabled: false,
telemetryDisabled: true,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
})
// The trailing wildcard answers the bare base path too (measured, copilot-guard.test.ts).
.all("*", () => Response.json({ code: NOT_FOUND }, { status: 404 }))
);
}
Loading
Loading