feat(service): the inbox store keeps threads of messages with asks
Step 4 of 7 of the inbox threads rollout. The store now keeps an Inbox item as a thread of messages, where an agent's message may end with an ask. Everything that reads or writes the inbox today keeps working:
- desktops before v5;
- agents still calling today's
post_to_inbox,close_inbox_itemandreply_inboxfrom the previous deployment; - the answer endpoint, which is keyed by revision.
Built on main, after step 3 (#3978) and #3932, a member closing an item without answering it. Step 5 builds on this branch.
Writes
- A post is the agent's message.
postInboxItem(today's tool) and the newpostInboxThread(for step 6's tool) both go throughkeepPost:- it upserts the item as a view of the ask;
- writes the agent's message with the body and its ask (
status: open); - marks every ask the thread held open
replaced, usingask || jsonb_build_object('status', …)so no other key of the ask is rewritten; - points
open_ask_message_idat the new message, moves the revision, and clears the answer. MAX_OPEN_PER_AGENTstill countsstatus = 'open', which now means an item with an open ask.
- Today's input.
- The body is the blocks joined in order. A link becomes
[label](url)on its own line, a code block becomeshttps://github.com/<repo>/blob/<sha>/<path>#L<a>-L<b>on its own line (the lone-link code card), and an image becomes. - The ask is
inboxResponseAsk(response). - The item keeps the response as posted rather than
inboxAskResponse(ask). That way an approval still draws Approve/Decline and a text keeps its placeholder on older desktops. Posts from step 6 useinboxAskResponse.
- The body is the blocks joined in order. A link becomes
- A message without an ask (
replyInboxItem,postInboxMessage, andpostInboxThreadwith no ask under an existing key) only appends. It movesupdated_at, never the revision. - The revision moves only when the agent asks: a post, or a close. A close now withdraws the open ask, keeping its reason.
- Answers.
answerInboxItem(an older desktop, by revision) settles the open ask and writes a member reply carryinginboxReplyAnswers(…). The reply goes under the answer's key, with the note as its text and the files as its own.- An item from before this step has no ask message. It keeps the answer and its files as its own, as before.
- New:
answerInboxAsk(userId, itemId, { answers, text, clientKey, files })for step 5. It checks the answer withinboxAnswersRefusal, then writes the member message, marks the askanswered, and fillsanswer,answered_byandsettled_at. Its outcome istaken,repeated(same key),not-open, orrefusedwith the reason. - The item's
answeris the v4 view of the answer. Approve/Decline on an approval reads back as approved/declined, and the note is cut toMAX_NOTE_CHARS.
- The revision doesn't move on an answer (agreed with the orchestrator).
withdrawInboxAnswerstill undoes a failed hand-off at the same revision: it deletes the answering message and its files, reopens the ask and points the item back at it.withdrawInboxMessageis unchanged. - Pictures.
- A post's pictures are now
kind=messagefiles of the agent's message, under ids minted in the write. The words draw each one byinboxAttachmentAddress(item, attachmentId, workspace slug)from #3969. postInboxThread(agent, post, pictures)rewrites each sandbox path a picture names outside code. It also shows a line that is a picture alone as an image block on older desktops.- Re-posting no longer deletes the earlier message's pictures, because the thread keeps them. Only an item's own files from before this step are deleted.
- A post's pictures are now
The thread with no ask (agreed with the orchestrator)
A thread with no open ask waits on nobody. This covers today's response: none, and postInboxThread with no ask under a new key. Such an item is written as a settled Note for older desktops:
- status
answered, responsenone,needs_replyfalse; - no answer,
settled_atnow; blocks: [], so the words appear once, as a message.
Badge change: a note no longer counts. Today one counts until someone presses Got it. It doesn't count against the open bound either.
Transition cost: older panels show a faceless "Answered" on such an item. An agent on today's tool reads it back as answered with no answer.
A member's close (#3932, folded in)
- A member's close without an answer withdraws the open ask with no
withdrawnReason, clearsopen_ask_message_id, and moves no revision, as #3932 defines. - No message is written for the close, so
messagesandthreaddon't duplicate the pane's "Closed without answering". - An agent's close always gives a reason, so an ask withdrawn without one is a member's close. While the close stands, the item's
closedBynames the member. This convention is commented onstandingAskfor step 7a. withdrawInboxClose, which undoes a failed hand-off, opens the withdrawn ask again and points the item back at it.
Reads (wire v5)
messageskeeps the v4 thread: the latestMAX_MESSAGES_READmessages that carry neither an ask nor answers. The item's blocks, response and answer stand for those, so an older desktop draws nothing twice. After a re-post, earlier asks and answers drop out ofmessages, as they did in v4. This rule was agreed with the orchestrator.thread, a new optional field oninboxItemSchema, carries every message with itsaskandanswers. That's the latestMAX_MESSAGES_READ, plus the first message and the open-ask message however long the thread runs.- Each ask reads as it stands on the item. A previous deployment's close leaves the ask
openin its JSON, so a pointed ask on a closed item reads aswithdrawnwith the close reason. - Items from before this step have no ask message and read unchanged. Nothing is backfilled.
- Goldens re-recorded under
LUKE_UPDATE_FIXTURES=1:inbox-wire-inboxItemSchemaand the six that embed an item (inboxAnswer,inboxAnswered,inboxMessaged, and the threecoding-agent-viewenvelopes). Each diff only adds the optionalthreadarray ofinboxMessageSchema.
For the steps above
- Step 5: call
answerInboxAsk. The archived-task check stays in the endpoint, as it does forinboxAnswerEndpoint. - Step 6: call
postInboxThread(agent, { key, title, body, ask?, needsReply? }, pictures). Find a body's picture paths outside code, aswithPictureAddressesdoes, read them from the sandbox, and pass each as{ path, name, contentType, bytes }. - Step 7:
- A post's message lists its pictures among its
filesand draws them in its words, so skip the files the words name (inboxAttachmentIdsIn). - An item with no ask message is a pre-v5 item; draw it from the legacy fields.
versionis no marker for this, because step 2's deployment already writes 5. - The fetch route for
…/inbox/<item>/files/<att>(OpenPage) lands with step 7. No reader in this step fetches by that address: older panels draw a picture in a message's words as a link.
- A post's message lists its pictures among its
Writes from the previous deployment
- Its close leaves the ask's JSON
open. The read derives the ask's standing from the item: an ask the item points at on a closed item readswithdrawn. - Its re-post rewrites the item's response but not the thread. An ask held open whose item response no longer stands for it (
askStandsFor) readsreplaced, andanswerInboxAskfinds it not open. An older desktop's answer by the new revision is taken as the item's own, with the stale ask marked replaced. A re-post that keeps the same response still stands for the same ask, so answering it is safe.
Tests
- New,
packages/service/src/agents/coder-host/inbox-threads.test.ts(15 tests, PGlite throughgetTestDB(), picked up bytest:store), one test per rule:- a post appends and replaces;
- a post with no ask is a settled Note that no waiting read counts;
- the revision moves only with the ask;
- an older desktop's answer by revision settles the ask and writes a reply, which
messagesleaves out andthreadcarries, and taking it back reopens the ask; answerInboxAsktakes, repeats, refuses, and finds a replaced ask not open;- step 6's view, including steps listed under the words of a
doask; - a post with no ask follows up or opens a thread;
- pictures, both as image blocks and in the body, under a task and under an agent with no task;
- a cut thread keeps its first message and its open-ask message;
- a legacy row reads unchanged and takes an answer as its own, and a close from the previous deployment reads as withdrawn;
- an ask a re-post from before threads posted over reads replaced and takes no answer;
- an answer under a key another message holds isn't taken;
- a note takes no place among the open items;
- a member's close withdraws the ask without a reason and moves no revision, taking it back reopens the ask, and the agent's own close keeps its reason;
- one user can't read or answer another workspace's thread.
- Updated,
inbox-store.test.ts, "an answer taken back … a post that opens the item again …": a re-post still clearsanswerFiles, but the answering message now keeps its files on the thread. coding-agents-app.test.ts,coder-inbox.test.ts,coder-host.test.ts,workspaces-app.test.ts,automation-runs.test.ts,inbox-attachment-view.test.tsandapps/web/tests/inbox-item-read-migration.test.tspass unchanged.
Checks
pnpm check:affected ran out of memory as one combined run in the 8 GB sandbox (the @luke/web build), so I ran its parts on their own:
- Repository contract checks, lint, knip and discarded-effect: pass.
turbo run typecheck --concurrency=1across all 14 affected workspaces, with a 6 GB heap: pass.@luke/webbuild, on its own with a 6 GB heap: pass.- Tests at
--concurrency=1, all passing:@luke/features: 840.@luke/database: 239.@luke/service: 585, 305 and 337 across the three shards.@luke/host: 362.@luke/panel: 2462.@luke/web: 394.
test:storeon a real Postgres runs in CI's Postgres job; there's no Postgres in the sandbox.
Made with Luke