Assistant: migrate to v2 runtime handler with Postgres-backed AgentRunner
All checks were successful
Deploy to VPS / test (push) Successful in 1m39s
Deploy to VPS / deploy (push) Successful in 22s

Replace the deprecated copilotRuntimeNextJSAppRouterEndpoint wrapper
(which was already the v2 single-route handler underneath) with
createCopilotRuntimeHandler from @copilotkit/runtime/v2, built once at
module scope, and plug in a custom PostgresAgentRunner:

- run() proxies the agent's event stream untouched — MAF persists every
  turn in chatthreadsnapshot, so the BFF records nothing
- connect() forwards to MAF's snapshot hydration (empty-messages run →
  RUN_STARTED → MESSAGES_SNAPSHOT → RUN_FINISHED, no LLM call), making
  the DB the only conversation store — correct across clears, restarts
  and future instances
- stop() aborts via a process-local live-run map (same pattern as the
  official sqlite-runner); isRunning() has no caller in the v2 SSE path

This deletes the whole workaround layer the in-memory runner forced:
the connect intercepts (URL + envelope forms) and Asistente's
client-side hydration run. The ask-box flow now awaits connectAgent
instead. Auth moves to a single Request rewrite (ws_token cookie →
Authorization Bearer) before the handler — the v2 path forwards
authorization + x-* headers to both run clones and runner.connect.
Stays in single-route mode: same envelope protocol the client already
auto-detected, no exposure to CopilotKit#4953, /threads* REST not
needed. Telemetry disabled via COPILOTKIT_TELEMETRY_DISABLED.

Verified full matrix in dev: fresh ask, reload replay via runner,
3 cleared convos + double navigation (only last survives), card renders
once and survives follow-up + reload, Inicio ask-box with history,
stale-tab persists only its new turn. 126 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 21:09:24 -06:00
parent 8595e74566
commit 47cb1826a8
2 changed files with 188 additions and 132 deletions

View File

