Skip to content

Continue in chat: hand an analysis summary into a new chat tab - #294

Merged
adamjohnwright merged 1 commit into
mainfrom
013-continue-chat-hand
Sep 25, 2026
Merged

adamjohnwright merged 1 commit into
mainfrom
013-continue-chat-hand

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

Spec 013, Story 1. Beside an analysis summary on the website, "Continue in chat" opens the chat in a new tab with that summary already the thread's previous turn — so the reader's first message can be a follow-up.

How the handoff travels

website  ──POST /api/handoff──▶  chatbot  →  {"id": "...", "path": "/chat/guest/#handoff=<id>", "expires_in": 900}
website  opens  path  in a new tab  (target="_blank", rel="noopener noreferrer")
tab      custom.js reads #handoff, postMessage → Chainlit → @cl.on_window_message → thread seeded
  • The ID is in the URL fragment. Browsers never send a fragment to a server, so it never reaches nginx logs or a Referer.
  • It is bound to the tab. Chainlit 2.11 gives the app no page URL on connect, only cookies, and a cookie is shared by every tab, so two handoffs opened together could seed the wrong one. The claim instead travels over the tab's own socket. Verified: two tabs opened together in one browser each claimed only their own ID.
  • The retry is load-bearing — measured, not assumed. A single post in the first second is lost 15/15, because Chainlit drops window messages until its socket is up. From two seconds on it is claimed 6/6. Posting once on load would never have worked. The script retries every 500 ms for up to 20 s, and redemption is idempotent per session because a claim can arrive twice.

What the chat receives

The summary the reader saw, not a new one (FR-002). Generation isn't reproducible here (~0.33 similarity run to run), so the handoff copies the text when it is minted. It can only be minted for a summary already in the cache at the requested tier. That one lookup is also how it refuses to widen disclosure (FR-003): an identifiers-tier handoff exists only if the reader chose identifiers.

The data, not just the words. The seeded turn has the shape of every real turn, [HumanMessage, AIMessage]. The AI turn carries the summary plus the allow-listed analysis data, rebuilt at the handoff's tier with the same functions the summary endpoint uses.

Verified in a browser, against a real analysis on beta

summary from the real endpoint 1,418 chars, real model call
handoff minted 200; identifiers tier refused with 404 (reader chose aggregate)
tab opens on the same text the endpoint produced
follow-up: "which pathway has the lowest FDR?" names the analysis's real top pathway, with its exact FDR (4.2e-15)
control: same question, no handoff "The user guide does not currently cover…" — cannot answer
unknown ID says the link may have expired, rather than opening empty

The control is what makes the follow-up check mean something: without the handoff, the chat has no way to know that pathway or that number.

Adversarial review

  • A promise the architecture could not keep. The first endpoint also returned a personal_path. The store is in memory, and the guest and logged-in chats are separate processes, so that link could only ever open on "couldn't load the summary". It now returns one path, for the chat served by the process that minted it. The spec records what Story 3 (logged-in) needs: a store both processes share.
  • current_release() and fetch_not_found() can both return None, which the first version assumed they could not. mypy caught it; both are now handled explicitly.
  • A claim arriving before on_chat_start would have seeded a thread named "None"; it falls back to the session id. Seeding failures now tell the reader instead of leaving a chat that silently lacks the context, and claims are logged either side.

Sabotage: fetching identifiers regardless of tier fails the aggregate test; looking the summary up at aggregate whatever tier was asked fails the cannot-widen test.

Not in this PR

  • Story 2, the search-page answer. It needs a store first, because /api/answer keeps nothing.
  • Story 3, the logged-in chat. It needs a shared store (above), and can only be verified in production.
  • The website buttons. That is WebsiteAngular's side; the contract is above.

🤖 Generated with Claude Code

Spec 013, Story 1. Beside an analysis summary on the website, "Continue in
chat" opens the chat in a new tab with that summary already the thread's
previous turn, so the reader's first message can be a follow-up.

**The handoff travels in the URL fragment and is bound to the tab.** The
website mints an ID with `POST /api/handoff` and opens
`/chat/guest/#handoff=<id>`. Browsers never send a fragment to a server, so
the ID stays out of nginx logs and Referer headers. Chainlit 2.11 gives the
app no page URL on connect -- only cookies -- and a cookie is shared by every
tab, so two handoffs opened together could seed the wrong one. Instead a
block in `public/custom.js` reads the fragment and posts it; Chainlit
forwards every window message to `@cl.on_window_message` over that tab's own
socket.

**The retry is load-bearing, measured.** A single post in the first second
after load is lost 15 times out of 15, because Chainlit drops window
messages until its socket is up; from two seconds on it is claimed 6 of 6.
Posting once on load -- the obvious implementation -- would never have
worked. The script retries every 500 ms for up to 20 s and stops on its own
acknowledgement, and since a claim can therefore arrive twice, redemption is
idempotent per session.

**The chat continues the summary the reader saw; it does not regenerate
one** (FR-002). Generation here is not reproducible (~0.33 similarity run to
run), so a handoff copies the summary text when it is minted. It can only be
minted for a summary that already exists in the summary cache at the
requested tier -- which is also how it refuses to widen disclosure (FR-003):
an identifiers-tier handoff exists only if the reader chose identifiers on
the website.

**The model gets the data, not just the words.** The seeded turn is
`[HumanMessage, AIMessage]`, the shape of every real turn, and the AI turn
carries the summary plus the allow-listed analysis data rebuilt at the
handoff's tier with the same functions the summary endpoint uses. Recorded as
having ended at `postprocess`, the last node of all three profiles, so the
next message is an ordinary turn with that history.

Verified end to end in a browser against a real analysis on beta: the tab
opens on the same text the summary endpoint produced; asked which pathway
has the lowest FDR, the chat names the analysis's real top pathway with its
exact FDR (4.2e-15). The control -- the same question with no handoff --
answers that the user guide does not cover it. An unknown ID says so rather
than opening an empty chat.

Adversarial review found:

- **A promise the architecture could not keep.** The first endpoint also
  returned a `personal_path`. The store is in memory and the guest and
  logged-in chats are separate processes, so that link could only ever open
  on "couldn't load the summary". It now returns one `path`, for the chat
  served by the process that minted it; the spec records what Story 3 needs.
- `current_release()` and `fetch_not_found()` can both return None, which
  the first version assumed they could not -- found by mypy, handled
  explicitly.
- A claim arriving before `on_chat_start` would have seeded a thread named
  "None"; it falls back to the session id. Seeding failures now say so to the
  reader instead of leaving a chat that silently lacks the context, and
  claims are logged either side.

Sabotage: fetching identifiers regardless of tier fails the aggregate test;
looking the summary up at `aggregate` whatever was asked fails the
cannot-widen test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 6308bfc into main Sep 25, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the 013-continue-chat-hand branch September 25, 2026 16:22
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