Continue in chat from a search-page answer - #296
Merged
Merged
Conversation
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>
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.
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/answerkeeps each answered stream under a freshanswer_id, present indoneonly whenstateisanswered, 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. The answer contract documents the field.
The handoff request
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,
AnalysisHandoffandSearchHandoff, 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
/api/answeranswer_idindoneSabotage: 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