feat(features): inbox wire v5 adds asks and answers on messages

Merged
DA
6d ago
+3462-62

Step 2 of 7 of the inbox threads rollout: adds the vocabulary for an ask on a thread message and the answer to it, in packages/features. All of it is additive. Nothing a desktop or the service reads today changes shape.

What changed

Chat's ask shapes are now chat's own (chat-wire.ts). Chat used to re-export inboxResponseSchema, inboxReadResponseSchema, inboxReplySchema and inboxReadBlockSchema as its own. It now declares its own copies of the v4 versions: chatResponseSchema, chatReplySchema, chatReadBlockSchema, chatReadResponseSchema, a chat-only unknown-type placeholder, knownResponse/knownBlock, and the ChatResponse/ChatChoice/ChatForm/ChatReply/ChatReadBlock/ChatReadResponse types. Later steps of this rollout therefore can't move chat's shapes. Every chat-wire-* golden is byte-for-byte the same. The leaf pieces chat still shares with the inbox (option, field, form values, stored block) are the ones v5 reuses unchanged.

The ask (inbox-wire.ts):

  • INBOX_ASK (decide, do, answer) and inboxAskSchema:
    • decide: 2–6 inboxOptionSchema options, multiple?, recommended?.
    • do: steps, each Markdown of at most MAX_STEP_CHARS (600), at most MAX_STEPS (8).
    • answer: fields?, 1–8 of the existing inboxFieldSchema. No new recommended values on fields.
  • inboxReadAskSchema: the ask as a message read carries it, with status from INBOX_ASK_STATUS (open, answered, replaced, withdrawn) and withdrawnReason?. An ask type this build doesn't know reads as { type, status } alone, the same way unknownTypeSchema handles blocks and responses. unknownTypeSchema is now built by a small unknownTypeOf(known) so asks have their own set of known types.

A do ask may have 0 steps. A v4 to-do named no steps: the item's blocks say what to do. So task maps to { type: "do", steps: [] } without inventing a step, and Mark done on it ticks nothing, since "every step ticked" holds for zero steps. Step 6's tool input can still ask the agent for at least one step if we want that.

The answer. inboxAnswersSchema holds ask (the asking message's id), reply (from INBOX_REPLY), choices?, values?, and steps? (which steps were ticked, as integer positions). The answerer's words and files belong to the message itself. inboxAnswersRefusal(ask, answers, words, files?) is the pure refusal. It reuses choiceRefusal/formRefusal, now typed on the options/multiple and fields they read, plus a shared ownWordsRefusal:

  • decide takes a pick of offered options (chose), or words or files (answered).
  • do takes Mark done only with every step ticked (done), or Couldn't do it (not-done) with any ticked. It refuses a step ticked twice or a tick that names no step.
  • answer with fields takes filled submitted values. Without fields it takes answered with words or files.

Messages carry them. inboxMessageSchema gains optional ask and answers. The body stays text on the wire, because older readers require it.

Mappings, as pure functions:

  • inboxAskResponse: an ask to the nearest v4 response. decide → choice, do → task, answer with fields → form, without → text.
  • inboxResponseAsk: a stored v4 response to an ask. choice → decide, approval → decide with INBOX_APPROVAL_OPTION Approve/Decline, task → do with no steps, form/text → answer, none → no ask.
  • inboxReplyAnswers: an older desktop's InboxReply to the answer of the open ask. Approved/Declined become a pick of Approve/Decline. Done ticks every step, because an older desktop shows none to tick. The note becomes the message's words. Acknowledged answers no ask.

INBOX_WIRE_VERSION = 5, with the doc comment and the module header saying what v5 adds.

Golden diff (LUKE_UPDATE_FIXTURES=1 through hosted-wire-schemas.test.ts)

  • New: inbox-wire-inboxAskSchema.json, inbox-wire-inboxReadAskSchema.json, inbox-wire-inboxAnswersSchema.json.
  • Grown by the same 274 added lines, none removed: the two new optional message properties ask and answers, in every schema that embeds inboxMessageSchema. That is inbox-wire-inboxMessageSchema, -inboxItemSchema, -inboxAnswerSchema, -inboxAnsweredSchema, -inboxMessagedSchema, and coding-agent-view-codingAgentInbox{Answer,Answered,Messaged}ViewSchema.
  • Unchanged: every chat-wire-* golden.

Tests

inbox-asks.test.ts tests through the module's public exports:

  • decoding a message with an ask in each status, with an answer, and with neither;
  • a known ask that breaks its own shape being refused;
  • an unknown ask type reading as the placeholder;
  • an older reader decoding a v5 item. The test writes out the v4 message struct by hand and reads through readServiceAnswer: the item reads whole and its messages drop the new keys;
  • every mapping in both directions;
  • every refusal message.

Held back for later steps

  • knownAsk (a predicate on a read ask) and exported InboxAsk/InboxAnswers/InboxReadAsk types: nothing here uses them yet, and knip would refuse them.
  • The new post_to_inbox input schema and withdraw_ask (step 6).
  • Tables (step 3), the store (step 4), answering by ask (step 5), the panel (step 7).

Checks

pnpm check:affected: lint, knip, discarded-effect, tests and build pass. In the 8 GB sandbox the combined run gets killed for memory: parallel tsc exits 137 and the web build hits the heap limit. So I ran each dependent workspace's typecheck one at a time (service, database, panel, ui, host, web, dev, luke, desktop, all clean) and the web build with a larger heap (clean).

Made with Luke


Devin Review