Skip to content

Flow: Prompt → run → settle: admitted on the POST, ended on the follow

A prompt from the browser is one POST /api/threads/:id/prompt, and the route’s whole job is admission: check the body, take the thread or queue behind the run that holds it, answer 202 with a receipt. Nothing of the run is an HTTP response (#112). The run lives on the follow (observations, settled, then a released status), and the thread’s own settled, read off the feed by the route, gives the lease back. Busy is queued, not refused (#75), and a late refusal is an observation.

Recorded by follow.test.ts: prompt answers at admission with a message id; the run is released by pi’s settle, no HTTP response in between. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.

sequenceDiagram
  participant B as browser (the test client)
  participant R as prompt route (server/index.ts, follow.ts)
  participant T as thread registry
  participant P as runtime (fake host)
  participant F as feed (observation log)
  B->>R: POST /api/threads/t1/prompt {message: "hello"}
  Note over T: status to followers: live true, busy false, run 0
  Note over T: thread built: session s1 in /s
  Note over T: status to followers: live true, busy true, run 1
  Note over T: run 1 acquired
  R->>P: prompt "hello" { onAccepted, onRejected }
  Note over R: prompt [id] opened on run 1
  R-->>B: 202 {threadId: "t1", messageId, generation: 1, queued: false}
  P->>F: text-delta
  Note over F: text-delta
  Note over F: run 1 first delta after [ms]ms
  P->>F: settled
  Note over F: settled
  Note over T: status to followers: live true, busy false, run 1
  Note over T: run 1 released · idle timer [ms] ms

Recorded by follow-connection.test.ts: send opens TanStack’s own run at admission; the thread’s settle closes it. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.

sequenceDiagram
  participant U as TanStack (useChat)
  participant A as adapter (follow-connection.ts)
  participant S as server (scripted)
  U->>A: send "hello" (run r1)
  A->>S: POST /api/threads/t1/prompt {message: "hello"}
  A->>U: RUN_STARTED r1
  S-->>A: 202 {threadId: "t1", messageId: "pm1", generation: 1, queued: false}
  A->>U: TEXT_MESSAGE_START
  Note over A: prompt pm1 opened on run 1
  S->>A: status {live: true, busy: true, generation: 1}
  S->>A: observation 1: text-delta
  S->>A: observation 2: settled
  S->>A: status {live: true, busy: false, generation: 1}
  A->>U: CUSTOM status
  A->>U: TEXT_MESSAGE_CONTENT "hi"
  A->>U: RUN_FINISHED r1
  A->>U: CUSTOM status

Admission is the answer; the run is not an HTTP response.

Section titled “Admission is the answer; the run is not an HTTP response.”

An IDLE thread is taken, runtime.prompt is fired and the 202 goes out at once. The prompt’s own settled (or, for a slash command, its acceptance: an extension command never settles) releases the thread through the feed, read the way any follower reads it. The receipt’s ids are the admission record’s ids.

Stated at promptThread in server/follow.ts.

Pinned by:

  • follow.test.ts: prompt answers at admission with a message id; the run is released by pi’s settle, no HTTP response in between

Busy is queued, not refused and not re-acquired.

Section titled “Busy is queued, not refused and not re-acquired.”

A BUSY thread (a run in flight, or a dialog the follow is showing) is read with peek: the message goes to the runtime with an admission (enqueue unless the caller asked to steer), which is #75’s decision, and the receipt names the run in flight with queued: true. That run keeps its lease; its settled releases the thread once everything queued has run.

Stated at promptThread in server/follow.ts.

Pinned by:

  • follow.test.ts: a prompt on a busy thread is enqueued through the runtime, not refused and not re-acquired

Refusals fall before the lease where they can; after the receipt they are observations.

Section titled “Refusals fall before the lease where they can; after the receipt they are observations.”

A whitespace-only body is a 400 from readPromptBody before any lease or session exists. The ONE refusal both paths share is a slash command this surface cannot run (unavailableCommand, #337): a 400 before anything is queued, busy or idle alike, and on the idle path the lease it took is given back. Once the 202 is out the feed is the only channel: a rejection is announced as prompt-rejected with the receipt’s message id, in sequence with the runtime’s observations.

Stated at promptThread in server/follow.ts.

Pinned by:

  • follow-connection.test.ts: ⚠ a prompt pi rejects after admission reaches the run as an error, by the receipt’s message id
  • follow.test.ts: ⚠ a prompt the runtime REJECTS after the 202 is said on the follow with the receipt’s message id, and the thread is released
  • follow.test.ts: ⚠ a QUEUED prompt the runtime rejects is said on the follow too, and the run it queued behind stays open
  • follow.test.ts: an unknown slash command is a 400 at admission and releases the thread

A settled run releases; it never disposes.

Section titled “A settled run releases; it never disposes.”

This arms the idle timer and publishes busy: false; the session survives into the next run (#43), and only the idle window or an explicit dispose ends it.

Stated at release in server/thread-registry.ts.

Pinned by:

  • server-handler.test.ts: ⚠ two prompts on one thread reuse ONE session; a second thread gets its own (#43 over HTTP)

The generation is the run’s number and the join key.

Section titled “The generation is the run’s number and the join key.”

The take increments it, marks the thread busy and publishes the status before the lease is handed out, so every follower hears the number before the receipt names it. A release through a lease an older generation took is a reported no-op.

Stated at acquire in server/thread-registry.ts.

Pinned by:

  • follow.test.ts: prompt answers at admission with a message id; the run is released by pi’s settle, no HTTP response in between

A run closes only once the server has moved past its generation.

Section titled “A run closes only once the server has moved past its generation.”

A run whose generation is still unknown (its receipt has not come back) or newer than the server’s is NOT closed: the frames ending an EARLIER run can arrive after this tab has already opened the next one, and closing that one here would end it before it started.

Stated at finishRunsThrough in follow-connection.ts.

Pinned by:

  • follow-connection.test.ts: ⚠ an earlier run’s ending frames cannot close a run whose receipt is still pending; the receipt closes it if the server already moved past it

Acceptance and completion are separate signals, so a run ends on either.

Section titled “Acceptance and completion are separate signals, so a run ends on either.”

onAccepted is the runtime’s preflight: taken, not finished (the ThreadRuntime contract). One exception is deliberate: an extension command’s acceptance IS its completion, so a slash prompt releases on acceptance and never settleds. So a released status (busy: false) is the server’s statement that the run is over, as much as settled is.

Stated at observeStatus in follow-connection.ts.

Pinned by:

A rejection after acceptance never reaches the feed.

Section titled “A rejection after acceptance never reaches the feed.”

It is dropped here on purpose: pi’s own rpc-mode handler does the same (dist/modes/rpc/rpc-mode.js, case "prompt"), and pi’s _runAgentPrompt emits agent_settled from a finally, so the run still terminates; only the error text is lost. Model and tool errors never reach this path: the agent loop turns them into an assistant message with stopReason: "error", carried as usual.

Stated at startPrompt in host/agent-host.ts.

Not pinned by a test: no host test drives pi’s preflight to success and then rejects the prompt.

/**
* What `POST /api/threads/:id/prompt` answers with: ADMISSION, not a result.
*
* A run is not one prompt's reply — interrupting prompts, enqueued ones and retries can all
* land inside it — so the receipt names the message and the acquisition it took or queued
* behind, and the follow carries everything after (deepseek-harness: `followup()` returns
* void).
*/
export const EnsoPromptReceipt = Type.Object(
{
threadId: Type.String(),
/** Minted at admission — the identity a client correlates this message by. */
messageId: Type.String(),
/** The acquisition this prompt took, or the one it queued behind. */
generation: Type.Integer({ minimum: 0 }),
/** The thread was busy: pi queued the message behind the run in flight (#75). */
queued: Type.Boolean(),
},
{ additionalProperties: false },
)