Skip to content

A chat you can start again - #81

Open
rhlsthrm wants to merge 1 commit into
CopilotKit:mainfrom
rhlsthrm:a-chat-you-can-start-again
Open

A chat you can start again#81
rhlsthrm wants to merge 1 commit into
CopilotKit:mainfrom
rhlsthrm:a-chat-you-can-start-again

Conversation

@rhlsthrm

Copy link
Copy Markdown

What this changes

Two halves of one missing capability: the browser could never leave a thread behind.

It never asked whether the thread still existed. useBotThread mints a thread id once and remembers it in localStorage for that Bot forever (app/src/lib/copilot/bot-thread.ts). Nothing ever checked it against Intelligence. Where the platform no longer had the thread, three things happened at once and none of them said so:

  • the transcript loaded empty, because the vendored runtime's handleGetThreadMessages catches Intelligence's 404 and returns a bare 500 {"error":"Failed to fetch thread messages"} — indistinguishable from a network blip (@copilotkit/runtime/dist/v2/runtime/handlers/intelligence/threads.mjs:173-190; its siblings annotate.mjs:60-67 and memories.mjs:40-44 already forward a platform 4xx verbatim, so this is an asymmetry inside the package rather than a rule);
  • every later message silently recreated an empty upstream thread under the same id, because getOrCreateThread swallows the 404 and calls createThread with the id it was given (.../intelligence-platform/client.mjs:449-476);
  • so the Bot answered as though the conversation were new, with the only evidence in the server log.

Observed on a running deployment: three THREAD_NOT_FOUND 404s for one thread, then a follow-up in it answered "Got it! How can I assist you today?" over an empty transcript.

OpenBot now asks the question itself. GET /api/threads/:threadId answers {known: boolean}, via a reader that duck-types the platform's 404 and rethrows everything else — which is the whole point of the shape: a thread the platform provably does not have can be replaced without losing a conversation, while a check that failed for any other reason must not cost somebody theirs. So a remembered thread that comes back known:false is replaced silently, and a 502 keeps the thread and says on screen that earlier messages could not be loaded.

And the button. The same minting path is what a New chat control needs, and there was none: clearing localStorage by hand was the only way to start a fresh conversation. The key on the packaged chat now carries the thread as well as the agent, because the vendored component's own startNewThread/setActiveThreadId are no-ops while threadId is a controlled prop (@copilotkit/react-core/dist/copilotkit-C4RqjAba.mjs:226-254) — without that the id changes underneath a transcript that stays on screen.

PlatformRequestError is not exported from @copilotkit/runtime/v2 (dist/v2/index.mjs:18), so the reader checks error?.status === 404 structurally rather than with instanceof. Nothing under node_modules is touched.

Where it runs

  • New state that outlives a request? None. The route is a read-through to Intelligence, which is the system of record for a thread. The only durable state remains the id in that browser's localStorage, as before.
  • What happens on the second replica? Identical. Every replica asks Intelligence the same question and gets the same answer; nothing is cached in process, and two replicas answering two checks for one browser cannot disagree except by the platform genuinely changing underneath them.
  • Anything serialised? Nothing new. Two tabs starting a new chat at once each mint their own id and the last write to localStorage wins, which is what a person doing that means. Minting is guarded per hook instance so one tab cannot fire two mints at once.
  • Anything fanned out to a browser? No. The chat surface reads a status the browser asked for.
  • New listener, port, or schedule? None. One GET on the existing /api/threads router, behind the same requireUser as POST /mint, and only registered when a reader is configured.

Boundary and audit

  • Every acting call still goes through the gateway. Untouched — this is thread identity, not a Bot action.
  • New refusals and new failures each write a row. No new class of Bot action, so no new audit event. The route logs a typed line with no error object and no upstream host on a failed check.
  • Nothing new is trusted from the client. The user id comes from requireUser's actor, never from the path or a query parameter — there is a test asserting exactly that, because "check this thread as that user" would otherwise be a request anybody could make.

Changelog

  • Two lines under Unreleased: the button under Added, the silent-amnesia fix under Fixed.

Proof

bun run typecheck clean across all workspaces. bun run test: 848 pass, 0 fail (20 new tests across three files). bun run format:check and bun run lint clean.

New tests: the route answering {known:true} / {known:false} / 502-without-leaking-the-thrown-message, the route being absent with no reader configured while POST /mint still answers (an unconfigured deployment loses nothing), the reader receiving the authenticated actor's id, createThreadReader's 404-vs-rethrow behaviour, and threadToUse's asymmetry — including known: undefined keeping the thread, which is the case that must never regress into discarding a conversation over a failed check.

Live, against a running deployment:

$ curl -s localhost:3001/api/threads/<minted-but-never-used>   ->  {"known":false}
$ curl -s localhost:3001/api/threads/<thread-with-history>     ->  {"known":true}
$ curl -s -o /dev/null -w '%{http_code}' .../api/threads/not-a-thread   ->  400
  • Recovery: planted a known-unknown id in localStorage, reloaded — the app swapped to a fresh thread with no banner and no empty-transcript chat. Silent is right here: there was provably nothing to lose.
  • Failed check: restarted the API with a deliberately bogus INTELLIGENCE_API_KEY so the reader threw a 401 rather than a 404. Route answered 502 {"error":"Could not check thread status."}, the thread was kept, and the banner read "Earlier messages in this conversation could not be loaded, and the Bot is answering without them." It sat above the existing stopped-turn banner, both visible, neither nested.
  • New chat: seeded a conversation (a takeover request plus a BANANA turn), pressed the button — thread id went …8fa7……8dc4…, the transcript emptied, and the composer stayed usable. Screenshots in the thread this PR came from.

Two halves of the same missing capability: the browser could never leave a
thread behind.

It remembered one thread id per Bot, forever, and never asked whether
Intelligence still had that thread. Where it did not — and the platform
does forget, or a deployment is renamed, or an id was minted against a
run that never landed — the transcript loaded empty, every later message
silently recreated an empty thread under the same id, and the Bot
answered as though the conversation were new. The reason existed only in
the server log: the vendored runtime flattens the platform's 404 into a
500 before the browser sees it, so nothing on screen could tell a person
their Bot had amnesia.

So OpenBot now asks the question itself. GET /api/threads/:id answers
{known:boolean} through a reader that duck-types the platform's 404 and
rethrows everything else, which is the whole point: a thread the platform
provably does not have can be replaced without losing a conversation,
while a check that failed for any other reason must not cost somebody
theirs. Absent the reader the route is not registered and the browser
behaves exactly as before.

And the other half, which the same minting path buys: a New chat button.
The key on the packaged chat now carries the thread as well as the agent,
because the vendored component's own startNewThread is a no-op while
threadId is a controlled prop, and without that the id changes underneath
a transcript that stays on screen.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant