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

Merged
KE
1mo ago
+121-13

What: A person can answer some steps of a multi-step question and finish later, from another client (web, Slack, Messages) or after a reload. New PUT /question/:requestID/progress stores the answers given so far on the question's existing question_request row, and GET /question returns them as progress.

Why: Today every client keeps partial answers in its own memory: the web card in an in-memory map, Slack in message metadata, Messages in delivery state. A reload or a switch of client loses them. It is also the prerequisite for auto-answering: when a question times out, only the steps the person has not answered should be filled in for them. Agreed order: rebase first (#52, merged), then these question changes. Slack thread · consumers: andytown #28112 (auto-answering) and #27021 (question state).

How:

  1. progress?: Answer[] on QuestionRequest, positional like reply's answers (an empty array is a step not answered yet). It lives in the row's existing JSON data column, so there is no migration.
  2. Question.saveProgress updates the row, scoped to the instance's project with the same condition claim uses (extracted as inProject). If a reply or reject already claimed the row, the write matches nothing and returns QuestionNotFoundError (404), the same error reply returns.
  3. Saving progress never resolves the question, publishes no event, and does not touch the in-memory waiter. A question still resolves only through reply or reject.

Compatibility across binary versions. Sandboxes pick up a new binary only when they respawn, so old and new binaries run side by side for days:

  • New binary, current andytown: payloads are unchanged. progress appears only after something saves it (the test asserts a fresh request has no progress key).
  • Rolled back to v1.18.32-1 after progress was saved: GET /question keeps working. The old schema strips the unknown key: encoding a row that carries progress with the v1.18.32-1 schema gives [{"id":…,"sessionID":…,"questions":[…]}].
  • Old binary, new andytown: the route does not exist, and the web app catch-all answers 200 text/html. Measured by sending PUT /question/que_x/progress to pristine dev through the production route tree.

So the client contract is: count the save as done only when the response is JSON true. A JSON QuestionNotFoundError means the question was already answered. Anything else, including a 200 HTML page, means the sandbox predates this route, so keep progress on the client exactly as today.

Testing done:

  • Fork CI gates on this branch: bun typecheck 30/30; packages/opencode bun run test 3,657 pass / 0 fail; bun run test:httpapi 210/210 in all three modes, 0 missing (new scenarios: 400 for a malformed body, 404 for an unknown request); core migration gate 18/18.
  • New test: partial answers are listed, a fresh request carries no progress, and saving after the reply fails with NotFoundError.

Not in this PR: auto-answering itself (the per-session delay, a recommended answer on each question, and marking which answers were chosen for the person). It is built and parked on claude/question-auto-answer-timer and will follow as its own PR on top of this one.

After merge: cut v1.18.32-2, then bump the two andytown pins. I will ask before each.

🤖 Generated with Claude Code


Summary by cubic

Saves partial answers to a multi-step question so a person can finish later from any client or after a reload. New PUT /question/:requestID/progress stores the answers so far on the existing question_request row (no migration), and GET /question returns them as progress. This is the prerequisite for auto-answering, which needs to know which steps are already answered.

Saving progress never resolves the question, publishes no event, or touches the in-memory waiter; a row claimed by reply or reject returns QuestionNotFoundError (404), same as reply.

Compatibility

  • Old binaries strip the unknown progress key, so GET /question still works after a rollback.
  • New clients hitting an old binary get a 200 HTML catch-all, so a client must treat only a JSON true response as saved.
  • Rollout: cut v1.18.32-2, then bump the two andytown pins (asking before each).