A chat you can start again - #81
Open
rhlsthrm wants to merge 1 commit into
Open
Conversation
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.
rhlsthrm
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 21, 2026 10:06
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
useBotThreadmints a thread id once and remembers it inlocalStoragefor 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:handleGetThreadMessagescatches Intelligence's 404 and returns a bare500 {"error":"Failed to fetch thread messages"}— indistinguishable from a network blip (@copilotkit/runtime/dist/v2/runtime/handlers/intelligence/threads.mjs:173-190; its siblingsannotate.mjs:60-67andmemories.mjs:40-44already forward a platform 4xx verbatim, so this is an asymmetry inside the package rather than a rule);getOrCreateThreadswallows the 404 and callscreateThreadwith the id it was given (.../intelligence-platform/client.mjs:449-476);Observed on a running deployment: three
THREAD_NOT_FOUND404s 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/:threadIdanswers{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 backknown:falseis 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
localStorageby hand was the only way to start a fresh conversation. Thekeyon the packaged chat now carries the thread as well as the agent, because the vendored component's ownstartNewThread/setActiveThreadIdare no-ops whilethreadIdis a controlled prop (@copilotkit/react-core/dist/copilotkit-C4RqjAba.mjs:226-254) — without that the id changes underneath a transcript that stays on screen.PlatformRequestErroris not exported from@copilotkit/runtime/v2(dist/v2/index.mjs:18), so the reader checkserror?.status === 404structurally rather than withinstanceof. Nothing undernode_modulesis touched.Where it runs
localStorage, as before.localStoragewins, which is what a person doing that means. Minting is guarded per hook instance so one tab cannot fire two mints at once./api/threadsrouter, behind the samerequireUserasPOST /mint, and only registered when a reader is configured.Boundary and audit
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
Unreleased: the button underAdded, the silent-amnesia fix underFixed.Proof
bun run typecheckclean across all workspaces.bun run test: 848 pass, 0 fail (20 new tests across three files).bun run format:checkandbun run lintclean.New tests: the route answering
{known:true}/{known:false}/ 502-without-leaking-the-thrown-message, the route being absent with no reader configured whilePOST /mintstill answers (an unconfigured deployment loses nothing), the reader receiving the authenticated actor's id,createThreadReader's 404-vs-rethrow behaviour, andthreadToUse's asymmetry — includingknown: undefinedkeeping the thread, which is the case that must never regress into discarding a conversation over a failed check.Live, against a running deployment:
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.INTELLIGENCE_API_KEYso the reader threw a 401 rather than a 404. Route answered502 {"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.BANANAturn), pressed the button — thread id went…8fa7…→…8dc4…, the transcript emptied, and the composer stayed usable. Screenshots in the thread this PR came from.