Skip to content
Open
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
91 changes: 91 additions & 0 deletions server/src/copilot.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 51 additions & 7 deletions server/src/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}