feat(question): save partial answers to a multi-step question

Merged
KE
1mo ago
+394-13

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/progress stores the answers given so far.
  • Read: GET /question returns them as progress.
  • 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

  1. Shape: one entry per step, in question order, like reply's answers. An empty entry means that step isn't answered yet.
  2. Saving never answers the question. It publishes no event and doesn't touch the waiting tool call. Only reply or reject resolve a question.
  3. Already answered? The save matches no row and returns 404 QuestionNotFoundError, the same as reply. It uses the same project check as claim, now shared as inProject.
  4. SDK regenerated, so typed clients see the route. The fork's generate job 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 /question still 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

  1. You review and merge. I won't merge it.
  2. Cut v1.18.32-2.
  3. Bump the two andytown pins.

I'll ask before steps 2 and 3.

Testing Done

  • Local: bun typecheck 30/30 · question tests 17/17 · test:httpapi 211/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 true with the answers listed back · 400 for a malformed body · 404 for an unknown request. Changing the true on 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/progress to store the answers given so far on the existing question_request row, and GET /question returns them as progress.
  • Progress lives in the row's existing JSON data column, so no migration is needed.
  • Saving progress never resolves the question or publishes an event; a question resolves only through reply or reject, and saving after that fails with QuestionNotFoundError.

Compatibility

  • Payloads are unchanged until something saves progress; a fresh request carries no progress key.
  • After a rollback, the old schema strips the unknown progress key, so GET /question keeps working.
  • Old binaries lack the route and answer 200 text/html, so clients must count a save only when the response is JSON true.

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.