feat(web): message a coding agent mid-run, steering or queueing

Merged
DA
11d ago
+1556-91

Server half of the message box: POST /api/agents/{id}/messages with body { text, delivery }, delivery one of CODING_AGENT_DELIVERY.STEER | QUEUE. O2 stacks the desktop UI on this branch.

The wire (what O2 reads)

packages/hosted/src/coding-agent-wire.ts:

  • CODING_AGENT_DELIVERY (steer | queue), CODING_AGENT_BOUNDS.MAX_MESSAGE_CHARS (16 000, trimmed), codingAgentMessageRequestSchema.
  • The answer is codingAgentAnswerSchema ({ agent }), 202, with the agent's status read after the send, which is running.
  • Queued state: the message shows in the transcript read at once as a role: "user" UIMessage whose metadata.delivery is the delivery asked (queue or steer) for as long as it awaits its turn. Once the turn receives the line, the field is dropped and the row is read again in place at a bumped revision (the cursor already covers that). awaitedDeliveryOf(message) reads the marker. @sidecar/wire's typed-ask metadata gained the optional delivery key for this.
  • CODING_AGENT_STATUS.RUNNING now also covers an agent with a message awaiting its turn, in the list and in the messages read.

New refusals in HOSTED_API_ERROR (goldens recorded): message-too-long (400), agent-not-ready (409, the desktop retries), agent-retired (409, the session is gone and only a new agent goes on). The existing ones apply as on Start: not-found for another account's or an unknown agent, invalid-token, invalid-request, repository-not-reachable / github-sign-in-required, unavailable.

How it works

  • The coder channel keeps turnPolicy: queue as its default; the route names eve's turnPolicy per message (eve 0.74's per-send override). EveSessions.send takes an optional delivery and posts it as turnPolicy. A session still not ready past the client's twenty-second loop is now its own outcome, EVE_SEND_OUTCOME.NOT_READY, rather than folded into RETIRED; the planning ask reads both the same way it did (opens a new session), so nothing changes there.
  • While a turn runs, steer joins it and queue waits for it; while idle, eve opens a new turn on either. The route does not care which: it sends, and the agent's status reads running because a line awaits (awaitingLinesOf) until the turn takes it.
  • The row is written by StoreWriter.writeAwaitingLine, which runs the hand-over to eve inside the conversation's lock and inserts the row only once eve accepted, so the turn eve opens on the line finds the row standing when its own first write takes the lock, and an eve that refused leaves no row behind. The row stands under eve's delivery id (awaitingLineId). The write is uninterruptible around the hand-over, with the dispatch bounded by a thirty-second deadline of its own (the planning ask's shape), so a request that goes away after eve took the message still leaves the accepted line on record. The relay's message.received handler (takeAwaitingLine) takes the awaiting line the event's delivery ids name into the turn, in place, instead of writing it a second time, and falls back to the oldest line with the received words for a stream that names no delivery; the planning brain goes through the same handler and has no awaiting rows, so it is unchanged. The one gap left is a store write that fails after eve took the message: the relay then writes the line when the turn receives it, without its marker.
  • The store hook already relayed message.received mid-turn (that is how a steer's line gets recorded); nothing new was needed there.
  • The route checks the developer still reaches the repository first (same call as Start), so a lapsed sign-in is refused here rather than failing the turn's checkout. The step hook's credential renewal is unchanged and is what refreshes a token minted an hour ago on the next turn's first step.

What I checked by reading, not by running

  • A sandbox the hook stop()ped at the last turn's end: eve's Vercel provider resumes the persistent sandbox from its recorded state on the next getSandbox, so a session whose sandbox Vercel stopped comes back with the checkout intact. If the named sandbox has disappeared, eve's resume fails rather than re-initializing, which fails the tool call and so the turn; the transcript shows a failed turn. The route cannot know that ahead of eve, so "sandbox gone for good" is answered as agent-retired only where eve says the session itself is gone.
  • No DELETE for a queued message: eve 0.74 has no route to withdraw a delivery (the docs name cancel() as the only stop-without-replacement), so none is added.

Tests

apps/web/tests/coding-agents-app.test.ts through the public app with the existing fakes: steer and queue while running reach eve with the delivery named under the developer's bearer and show at once as awaiting user lines; a message to an idle agent opens a turn and the agent reads running in the list and the transcript, then completed once the turn that took the line ends; the owner check and no bearer; the bounds (too long, blank, a third delivery, no delivery; the bound itself is taken); eve's three refusals and an unclaimed session leave no row behind; a repository the developer no longer reaches is refused before eve hears of it. apps/web/tests/coder-host.test.ts drives the relay: a steer's line and a queued line are each taken by the turn that receives them, the marker clears in place, no second row is written, and the status moves running → completed on the line's account. hosted-eve-sessions.test.ts holds the posted turnPolicy and the new not-ready outcome; hosted-refusal.test.ts and the wire metadata tests cover the new vocabulary.

./scripts/check.sh exits 0. CI could not verify the Mac; no UI changed here (Dean waived verify.sh for this work).

šŸ¤– Generated with Claude Code