Skip to content

Inbox: a row is a thread (send_message joins the session's thread; optional thread name) - #1752

Merged
backnotprop merged 2 commits into
mainfrom
feat/inbox-thread-routing
Oct 7, 2026
Merged

backnotprop merged 2 commits into
mainfrom
feat/inbox-thread-routing

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Inbox step 1c. Owner ruling 2026-10-07 ("The row model, ruled"): a row is a thread, and the project is a label on the row and a filter. Before this change, every send_message without reply_to started a new thread.

Routing (InboxStore.sendMessage)

The first rule that matches wins:

  1. reply_to: the message goes into that message's thread as a reply, and thread is ignored. Same as today.
  2. thread (a name): trimmed, 1..120 characters, no control characters or line breaks. Otherwise the refusal is validation_error: thread: ….
    • The message joins the newest thread with that name in the project, from any session, while that thread is open.
    • If there is no such thread, or it is resolved, the message starts a new thread under that name.
    • Names are compared after NFC normalization and whitespace collapsing, ignoring case.
    • A named thread is never the session's default thread.
  3. Otherwise: the message joins the newest unnamed thread that the agent_session started in this project, while that thread is open. If there is none, or it is resolved, the message starts a new thread. Resolved means done.
  • A sender with no session has nothing to join, so each message starts its own thread (the step-1 behaviour).
  • A thread keeps its first message's subject. A joining message's subject is dropped.
  • Idempotency keys are unchanged. The key is checked before routing, so a retry returns the first message even after its thread was resolved.
  • Routing reads only the log. The two indexes are built from each root's first line in seq order, so a replay routes exactly as the live store did.
  • Only fields were added: thread_name, plus person_seen_seq, agent_checked_seq and agent_checked_at on thread roots. A step-1 store replays unchanged.

The list: sections per thread

  • GET /api/inbox/threads[?project=prj_…] returns all six sections in the approved order: Stopped on you, Holding up work, Waiting on you, Sent, New since you looked, Quiet.
  • Each row is one thread and carries project: { id, name }, thread_name, section, unread, answered_not_sent, waiting_since, unseen and sent.
  • The project filter keeps all six sections and only removes other projects' rows.
  • /api/inbox/projects now returns the sidebar folders, with thread and unread counts.
  • Placement rules are in packages/shared/inbox/list.ts, a pure module that reads Workspaces' inbox-model.ts per thread. Sort order inside each section also follows Workspaces.
  • POST /api/inbox/threads/:id/seen records that the person looked. It uses the same-origin and serverSession guards. The stub page sends it when a thread opens.
  • When the agent reads the person's reply (wait_for_reply returns it, or read_thread reads the thread), the row moves from Sent to Quiet, matching the approved Sent band.

MCP

  • send_message has a new thread parameter. Its description now explains the routing, and its result adds new_thread and thread_name.
  • The server instructions are 1,267 characters, under the 2,048 limit.
  • read_thread's list mode returns list rows for the threads the session sent in: threads it started, joined or replied in.

Proof

Proved live, with no mocks:

  • packages/server/inbox-threads.test.ts runs the official MCP SDK client against a running Inbox and covers:
    • two sends from one session → one thread;
    • a third send with thread: "b" → a second thread;
    • a send from another session with " B " → joins it;
    • resolve, then send → a new thread, for both the default thread and a named one;
    • reply_to lands in the replied-to thread, even with thread set;
    • with no session, each send starts its own thread;
    • name validation.
  • Sections: the same test file puts eight threads in different states across two projects. It checks the exact section contents and order with the filter and without it, plus Sent → Quiet when the agent reads, New → Quiet after a look, and New again when the agent writes again.
  • Old-format store: tests/test-fixtures/inbox-step1/ was written by step 1's own store.ts (taken from origin/main). It replays every record unchanged and lists one row per thread in the right sections, also with the filter. Its session's next message joins that session's newest open root, and a restart gives the same list.
  • Property test (store.test.ts): an independent oracle walks the log in seq order and assigns every message the same thread the store did. A copy replayed from disk routes every next send the same way. A mutation check confirmed the property fails when name case-folding is broken.
  • bun run typecheck passes.
  • bun test packages/server packages/shared packages/core apps/hook: 3486 pass, 2 skip, 0 fail.

Judgement calls

  • Thread name matching ignores case and collapses whitespace, so "Auth refactor" and "auth refactor" are the same thread. The stored name keeps the first spelling.
  • "Most recent open thread" is read as the session's newest unnamed thread. If that thread is resolved, the next message starts a new thread, even if an older thread of the session was reopened.
  • Looks and agent reads are recorded, so that "New since you looked" and "Sent → Quiet" can be derived. They are written as root snapshots, and only when something changed. Writing on an MCP read (read_thread) is new; readOnlyHint stays, because the agent cannot observe the change.
  • A plain agent message with no question goes to New since you looked, not Waiting on you. The "waiting" sections are reserved for open questions, matching Workspaces, where a turn is something asked of the person.
  • /api/inbox/projects changed shape to become the sidebar folders. Nothing outside the stub, the tests and DAST read it; the DAST seeds now include /api/inbox/threads.
  • The held order and the "N new" notice stay window work.

…tional thread name)

Owner ruling 2026-10-07: a row is a thread, the project is a label and a
filter. send_message without reply_to now joins the sender session's open
default thread in the project, or the open thread named by the new optional
'thread' (1..120 chars, case-folded, across sessions); resolved means a new
thread; reply_to still wins. Routing reads only the log (indexes built from
each root's first line in seq order), so replays route identically and
step-1 stores replay unchanged.

The list model is now one row per thread, placed in the approved sections
(Stopped on you, Holding up work, Waiting on you, Sent, New since you
looked, Quiet) with the project as a label and ?project= filter on
GET /api/inbox/threads; /api/inbox/projects becomes the sidebar folders.
POST /api/inbox/threads/:id/seen records a look; an agent reading the
person's reply moves Sent to Quiet. Additive record fields only.

Proof: live MCP SDK client against the running server, a step-1 store
fixture written by step 1's own code, and a fast-check oracle replaying the
log.
…st cost

- Thread names: refuse bidi controls and invisible-only names; compare by
  NFKC with default-ignorable code points dropped and case folded
  upper-then-lower, so look-alike spellings are one thread.
- A name joins its newest OPEN thread (one the person reopened counts).
- An idempotency key reused with another thread name is refused, like a
  different body or reply_to.
- wait_for_reply without a cursor counts replies after the CALLER's last
  message, so another session writing in a shared thread cannot hide one;
  a returned reply records only itself as checked.
- /seen writes only when an agent message is new to the person.
- The list is linear: questions indexed by message (4,000 threads: 627 ms
  to 11 ms), and /api/inbox/threads builds its rows once.
- send_message's lead is one short sentence, so Claude Code's 2,048-char
  description cut keeps the question guide through "Ask only what you
  cannot decide alone"; pinned by a test.
- Live tests for each, through the MCP SDK client and real fetch.
@backnotprop
backnotprop merged commit 2ef9f6d into main Oct 7, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant