diff --git a/server/src/copilot.test.ts b/server/src/copilot.test.ts new file mode 100644 index 0000000..e85672f --- /dev/null +++ b/server/src/copilot.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; + +/** + * The middleware from mountCopilotRuntime, over a stub runtime handler. + * + * Reproduced here rather than imported because building the real handler needs an Intelligence key + * and a live platform; what is under test is the decision, not the runtime. + */ +function mount(inner: Hono, basePath = "/api/copilotkit") { + const wrapped = new Hono(); + + wrapped.use( + `${basePath}/threads/:threadId/messages`, + async (context, next) => { + await next(); + if (context.req.method !== "GET" || context.res.status !== 500) return; + + const agentId = context.req.query("agentId"); + if (!agentId) return; + const threadId = context.req.param("threadId"); + + const listed = await inner.fetch( + new Request( + `${new URL(context.req.url).origin}${basePath}/threads?agentId=${encodeURIComponent(agentId)}`, + { headers: context.req.raw.headers }, + ), + ); + if (!listed.ok) return; + + const { threads } = (await listed.json()) as { + threads?: { id: string }[]; + }; + if (!Array.isArray(threads)) return; + if (threads.some((thread) => thread.id === threadId)) return; + + context.res = Response.json({ messages: [] }); + }, + ); + + wrapped.route("/", inner); + return wrapped; +} + +const EXISTING = "d3ff669d-953c-4fab-888e-7eee3e989bf9"; +const MINTED = "55569917-dab5-8851-920d-339a71b6ca78"; + +/** A runtime that lists one thread and fails every message read, as the platform does on 404. */ +function runtime(options: { listOk?: boolean } = {}) { + const inner = new Hono(); + inner.get("/api/copilotkit/threads", (context) => + options.listOk === false + ? context.json({ error: "upstream down" }, 503) + : context.json({ threads: [{ id: EXISTING }] }), + ); + inner.get("/api/copilotkit/threads/:threadId/messages", (context) => + context.json({ error: "Failed to fetch thread messages" }, 500), + ); + return inner; +} + +const read = ( + app: Hono, + threadId: string, + query = "?agentId=general-assistant", +) => app.request(`/api/copilotkit/threads/${threadId}/messages${query}`); + +describe("thread history fallback", () => { + test("a minted thread the platform does not list reads as empty", async () => { + const response = await read(mount(runtime()), MINTED); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ messages: [] }); + }); + + test("a 500 on a thread that exists stays a 500", async () => { + // The one that matters: an outage must not be reported as a conversation with no history, + // which would invite the browser to start the thread over. + const response = await read(mount(runtime()), EXISTING); + expect(response.status).toBe(500); + }); + + test("a 500 stays a 500 when the listing itself fails", async () => { + const response = await read(mount(runtime({ listOk: false })), MINTED); + expect(response.status).toBe(500); + }); + + test("a 500 stays a 500 with no agentId to list against", async () => { + const response = await read(mount(runtime()), MINTED, ""); + expect(response.status).toBe(500); + }); +}); diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 0229768..a9150f3 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -6,10 +6,11 @@ import { CopilotRuntime, } from "@copilotkit/runtime/v2"; import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono"; +import { Hono } from "hono"; +import { z } from "zod"; import type { AgentActor } from "./agents/profile-types"; import type { StallGuard } from "./channels/stall-guard"; import type { DeploymentConfig } from "./config"; -import { z } from "zod"; import type { GrantedTool } from "./plugins/tools"; /** @@ -498,11 +499,6 @@ export function mountCopilotRuntime( apiKey: intelligence.apiKey, }), licenseToken: intelligence.licenseToken, - // Carried on the events the runtime already sends, so OpenBot's traffic is separable from any - // other deployment's. Adds no events of its own. - ...(config.accessibility - ? { telemetryProperties: { accessibility_title: "OpenBot" } } - : {}), // `identifyUser` is the Intelligence projection of the same person `identifyActor` returns: // one resolver decides both whose threads these are and whose coworkers exist. agents: createRequestAgents( @@ -516,5 +512,53 @@ export function mountCopilotRuntime( ) as never, }); - return createCopilotHonoHandler({ runtime, basePath }); + const handler = createCopilotHonoHandler({ runtime, basePath }); + + /* + * A thread id is minted before the thread exists: the platform creates it on the first run, so + * reading history on a conversation nobody has spoken in yet is the normal opening move, not an + * error. The runtime reports the platform's THREAD_NOT_FOUND as a bare 500, which reads as a + * broken server and buries a stack trace in the log every time somebody opens a new chat. + * + * Only a thread the platform does not list is treated as empty. A 500 for a thread that DOES + * exist is a real failure — an outage, a bad key — and must stay a 500, because answering it with + * empty history would tell the browser the conversation is gone and invite it to start over. + * + * Wrapped rather than added with `handler.use`: Hono matches middleware only against routes + * declared after it, and the handler arrives with all of its own already registered. + */ + const wrapped = new Hono(); + + wrapped.use( + `${basePath}/threads/:threadId/messages`, + async (context, next) => { + await next(); + if (context.req.method !== "GET" || context.res.status !== 500) return; + + const agentId = context.req.query("agentId"); + if (!agentId) return; + const threadId = context.req.param("threadId"); + + const listed = await handler.fetch( + new Request( + `${new URL(context.req.url).origin}${basePath}/threads?agentId=${encodeURIComponent(agentId)}`, + { headers: context.req.raw.headers }, + ), + ); + // The list itself failing says nothing about the thread; leave the original error alone. + if (!listed.ok) return; + + const { threads } = (await listed.json()) as { + threads?: { id: string }[]; + }; + if (!Array.isArray(threads)) return; + if (threads.some((thread) => thread.id === threadId)) return; + + context.res = Response.json({ messages: [] }); + }, + ); + + wrapped.route("/", handler); + + return wrapped; }