From 0ab6993d347acfe1b82625623a47b0ab229f177c Mon Sep 17 00:00:00 2001 From: Stephanie Schofield Date: Fri, 21 Aug 2026 00:14:23 -0500 Subject: [PATCH] fix: read history on a thread that has never run as empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 and stays a 500: 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, because Hono matches middleware only against routes declared after it and the handler arrives with its own already registered. --- server/src/copilot.test.ts | 91 ++++++++++++++++++++++++++++++++++++++ server/src/copilot.ts | 58 +++++++++++++++++++++--- 2 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 server/src/copilot.test.ts 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; }