@@ -1,12 +1,12 @@
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import {
AgentRunner,
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
import { Observable } from "rxjs";
import { Observable, ReplaySubject } from "rxjs";
import { Hono } from "hono";
const BACKEND_URL =
@@ -383,52 +383,21 @@ app.use("*", async (c, next) => {
});
});
app.all("/api/copilotkit/*", async (c) => {
// CopilotChat fires "connect" on every mount with an explicit threadId,
// and the runtime's in-memory runner answers it by replaying THIS
// PROCESS's event history — every run since the server started, across
// "Nueva conversación" clears (the DB delete can't touch process memory).
// That resurrected cleared conversations on navigation. The
// empty-messages hydration run against MAF's snapshot store is our single
// restore path, so connect gets an immediately-completed stream instead.
//
// Two transports must be caught: multi-route (POST …/agent/<id>/connect)
// and single-route (POST /api/copilotkit with {method:"agent/connect"} in
// the body — the one CopilotKit actually uses here; the URL-only check
// silently missed it).
const emptyStream = () =>
new Response("", {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
if (new URL(c.req.url).pathname.endsWith("/connect")) {
return emptyStream();
}
if (
c.req.method === "POST" &&
(c.req.header("content-type") ?? "").includes("application/json")
) {
try {
const envelope = (await c.req.raw.clone().json()) as { method?: string };
if (envelope?.method === "agent/connect") {
return emptyStream();
}
} catch {
// not JSON — fall through to the runtime
}
}
// ── CopilotKit v2 runtime (module scope, built once) ─────────────────────────
// Migrated 2026-07-04 off the deprecated copilotRuntimeNextJSAppRouterEndpoint
// wrapper (which was already the v2 single-route handler underneath) onto
// createCopilotRuntimeHandler directly, with a Postgres-backed AgentRunner:
// run() is a pure proxy to the MAF backend (which persists every turn in
// chatthreadsnapshot), and connect() forwards to MAF's snapshot hydration —
// so the ONLY conversation store is the DB. This deletes the in-memory
// runner (whose process-local replay resurrected cleared conversations) and
// the client-side hydration hack that compensated for it.
const cookieHeader = c.req.header("cookie") ?? "";
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
const token = match?.[1];
const agentHeaders: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {};
// Note: the browser's X-Client-Timezone header (set in App.tsx) reaches
// the backend without help — CopilotKit's runtime forwards authorization
// and all x-* headers to the agent (extractForwardableHeaders).
// The v2 handler nags about anonymous telemetry unless disabled.
process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true";
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
function makeWealthysmartAgent(headers: Record<string, string> = {}): HttpAgent {
const agent = new HttpAgent({ url: AGENT_URL, headers });
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these
// workarounds are still needed after CopilotKit/MAF upgrades.
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -442,9 +411,83 @@ app.all("/api/copilotkit/*", async (c) => {
agent.use(new DebugLogMiddleware() as any);
}
/* eslint-enable @typescript-eslint/no-explicit-any */
return agent;
}
// ── PostgresAgentRunner ───────────────────────────────────────────────────────
// The conversation store is Postgres (chatthreadsnapshot), written by MAF at
// every run end. So the runner records NOTHING:
// - run() proxies the agent's event stream (the per-request clone the v2
// handler prepared, auth headers already forwarded onto it).
// - connect() asks MAF for its snapshot hydration — an empty-messages run
// returns RUN_STARTED → [STATE_SNAPSHOT] → MESSAGES_SNAPSHOT → RUN_FINISHED
// with no LLM call (unknown thread: RUN_STARTED → RUN_FINISHED). The same
// stream shape the in-memory runner's replay approximated, but sourced
// from the DB, so it is correct across clears, restarts and instances.
// - Live runs are tracked in-process only for stop(); the official
// sqlite-runner does the same (single-instance limitation acknowledged).
// isRunning() has no caller in the v2 SSE path (verified at 1.62.2).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const liveRuns = new Map<string, any>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function proxyAgentRun(agent: any, input: any, threadId: string): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subject = new ReplaySubject<any>(Infinity);
liveRuns.set(threadId, agent);
agent
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.runAgent(input, { onEvent: ({ event }: any) => subject.next(event) })
.then(() => subject.complete())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch((err: any) => subject.error(err))
.finally(() => {
if (liveRuns.get(threadId) === agent) liveRuns.delete(threadId);
});
return subject.asObservable();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class PostgresAgentRunner extends (AgentRunner as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(request: any): Observable<any> {
return proxyAgentRun(request.agent, request.input, request.threadId);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
connect(request: any): Observable<any> {
// request.headers = authorization + x-* from the incoming browser
// request (extractForwardableHeaders) — exactly what MAF needs.
const agent = makeWealthysmartAgent(request.headers ?? {});
agent.threadId = request.threadId;
// No messages + known threadId = MAF's hydration trigger.
return proxyAgentRun(agent, {}, `connect:${request.threadId}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isRunning(request: any): Promise<boolean> {
return Promise.resolve(liveRuns.has(request.threadId));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async stop(request: any): Promise<boolean | undefined> {
const agent = liveRuns.get(request.threadId);
if (!agent) return false;
try {
agent.abortRun();
liveRuns.delete(request.threadId);
return true;
} catch {
return false;
}
}
}
const runtime = new CopilotRuntime({
agents: { wealthysmart: agent },
agents: { wealthysmart: makeWealthysmartAgent() },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runner: new PostgresAgentRunner() as any,
// a2ui disabled 2026-07-04: CK 1.60+ validates generated ops against a
// catalog and our agent's ops fail validation (surface never paints).
// Markdown tables cover the use case until the pipeline matures.
@@ -501,14 +544,32 @@ app.all("/api/copilotkit/*", async (c) => {
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
const ckHandler = createCopilotRuntimeHandler({
runtime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
basePath: "/api/copilotkit",
// Single-route mode: same envelope protocol the client already resolved
// via auto-detect (its GET /info probe 405s here, so it stays "single").
// Multi-route would only add /threads* REST endpoints we don't use and is
// exposed to CopilotKit#4953 (frontend tools under multi-route).
mode: "single-route",
cors: false,
});
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
});
// The v2 handler forwards only `authorization` and `x-*` headers to agents
// and to runner.connect — cookies never pass. Translate the auth cookie into
// a Bearer header before the handler sees the request.
function withBearerFromCookie(req: Request): Request {
if (req.headers.get("authorization")) return req;
const cookie = req.headers.get("cookie") ?? "";
const match = cookie.match(/(?:^|;\s*)ws_token=([^;]+)/);
if (!match) return req;
const headers = new Headers(req.headers);
headers.set("Authorization", `Bearer ${match[1]}`);
return new Request(req, { headers });
}
app.all("/api/copilotkit/*", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
app.all("/api/copilotkit", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
app.get("/api/health", (c) => c.json({ ok: true }));

View File

@@ -39,39 +39,34 @@ export default function Asistente() {
const [confirmClear, setConfirmClear] = useState(false);
const [clearing, setClearing] = useState(false);
// Boot sequence, once per page load: hydrate the persisted thread, then
// send any question that arrived from the Inicio ask-box via router state.
// Order matters — hydration first keeps the invariant that every normal
// run's input carries the full client-known history (the BFF's snapshot
// reconciliation relies on it).
// Ask-box flow: a question typed on Inicio arrives via router state.
// History hydration is no longer done here — CopilotChat's mount connect
// replays the persisted thread through the BFF's PostgresAgentRunner. We
// still await our own connect before sending so the run's input carries
// the full client-known history (idempotent with CopilotChat's connect:
// the MESSAGES_SNAPSHOT merge is id-based).
const bootRef = useRef(false);
useEffect(() => {
if (!agent || bootRef.current) return;
bootRef.current = true;
const ask = (location.state as { ask?: string } | null)?.ask;
if (ask) {
if (!ask || !agent || bootRef.current) return;
bootRef.current = true;
// Clear the state so back/refresh doesn't re-send the question.
navigate(location.pathname, { replace: true, state: null });
}
void (async () => {
agent.threadId = CHAT_THREAD_ID;
if (agent.messages.length === 0) {
// Empty-messages run = MAF's hydration trigger: replays the stored
// snapshot as MESSAGES_SNAPSHOT without invoking the LLM.
try {
await copilotkit.runAgent({ agent });
await copilotkit.connectAgent({ agent });
} catch (err) {
console.error("[asistente] thread hydration failed:", err);
console.error("[asistente] connect before ask failed:", err);
}
}
if (ask) {
agent.addMessage({
id: crypto.randomUUID(),
role: "user",
content: ask,
});
void copilotkit.runAgent({ agent });
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent]);