Repository navigation
Conversation
… message
The contract for the questions round, with nothing asking yet: the prompt
is unchanged, so the model has no reason to send questions.
- QuestionsSchema in prompt.ts: { questions: { topics, whatOptions } },
strict at both levels. topics are one to three of what, when and
how_many, each once, always with what; whatOptions are two to four
distinct options of up to 40 characters. MagicComposeDraftSchema takes
it as a third arm; a refusal still wins, and the draft arm refuses a
questions key so a reply mixing the two fails. FullDraftSchema, which
the MCP create_signup tool uses, is unchanged.
- src/lib/magic-compose/questions.ts: the shared types (FollowUp,
WhenAnswer, MagicComposeQuestions) and the topic list, without zod, so
the page can import them; limits.ts holds the numbers.
- src/lib/magic-compose/follow-up.ts: FollowUpSchema checks the
organizer's answers (a real date, times in order, whole counts lined up
with the items, no repeated items, placeholders with no answers), and
buildFollowUpMessages puts the description and the answers under fixed
labels in one user message, the organizer's words JSON-quoted.
- The route answers a questions reply with 200 { questions } and no
signup, logged at info with the topics and how many options.
Part of #570.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
A follow-up to Magic Compose's questions may need a second call to the model, and the organizer should wait for both together no longer than LLM_TIMEOUT_MS. generateDraft takes an optional timeoutMs for that: the second call gets what the first left. It can only shorten the limit, never lengthen it. The limit has to live in the client rather than in a shorter signal from the caller: the client reports a caller's signal as "aborted" (the organizer left), which is the wrong message for running out of time. Its timeout message now names the limit that ran out. Part of #570. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
POST /api/signups/magic-compose now takes the answers to a questions reply as followUp, beside the description. They are checked before the hour's quota is charged: a date that isn't a real day, times out of order or a count that isn't a whole number comes back as invalid_input against followUp, and costs nothing. The model then gets the description and the answers in one message, and the reply must be a draft or a refusal. A model that asks again gets one firmer retry, without another charge. If it still asks, the organizer reads "Couldn't draft from these answers. Add a little more, or draft with placeholders." (after Draft with placeholders, "Couldn't draft this. Try again, or fill it in by hand."), with details.reason asked_again, so the page can keep their answers on screen. Both calls share one LLM_TIMEOUT_MS budget, so a retry never doubles the wait. With under 15 seconds left, or the organizer already gone, the retry is skipped and the answer is asked_again. The log lines give the description's length, never the answers. Nothing asks yet: the prompt is unchanged. The new route tests stub the model and run against the real quota and database. Part of #570. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
The route only noticed the model asking again when the reply passed QuestionsSchema. After the answers, a model that asks again most likely asks about less than the first time (only "when", with no options), which the schema rejects, so the organizer got "AI draft did not match expected shape" with no retry. A reply with a questions key now counts as asking again, whatever its shape; a refusal beside it is still a refusal. The retry's 15-second floor is now at most half of LLM_TIMEOUT_MS, so an instance with a short timeout still gets its one retry. The privacy page says the answers go to the AI provider too, since the route sends them from this change on. Tests: a partial and a mixed questions reply are retried; a retry that asks again in another shape ends in asked_again; a retry that times out reads as a timeout; the floor, with the clock moved on during the first call. Part of #570. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
This was referenced Oct 24, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
Second review of this PR: - Models in json_object mode often send null for keys they don't use; the draft schema already accepts that for choices and groupBy. A complete draft with "questions": null failed the draft arm (which refuses a questions key) and reached the organizer as "AI draft did not match expected shape", a draft that worked before this change. MagicComposeDraftSchema now drops null top-level keys before it reads the reply, so that draft is a draft and questions with "refusalReason": null are questions. - asksQuestions moves into prompt.ts beside the schema and reads the reply the same way, so a draft with "questions": null is no longer retried as if the model asked again. - tidyText, in the shared questions.ts, is the one whitespace tidy-up: the answers' schema uses it, and so do the model's options, so an option with a doubled space and the organizer's own item typed with one can't pass the page's duplicate check and fail the server's. Part of #570. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
Brings in #525 and #526. The route keeps #525's magic_compose.failed row on every failed path, and adds asked_again to its codes: the model asked again after the answers, even after the retry (aborted when the organizer had left). A first call the model answers with questions is an answer, not a failure, and records none. #525's route test stubs the env, so its stub now carries the AI's time budget the route reads; it gains a case for each of those two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
This was referenced Oct 24, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
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.
Part of #570, the second of five stacked PRs. It's based on #572 (friendly limit messages). It's dormant on its own: the prompt doesn't ask questions until #577, so nothing an organizer sees changes.
What it adds
Magic Compose can now answer a description with a short list of what's missing instead of a draft. The organizer's answers can come back in a second request. That second request must end in a draft or a refusal, never more questions.
The model's questions reply
{ "questions": { "topics": ["what", "when", "how_many"], "whatOptions": ["Bake something to sell", "Staff the table"] } }QuestionsSchemainprompt.tsis.strict()at both levels.topics: one to three ofwhat,whenandhow_many, each once, always withwhat.whatOptions: two to four distinct options of up to 40 characters, tidied the way the answers are.MagicComposeDraftSchema.questionskey, so a reply that mixes questions with half a draft fails."questions": nullis still a draft.FullDraftSchema, which the MCPcreate_signuptool uses, is unchanged.200 { data: { questions } }, with no signup and no links. It's logged at info with the topics and the number of options, never the options.The second request
POST /api/signups/magic-composetakes an optionalfollowUp, one key per topic that was asked:Checks.
FollowUpSchema(src/lib/magic-compose/follow-up.ts) requires:placeholders.Text goes through
tidyText(shared with the page). A badfollowUpisinvalid_inputonfield: 'followUp', before the quota is charged.The message.
buildFollowUpMessagesputs the description and the answers under fixed labels in one user message, with the organizer's own words JSON-quoted:A skipped topic reads "not given, use a TBD: placeholder". A topic that wasn't asked has no line.
If the model asks again. The route retries once with a firmer last line and no extra quota.
invalid_inputwithdetails.reason: 'asked_again'and "Couldn't draft from these answers. Add a little more, or draft with placeholders." After Draft with placeholders, the message is "Couldn't draft this. Try again, or fill it in by hand."asksQuestionsinprompt.ts). After the answers, a model most likely asks about less than the first time.Time. The whole request gets one
LLM_TIMEOUT_MS.generateDrafttakes an optionaltimeoutMs, so the retry gets what's left, and running out is still atimeout. With under 15 seconds left (or half the budget, if that's less), or the organizer gone, it skips the retry. That keeps a follow-up inside the browser's 210-second watchdog.Record link copies, failed AI drafts and refused publishes #525's failure telemetry. Every failed path still records
magic_compose.failed, andasked_againjoins its codes (abortedwhen the organizer had left). A first call answered with questions isn't a failure and records none.docs/telemetry.mdlists the new code.Privacy. The privacy page now says any answers go to the AI provider too.
Where this differs from the issue's wording
whatarrives aspickedandaddedrather than oneitemslist. Record when Magic Compose asks questions, and how they were answered #575 records how many of each without the browser sending the questions back.howManyis an array aligned with the items, not an object keyed by the organizer's text.placeholders: truemarks Draft with placeholders.Tests
prompt.test.ts:what, a repeated topic, one or five options, a reply mixing questions and draft keys, and a reply with none of the three);asksQuestions.follow-up.test.ts: every check above, and the message word for word.llm-client.test.ts: a shortertimeoutMstimes out astimeoutand can't lengthenLLM_TIMEOUT_MS.route.questions.db.test.ts, with the real route, quota and database and a stubbed model:asked_againafter exactly one retry and one quota charge;"questions": nulldrafts in one call;route.failed.test.ts(Record link copies, failed AI drafts and refused publishes #525's) gainsasked_again, and "no failure for a first-call questions reply".pnpm lint && pnpm typecheck && pnpm knip && pnpm test(1423) andpnpm test:db(555) pass.Tophat
This branch on its own, against a local fake OpenAI-compatible endpoint, through the API:
asked_again;On this branch alone, the existing page shows "AI drafting failed (HTTP 200)" if a model ever answers with questions. That's expected: the screen comes in #576, and this branch's prompt never asks.
Reviews
/code-review), fixed in a83a270:"questions": nullfailed (and was retried as a re-ask);main(with #525 and #526) is merged in.🤖 Generated with Claude Code
https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks