feat(service): the inbox store keeps threads of messages with asks

Merged
DA
6d ago
+4644-176

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_item and reply_inbox from 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 new postInboxThread (for step 6's tool) both go through keepPost:
    • 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, using ask || jsonb_build_object('status', …) so no other key of the ask is rewritten;
    • points open_ask_message_id at the new message, moves the revision, and clears the answer.
    • MAX_OPEN_PER_AGENT still counts status = '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 becomes https://github.com/<repo>/blob/<sha>/<path>#L<a>-L<b> on its own line (the lone-link code card), and an image becomes ![alt](<inbox attachment address>).
    • 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 use inboxAskResponse.
  • A message without an ask (replyInboxItem, postInboxMessage, and postInboxThread with no ask under an existing key) only appends. It moves updated_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 carrying inboxReplyAnswers(…). 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 with inboxAnswersRefusal, then writes the member message, marks the ask answered, and fills answer, answered_by and settled_at. Its outcome is taken, repeated (same key), not-open, or refused with the reason.
    • The item's answer is the v4 view of the answer. Approve/Decline on an approval reads back as approved/declined, and the note is cut to MAX_NOTE_CHARS.
  • The revision doesn't move on an answer (agreed with the orchestrator). withdrawInboxAnswer still 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. withdrawInboxMessage is unchanged.
  • Pictures.
    • A post's pictures are now kind=message files of the agent's message, under ids minted in the write. The words draw each one by inboxAttachmentAddress(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.

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, response none, needs_reply false;
  • no answer, settled_at now;
  • 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, clears open_ask_message_id, and moves no revision, as #3932 defines.
  • No message is written for the close, so messages and thread don'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 closedBy names the member. This convention is commented on standingAsk for step 7a.
  • withdrawInboxClose, which undoes a failed hand-off, opens the withdrawn ask again and points the item back at it.

Reads (wire v5)

  • messages keeps the v4 thread: the latest MAX_MESSAGES_READ messages 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 of messages, as they did in v4. This rule was agreed with the orchestrator.
  • thread, a new optional field on inboxItemSchema, carries every message with its ask and answers. That's the latest MAX_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 open in its JSON, so a pointed ask on a closed item reads as withdrawn with 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-inboxItemSchema and the six that embed an item (inboxAnswer, inboxAnswered, inboxMessaged, and the three coding-agent-view envelopes). Each diff only adds the optional thread array of inboxMessageSchema.

For the steps above

  • Step 5: call answerInboxAsk. The archived-task check stays in the endpoint, as it does for inboxAnswerEndpoint.
  • Step 6: call postInboxThread(agent, { key, title, body, ask?, needsReply? }, pictures). Find a body's picture paths outside code, as withPictureAddresses does, read them from the sandbox, and pass each as { path, name, contentType, bytes }.
  • Step 7:
    • A post's message lists its pictures among its files and 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. version is 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.

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 reads withdrawn.
  • 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) reads replaced, and answerInboxAsk finds 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 through getTestDB(), picked up by test: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 messages leaves out and thread carries, and taking it back reopens the ask;
    • answerInboxAsk takes, repeats, refuses, and finds a replaced ask not open;
    • step 6's view, including steps listed under the words of a do ask;
    • 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 clears answerFiles, 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.ts and apps/web/tests/inbox-item-read-migration.test.ts pass 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=1 across all 14 affected workspaces, with a 6 GB heap: pass.
  • @luke/web build, 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:store on a real Postgres runs in CI's Postgres job; there's no Postgres in the sandbox.

Made with Luke