Skip to content

Continue in chat from a search-page answer - #296

Merged
adamjohnwright merged 1 commit into
mainfrom
013-search-handoff
Sep 25, 2026
Merged

adamjohnwright merged 1 commit into
mainfrom
013-search-handoff

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

Spec 013, Story 2. The search page's AI answer can now be continued in a chat tab, the way analysis summaries can since #294.

Each answer gets its own ID

An answer can't be keyed by its question: two readers of one search get different answers (~0.33 similarity run to run), so a question-keyed cache would let one reader continue another's. Instead /api/answer keeps each answered stream under a fresh answer_id, present in done only when state is answered, for an hour.

event: done
data: {"state": "answered", "seconds": 8.4, "answer_id": "Ev3z3JDmIIUF4gkKTfrn9VF83H"}

What is kept is exactly what the page was sent — the text after anchor and sources stripping, and the citations — not the raw model output. The answer contract documents the field.

The handoff request

{"kind": "analysis", "token": ..., "disclosure": ..., "caller_token": ...}
{"kind": "search",   "answer_id": ...,                "caller_token": ...}

Discriminated by kind: a search request carrying analysis fields is rejected (422) rather than read as one.

No human-presence claim for a search handoff. /api/answer, which produced the answer, deliberately doesn't require one — it returns public pathway text — so the search page may have none to send. The analysis handoff still requires it, because it releases a reader's own analysis. The rule is pinned in both directions: requiring presence for search fails one test; dropping it for analysis fails two.

The handoff record is two types, AnalysisHandoff and SearchHandoff, rather than one with optional fields, so a search handoff can't carry a disclosure tier that means nothing for it. mypy then found every place that had assumed a handoff was an analysis.

Verified through the real gate, as a first-time visitor, over HTTPS

real answer from /api/answer 1,338 chars, answer_id in done
handoff minted 200, with no human claim
tab, through Turnstile opens on the reader's question and the same answer
follow-up: "which protein kinase were we just discussing?" "CDK5 (Cyclin-Dependent Kinase 5) is…"
control: same question, no handoff "I cannot answer your question because it is ambiguous"

Sabotage: storing anything other than the streamed text fails test_what_is_kept_is_exactly_what_the_page_was_sent. Two of the presence sabotages first came back void — ruff had reformatted the conditional, so the anchor matched nothing, and one printed "applied" over an unchanged file. Both were redone with the change asserted.

🤖 Generated with Claude Code

Spec 013, Story 2. The search page's AI answer can now be continued in a
chat tab, the way analysis summaries can since #294.

**Each answer gets its own ID, emitted in `done`.** It cannot be keyed by
question: two readers of one search get different answers (~0.33
similarity run to run), and a question-keyed cache would let one continue
the other's. `/api/answer` keeps each answered stream under a fresh
`answer_id`, present in `done` only when `state` is `answered`, for an hour.
What is kept is exactly what the page was sent -- the text after anchor and
sources stripping, and the citations -- not the raw model output.

`POST /api/handoff` now takes a discriminated request:
`{"kind": "analysis", "token", "disclosure"}` or
`{"kind": "search", "answer_id"}`. A search request carrying analysis fields
is rejected rather than read as one.

**No human-presence claim for a search handoff.** `/api/answer`, which
produced the answer, deliberately does not require it -- public pathway
text -- so the search page may have none to send. The analysis handoff
still requires it, because it releases a reader's own analysis. Pinned in
both directions: requiring presence for search fails one test, dropping it
for analysis fails two.

The handoff record is two types, `AnalysisHandoff` and `SearchHandoff`,
rather than one with optional fields, so a search handoff cannot carry a
disclosure tier that means nothing for it. mypy then found every place that
had assumed a handoff was an analysis.

The chat opens on the reader's own question as the human turn and the
answer, with its cited sources, as the model's.

Verified end to end through the real Turnstile gate as a first-time
visitor, over HTTPS: a real answer from the real endpoint carried an
answer_id; a handoff was minted with no human claim; the tab opened on the
question and the same answer; "which protein kinase were we just
discussing?" was answered "CDK5". The control -- the same question with no
handoff -- could not say.

Sabotage: storing anything other than the streamed text fails
`test_what_is_kept_is_exactly_what_the_page_was_sent`. Two of the presence
sabotages first came back void -- ruff had reformatted the conditional, so
the anchor matched nothing -- and one printed "applied" over an unchanged
file; both were redone with the change asserted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 4ef0f5c into main Sep 25, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the 013-search-handoff branch September 25, 2026 17:30
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