feat(service): a call joins an agent's running turn
PR 2c of the Unified agents rollout (STAG-322). It builds the follow-up #4183 sized under "Follow-up".
Until now, a call's spoken ask to a coding agent waited behind the agent's running typed turn, and a coding turn can run for an hour. With this PR the ask joins that turn at its next step, the same way a typed line does, and the agent answers it within a step. Stopping the call never cancels the coding turn.
What changed
The door (coder-host/channel.ts, brain-host/auth.ts)
- The coder deployment now presents the account for a spoken ask too (
CODER_DEPLOYMENT_PRESENCE = DEPLOYMENT_PRESENCE.ACCOUNT). eve steers a message only into a turn of the same principal, so the call's ask now joins the agent's typed turn, and a line typed to the agent joins a call's spoken turn. - Reading a session's stream is now its own flag,
DeploymentActor.streams. Both channels set it, so the voice's follow still reads the stream on/eve/coder. Until now only an actor that presented itself could read streams. - Every spoken line on the coder channel carries a short note ahead of it, through eve's
onMessagecontext. It says the line was said on a call, the reply is heard, and the reply should be a sentence or two that never waits, after which the agent carries on with its work.- A turn keeps the tools and words it opened with, so this note is the only call guidance a spoken line gets when it joins a typed turn.
- It also says that, in the middle of other work, the first words after the line are taken as its whole answer. That is the boundary the follow ends a joined run at, so the model looks things up first and answers in one go.
- The note is a user-role message placed just ahead of the line, so it sits after the history and leaves the cached prefix alone.
The shared relay classifies each line, not each turn (brain-host/relay.ts)
- The opening line of a turn is still read by the turn's kind.
- Every later line goes through
#receivedin this order:- Take the developer's awaiting typed row (
takeAwaitingLine). This runs under the conversation's lock, so it waits for an ask'sdispatchOnceto commit. - Only then bind the line's new deliveries.
- If a spoken ask binds, the line is the transcript's row. Otherwise the relay writes it as the user row.
- Take the developer's awaiting typed row (
- Binding before taking could miss a delivery whose dispatch hadn't committed yet, and the line would then be written twice.
Where a spoken ask joined is kept on the ask (asks.joined_after_step, nullable; migration 20261020222556_asks_add_joined_after_step)
- When a spoken ask binds to a typed turn, the relay records how many of that turn's steps had begun. An ask folded in at the turn's start records 0.
- It's recorded at bind time because the voice usually tags its transcript row with the ask (
attachSpokenAsk) only after eve has already steered the ask in. Read off the row, the join would come out late. - Three places read it:
- The attach (
writer.ts, bothattachAskLinesandattachSpokenAsk): the line is given to the turn in place, withtaken_after_step, the same as a steered typed line. The coding turn's journal no longer moves behind it. - The Stop.
- The follow.
- The attach (
A Stop covers only the spoken part
- The relay doesn't carry the Stop of a joined ask, whether it was taken while the ask waited or after it joined.
stopAskstamps the joined ask and answers cancelled, but never asks eve to cancel and never stamps the turn.- A spoken turn of its own is cancelled as before.
Asks on every turn (coder-host/host.ts)
- The coder relay now binds asks on all of the agent's own turns. A typed turn's start costs two statements: the bind and the read of Stops. A worker's turns take none.
recordContextnow reads the kind the turn opened as (relayedTurnKind). eve moves the session's current auth to the steering line, so after a spoken ask joins, the current auth would otherwise read spoken.
The voice follow (voice/live-brain.ts, agents/turn-events.ts)
projectTurnEvents(turn, journal, joinedAfterStep)projects only the steps after the join, numbered from one.- The follow's activity, settled steps and parked bound read the same slice (
journalAfterStep). - What was told is tracked per part of a turn (a nested map: turn, then join step), so two asks that joined at different steps don't share a count.
- A stopped joined ask ends its run as cancelled.
Decisions (agreed with the orchestrator)
- One nullable column, rather than reading the join off the transcript row, for the reason above.
- A joined run ends at its answer. The run completes once a step after the join has said something and the next step has begun, or once the turn ends. Slow steps are told only before that answer.
- Following the coding turn to its end would keep the call's exchange open for up to an hour.
- The follow's 10-minute bound would then make the voice say "I couldn't finish that ask".
- After the answer, the agent keeps coding quietly. Asking again joins the turn again.
Typed and spoken lines in one message
If a typed line and a spoken ask reach eve in the same step, eve coalesces them into one received message. Once the spoken ask binds, the relay takes the awaiting typed rows whose words appear whole among that message's segments (takeAwaitingLine with beside, coalescedRunWithin), so the typed row doesn't stay awaiting.
Tests (each fails without the change)
coder-host.test.ts:- A spoken ask that joins the agent's typed turn after its first step binds there with
joinedAfterStep: 1. Its line is written once and drawn where it arrived (arrivedIn.afterStep: 1), even when the voice tags the row only after eve steered the ask in. - A typed line that joins a call's spoken turn is taken as its awaiting row where it arrived, and isn't written again.
- A typed line that eve coalesced with a spoken ask into one message is taken as its awaiting row, while the ask's line stays the transcript's.
- A spoken ask that joins the agent's typed turn after its first step binds there with
turn-events.test.ts: a joined ask hears only the steps after the join. It hears the answer's words, never the turn's earlier ones, and no slow step from the answering step. Its run ends once the next step begins while the turn is still running.live-brain.test.ts, end to end through the ask door and the relay:- A joined ask is told only its answer and then completes.
- A later ask stopped while it waited, and another stopped after it joined, each end cancelled.
- The turn runs to
settledwith no cancel stamp, and eve is never asked to cancel.
coder-agent.test.ts:- The spoken ask, its opening and the stream read are minted as the developer's principal, with the deployment as carrier.
- A spoken line carries the call note, and a typed line carries none.
Checks
pnpm check:affectedran out of memory in this 8 GB sandbox (typecheck exit 137, as #4183's run did), so I ran its checks in parts:- repository checks,
drizzle-kit check, biome and oxlint, knip; - typecheck of features, database, service, apps/luke, apps/dev, voice and web;
- the touched service suites (30 files, 265 tests).
- repository checks,
- CI is the gate.
BRAIN_REQUEST_ORIGINis no longer re-exported from@luke/features/brainorcore.ts: the relay was its only outside reader.
Coordination: #4227 (3b) is still open and changes how turn context is carried. I'll merge main once it lands.
Made with Luke