Skip to content

Flow: A dialog: asked in the host, answered once, settled for every follower

An extension’s ctx.ui.select / confirm / input / editor blocks its run until a human answers. In-process there is no wire for that wait: the host IS the UI (this file’s header), so the question is parked in the host’s pending map and rides to every follower as a blocking extension-ui-request observation (#27, #112). The answer is one unary POST /api/threads/:id/dialog, checked against the question it names. Every way the wait can end crosses one finish, which emits dialog-settled with its outcome (how a tab that did not answer learns the card is stale), and a follow opened under an open question finds it in the snapshot’s openDialogs (#114).

Recorded by agent-host.test.ts: ⚠ Stop stops: the run’s abort settles a pending dialog as aborted — the card drops, the extension resumes with its cancel value. 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 X as extension (the probe)
  participant H as host (agent-host.ts, extension-ui-context.ts)
  participant S as subscriber (the test)
  X->>H: ctx.ui.confirm("Still here?")
  H->>S: extension-ui-request {method: confirm, blocking: true}
  S->>H: openDialogs()
  H-->>S: 1 open
  S->>H: abort()
  H->>S: dialog-settled {outcome: aborted}
  H-->>X: resolves false
  S->>H: answerDialog {confirmed: true}, after the stop
  Note over H: dialog answer for unknown request id [id] — already answered, cancelled, or never asked

Recorded by follow.test.ts: ⚠ a blocking dialog is an observation on the follow and a unary answer back — nothing parks, nothing expires. 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 dialog 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: "/mode"}
  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 "/mode" { onAccepted, onRejected }
  Note over R: prompt [id] opened on run 1
  P->>F: extension-ui-request
  Note over F: extension-ui-request
  B->>R: POST /api/threads/t1/dialog {id: "d1", value: "zzz"}
  Note over R: dialog d1 answer refused (422): answer refused: 'zzz' is not one of the offered options
  R-->>B: 422
  B->>R: POST /api/threads/t1/dialog {id: "nope", value: "a"}
  Note over R: dialog nope answer refused (404): no open question nope on thread t1 — answered, expired, or never asked
  R-->>B: 404
  B->>R: POST /api/threads/t1/dialog {id: "d1", value: "a"}
  R->>P: answerDialog {id: "d1", value: "a"}
  Note over F: dialog-settled
  Note over R: dialog d1 answered
  R-->>B: 204
  B->>R: POST /api/threads/t1/dialog {id: "d1", value: "a"}
  Note over R: dialog d1 answer refused (404): no open question d1 on thread t1 — answered, expired, or never asked
  R-->>B: 404
  P-->>R: onAccepted()
  Note over T: status to followers: live true, busy false, run 1
  Note over T: run 1 released · idle timer [ms] ms

The question is parked in the host, and nowhere else.

Section titled “The question is parked in the host, and nowhere else.”

This mints the id, keeps { resolve, request } in a Map keyed by it, and emits the request on the same emitter as the session’s events, so request and settle sit in stream order. Nothing parks server-side; the run keeps going on the follow (server/follow.ts header). The thread is busy (glossary.md: parked).

Stated at awaitDialog in host/extension-ui-context.ts.

Pinned by:

  • agent-host.test.ts: ⚠⚠ a dialog an extension opens reaches the subscriber and its answer resolves the extension
  • follow.test.ts: ⚠ a blocking dialog is an observation on the follow and a unary answer back — nothing parks, nothing expires

Every path that stops the wait goes through finish.

Section titled “Every path that stops the wait goes through finish.”

