feat(question): save partial answers to a multi-step question
People can now answer part of a multi-step question and finish it later, on any screen, even after a reload. The sandbox keeps the half-finished answers with the question itself. This is step 1 of auto-answering: when a question times out, only the steps nobody answered should get filled in.
Context: Slack thread · re-opens #53, which was merged before review and reverted in #54 · used by andytown #28112 (auto-answering) and #27021 (question state)
TL;DR
- Save: new
PUT /question/:requestID/progressstores the answers given so far. - Read:
GET /questionreturns them asprogress. - No migration: they live in the question row's existing JSON column.
- Safe with old sandboxes: clients count a save only when the response is JSON
true.
Before
Each screen keeps half-finished answers in its own memory: the web card in the browser, Slack in the message, Messages in its delivery store.
flowchart TD
A["Person answers step 1 of 3"] --> B["Kept only in that screen's memory"]
B --> C["Reload, or switch from Slack to the web"]
C --> D["Starts over at step 1"]
Now
The answers are saved with the question, so every screen sees the same progress.
flowchart TD
A["Person answers step 1 of 3"] --> B["Client saves it: PUT /question/:id/progress"]
B --> C[("Question row in the sandbox database")]
C --> D["Any screen reads it: GET /question"]
D --> E["Resumes at step 2"]
How it works
- Shape: one entry per step, in question order, like
reply's answers. An empty entry means that step isn't answered yet. - Saving never answers the question. It publishes no event and doesn't touch the waiting tool call. Only
replyorrejectresolve a question. - Already answered? The save matches no row and returns 404
QuestionNotFoundError, the same asreply. It uses the same project check asclaim, now shared asinProject. - SDK regenerated, so typed clients see the route. The fork's
generatejob fails on every merge, so this also picks up one leftover line from the v1.18.32 merge:promptCacheKey.
Works with old sandboxes
Sandboxes get a new binary only when they respawn, so old and new binaries run side by side for days.
- New binary, today's andytown: nothing changes until something saves.
- Rolled back after a save: the old binary drops the unknown key, and
GET /questionstill works. - Old binary, new andytown: the route doesn't exist, and the web app's catch-all answers
200 text/html.
Client rule: count the save only on JSON true. A JSON QuestionNotFoundError means already answered. Anything else means an old sandbox, so keep the local copy.
Review calls from cubic
- The last save wins. Each save sends the whole answer list. A transaction wouldn't change that. A per-step merge would, but then a step could never be cleared.
- No length check, the same as
reply. Readers match answers to steps by position, so a short list just leaves later steps unanswered.
Not in this PR
Auto-answering itself: the per-session delay, a recommended answer on each question, and marking which answers were picked for the person. It's built and parked on claude/question-auto-answer-timer.
Next
- You review and merge. I won't merge it.
- Cut
v1.18.32-2. - Bump the two andytown pins.
I'll ask before steps 2 and 3.
Testing Done
- Local:
bun typecheck30/30 · question tests 17/17 ·test:httpapi211/211 in all three modes, 0 missing. - CI: all green. The route-harness step stalled once with no output, and a re-run of the same commit passed.
- Route tests: 200
truewith the answers listed back · 400 for a malformed body · 404 for an unknown request. Changing thetrueon purpose fails the test. - Unit tests: answers are listed · a fresh request has no
progress· progress survives an opencode restart · saving after the reply fails.
🤖 Generated with Claude Code
Summary by cubic
Adds the ability to save partial answers to a multi-step question so a person can finish it later from another client or after a reload. Today each client keeps partial answers in its own memory — the web card, Slack metadata, and Messages delivery state — so a reload or client switch loses them.
How it works
- Adds
PUT /question/:requestID/progressto store the answers given so far on the existingquestion_requestrow, andGET /questionreturns them asprogress. - Progress lives in the row's existing JSON
datacolumn, so no migration is needed. - Saving progress never resolves the question or publishes an event; a question resolves only through
replyorreject, and saving after that fails withQuestionNotFoundError.
Compatibility
- Payloads are unchanged until something saves progress; a fresh request carries no
progresskey. - After a rollback, the old schema strips the unknown
progresskey, soGET /questionkeeps working. - Old binaries lack the route and answer
200 text/html, so clients must count a save only when the response is JSONtrue.
New tests cover a save through the HTTP route (asserting the true response and reading progress back via the service) and persistence across an instance reload. The typed SDK and OpenAPI spec are regenerated for the new route.
Auto-answering (the per-session delay, recommended answers, and marking which answers were chosen) is not part of this PR and follows separately.