Answer, browser cancel, dispose’s cancelAllDialogs, the extension’s own timeout, the extension’s own signal: each deletes the pending entry first (so a second path is a no-op), emits extension_ui_settled with its outcome (#114), and THEN resolves the extension. pi has no event for a question ending, and a follower that did not answer it has no other way to learn the card is stale. One exception: a signal already aborted when asked resolves the cancel value with no request and no settle.

Stated at awaitDialog in host/extension-ui-context.ts.

Pinned by:

  • agent-host.test.ts: a cancelled dialog resolves to pi’s cancel value, and dispose cancels what is still pending
  • agent-host.test.ts: ⚠ Stop stops: the run’s abort settles a pending dialog as aborted — the card drops, the extension resumes with its cancel value
  • extension-ui-context.test.ts: openDialogs lists the pending questions as the browser’s dialog specs, oldest first, and nothing once settled (#112, #114)
  • extension-ui-context.test.ts: the caller’s abort signal and timeout both resolve to the cancel value, each settled with its own reason (#114)

A non-answer resolves the runtime’s own cancel value, never a wrong type.

Section titled “A non-answer resolves the runtime’s own cancel value, never a wrong type.”

cancelValue is what the extension sees for every non-answer outcome, pi’s own contract: select, input and editor see undefined; confirm sees false (CONFIRM_DISMISSED). mapResponse degrades a wrong-shaped answer to that value. An answer naming no pending id is a logged warning (answerDialog), never a throw.

Stated at awaitDialog in host/extension-ui-context.ts.

Pinned by:

  • agent-host.test.ts: a cancelled dialog resolves to pi’s cancel value, and dispose cancels what is still pending
  • agent-host.test.ts: ⚠ Stop stops: the run’s abort settles a pending dialog as aborted — the card drops, the extension resumes with its cancel value
  • extension-ui-context.test.ts: the caller’s abort signal and timeout both resolve to the cancel value, each settled with its own reason (#114)

The settle is for one id, on every follower; the snapshot’s list is complete.

Section titled “The settle is for one id, on every follower; the snapshot’s list is complete.”

The browser drops the card only when dialog-settled names the id it shows (custom-event-router.ts, its dialog-settled case). This walks the pending map in insertion order, oldest first, checking each request with isEnsoDialogSpec rather than casting; the snapshot carries that list whole, so a reconnect renders the card and an empty list clears one.

Stated at openDialogs in host/extension-ui-context.ts.

Pinned by:

  • extension-ui-context.test.ts: openDialogs lists the pending questions as the browser’s dialog specs, oldest first, and nothing once settled (#112, #114)
  • follow.test.ts: ⚠ a question answered in one tab is settled on every follower’s feed, and a follow opened under it gets it in the snapshot (#114)

Stop stops: the run’s abort settles every pending dialog as aborted.

Section titled “Stop stops: the run’s abort settles every pending dialog as aborted.”

The runtime is told FIRST so its loop holds the abort signal when a blocked tool_call handler returns (a permission prompt): pi’s abort() raises it synchronously, before its first await (agent-session.js abort → agent.abort()). Then, while the runtime winds down, abortAllDialogs() resolves each pending question to its cancel value with the glossary’s outcome for exactly this, aborted, and every follower hears dialog-settled; refuseDialogsUntil answers any question asked before the stop finishes with its cancel value, showing nothing (ask_user’s multi-select asks the next option). Never only after the abort resolves: a question from a tool’s execute (ask_user) holds the run, pi’s abort() waits for idle, and such a stop waited on the card for ever (#376 review). Before 2026-09-16 the host settled nothing on abort, and the vendor’s permission prompt passes no signal of its own, so Stop during it waited on a human (decided, Daniel: if the user says stop, it’s stop).

A stop must stop (thread-registry.test.ts’s rule, one layer down): the dialogs settle even if the runtime’s abort rejects; the rejection still propagates, the cards still drop. Exported so that path is pinned against a stop that rejects, which the real runtime’s is not made to do.

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

Pinned by:

  • agent-host.test.ts: ⚠ Stop stops: the run’s abort settles a pending dialog as aborted — the card drops, the extension resumes with its cancel value
  • extension-ui-context.test.ts: abortAllDialogs settles every pending dialog as aborted, each blocked call resolving to its cancel value, once

An answer is checked against the question it names before the runtime sees it.

Section titled “An answer is checked against the question it names before the runtime sees it.”

This peeks, never builds, then finds the id in the runtime’s openDialogs(): a cold thread or a spent question is 404. A non-cancel answer validateDialogAnswer refuses is 422 and the question stays open: pi hands a select’s value to the extension VERBATIM, so an unoffered value is an injection seam the server closes (#112), the same check the run path’s resume made. Only a real cancelled: true skips the check (#225). Only then is answerDialog called, and 204 returned; the response says nothing else.

Stated at answerThreadDialog in server/follow.ts.

Pinned by:

  • schemas.test.ts: validateDialogAnswer refuses every answer shape a dialog did not ask for, and names the shape it wanted
  • follow.test.ts: ⚠ a blocking dialog is an observation on the follow and a unary answer back — nothing parks, nothing expires
/**
* Does this payload answer THIS dialog?
*
* Returns `undefined` when it does, or a human-readable refusal. Lives beside the schemas
* so both surfaces enforce the same rule — in particular the select rule: pi hands the
* answered `value` to the extension verbatim, so a value outside the offered options would
* put a string the extension never offered into its control flow. That is an injection
* seam, and it is closed HERE rather than trusted to the renderer.
*/
export function validateDialogAnswer(spec: EnsoDialogSpec, payload: unknown): string | undefined {
if (!dialogAnswerValidator.Check(payload)) {
return `the payload is not a dialog answer ({ value: string }, { confirmed: boolean } or { answers: [...] })`
}
switch (spec.method) {
case 'confirm':
return 'confirmed' in payload ? undefined : `a 'confirm' dialog takes { confirmed: boolean }`
case 'select':
if (!('value' in payload)) return `a 'select' dialog takes { value: string }`
return spec.options.includes(payload.value) ? undefined : `'${payload.value}' is not one of the offered options`
case 'input':
case 'editor':
return 'value' in payload ? undefined : `an '${spec.method}' dialog takes { value: string }`
case 'questionnaire':
if (!('answers' in payload)) return `a 'questionnaire' dialog takes { answers: [...] }`
return questionnaireRefusal(spec.questions, payload.answers)
}
}