feat(maestro): add elicitation web UI and TUI stub
ElicitationView renders form-mode questions from the agent: single-property string enums as a button group (the common multiple-choice case), general schemas as a form with typed inputs. Wired into TranscriptRow, StructuredPane, and the model helpers. TUI renders elicitation rows as plain text for now. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1e628789fb
commit
5bdedc8d23
+64
-23
@@ -6,11 +6,11 @@ pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
|
||||
read this before changing anything that renders a structured session — both
|
||||
the web and TUI renderers are written against it.
|
||||
|
||||
It covers the eight transcript row kinds, what the daemon decides versus what
|
||||
It covers the nine transcript row kinds, what the daemon decides versus what
|
||||
a client decides, the two projections and their delta/cursor semantics, the
|
||||
ACP handshake and the capabilities it declines, the adapter's install
|
||||
command, and the approval nonce rules. **Non-goal: the detached runner.** It
|
||||
does not exist yet, and nothing here describes it.
|
||||
command, the approval nonce rules, and the elicitation flow. **Non-goal: the
|
||||
detached runner.** It does not exist yet, and nothing here describes it.
|
||||
|
||||
## What a structured session is
|
||||
|
||||
@@ -88,10 +88,12 @@ declares, deliberately, almost nothing:
|
||||
Declining all three keeps the daemon out of the file/terminal surface
|
||||
entirely — the adapter already has direct access to the session's `cwd`, and
|
||||
routing every read, write, and shell command through the daemon would only
|
||||
add a hop with no new capability. Every other capability namespace ACP
|
||||
defines (session extensions, auth, elicitation) is simply left at its
|
||||
default; the daemon does not advertise support because neither supported
|
||||
adapter needs it, not because it was weighed and refused.
|
||||
add a hop with no new capability.
|
||||
|
||||
The daemon does declare `elicitation.form` support. This tells the adapter
|
||||
that form-mode elicitation (`CreateElicitationRequest`) will be handled and
|
||||
resolved by the operator, rather than declined outright. See
|
||||
[Elicitations](#elicitations) below.
|
||||
|
||||
The one client method that *is* implemented for real is
|
||||
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
||||
@@ -111,12 +113,12 @@ in the projections below.
|
||||
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
||||
shell commands, file changes, MCP calls, web search, image generation/view,
|
||||
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
||||
those fold into the same eight rows. Codex's `available_commands_update` and
|
||||
those fold into the same nine rows. Codex's `available_commands_update` and
|
||||
`session_info_update`, like equivalent Claude updates, remain opaque
|
||||
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
||||
just as it is for Claude.
|
||||
|
||||
## The eight transcript row kinds
|
||||
## The nine transcript row kinds
|
||||
|
||||
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
||||
transcript is written in. Every row has a stable `id` (so later deltas can
|
||||
@@ -132,6 +134,7 @@ crate.
|
||||
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
|
||||
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
||||
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
||||
| `Elicitation` | A daemon-originated elicitation request (`CreateElicitationRequest`), serialized without its nonce. Carries `message` and `requestedSchema` (a JSON Schema object with typed properties). | `elicitation:<uuid>` — minted fresh per request, same lifecycle as `Approval`. |
|
||||
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
||||
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
||||
|
||||
@@ -139,7 +142,7 @@ crate.
|
||||
`agent-client-protocol` dependency's schema already deserializes (it
|
||||
currently folds `available_commands_update` and `session_info_update`
|
||||
there) but the projection's `match` doesn't fold into one of the other
|
||||
seven kinds. It is not a wire-level forward-compatibility seam:
|
||||
eight kinds. It is not a wire-level forward-compatibility seam:
|
||||
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
||||
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
||||
dependency has never heard of — including a variant gated behind an
|
||||
@@ -174,6 +177,8 @@ choices, not daemon ones.
|
||||
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
||||
below) — a client never sees an ACP permission request before the daemon
|
||||
has already turned it into an `Approval` row and a nonce.
|
||||
- Elicitation nonce minting and the single-active-elicitation rule (see
|
||||
[Elicitations](#elicitations)) — same lifecycle as approvals.
|
||||
- What happens to an ACP update variant the dependency decodes but nobody has
|
||||
taught the projection to special-case (falls back to `Notice` rather than
|
||||
erroring). A variant the dependency itself can't decode is a deserialization
|
||||
@@ -195,7 +200,8 @@ choices, not daemon ones.
|
||||
text → group → text → group rather than lumping every tool in a turn into
|
||||
one box. Text and thought rows stay outside groups as standalone transcript
|
||||
items — the operator sees all reasoning without expanding. Resolved
|
||||
approval rows between tools within a run are absorbed into the group. The
|
||||
approval and elicitation rows between tools within a run are absorbed into
|
||||
the group. The
|
||||
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
||||
argument come from the last tool row's `splitToolTitle` — the operator
|
||||
sees which command is current at a glance. Opening the group reveals tool
|
||||
@@ -213,6 +219,9 @@ choices, not daemon ones.
|
||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||
never the UI that solicits it.
|
||||
- How the operator is asked to resolve an `Elicitation` row — the daemon
|
||||
exposes the three actions (`Accept` with content / `Decline` / `Cancel`);
|
||||
the client decides the form layout and button treatment.
|
||||
- Scrolling, collapsing, and every other presentation-only behaviour.
|
||||
|
||||
## The two projections
|
||||
@@ -225,8 +234,8 @@ exposes two independent views, both defined in `maestro-protocol`:
|
||||
It is intentionally always the *whole* transcript, not a windowed one: a
|
||||
tool row patched much later must still find its original entry.
|
||||
- **`ReducedState`** — one whole-session snapshot folded over every event
|
||||
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
|
||||
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
||||
seen: `turn` (the per-turn indicators above), `pending_approval`,
|
||||
`pending_elicitation`, `plan`, `usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
||||
always full-history, never a delta — a client that only wants "is a turn
|
||||
active right now" never has to replay anything to answer that.
|
||||
|
||||
@@ -323,6 +332,37 @@ pending decision, entirely on the daemon side:
|
||||
permanent one (`RejectAlways`) only when that is the sole rejection option
|
||||
offered; if neither is present, it answers `Cancelled`.
|
||||
|
||||
## Elicitations
|
||||
|
||||
An ACP `elicitation/create` callback becomes an `Elicitation` row plus a
|
||||
pending decision, mirroring the approval lifecycle:
|
||||
|
||||
- **Generation.** The daemon mints a nonce per request (`Uuid::new_v4()`),
|
||||
same as approvals.
|
||||
- **Live-only placement.** The nonce lives in `pending_elicitations` and in
|
||||
`ReducedState.pending_elicitation.nonce`, never in the row's `data`.
|
||||
- **One live elicitation at a time.** A new elicitation cancels any
|
||||
prior pending one before installing itself.
|
||||
- **Resolution.** `POST /api/sessions/:id/structured/elicitations/:nonce`
|
||||
accepts `{ "action": "accept", "content": { ... } }`,
|
||||
`{ "action": "decline" }`, or `{ "action": "cancel" }`. The endpoint
|
||||
always answers `204` for a known, live structured session, same as
|
||||
approvals. The three actions map to ACP's `ElicitationAction::Accept`
|
||||
(with the filled-in values as `ElicitationContentValue` entries),
|
||||
`Decline`, and `Cancel`.
|
||||
- **Cancellation.** Turn completion, session cancel, and fatal errors
|
||||
cancel pending elicitations as `Cancel`, same as approvals.
|
||||
- **Schema.** The row's `data.requestedSchema` is a JSON Schema object
|
||||
whose properties describe the fields the agent wants filled:
|
||||
`StringPropertySchema` (with optional `enum`/`oneOf` for multiple-choice),
|
||||
`BooleanPropertySchema`, `NumberPropertySchema`, `IntegerPropertySchema`.
|
||||
Claude Code's common case is a single-property string enum — a
|
||||
multiple-choice question with titled options.
|
||||
- **Rendering.** Clients render the `message` as the question and the schema
|
||||
properties as form fields or button groups. The web client detects the
|
||||
single-property string enum fast path and renders titled buttons. Resolved
|
||||
elicitations collapse to a compact one-liner.
|
||||
|
||||
## Prompt and cancel: bounded liveness
|
||||
|
||||
The web composer accepts up to ten files through its attachment picker, file
|
||||
@@ -364,8 +404,8 @@ allowed to claim the daemon's idea of "the active turn."
|
||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||
operator's input. If that append fails, the prompt is not dispatched.
|
||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||
and ends the turn's client-visible lifecycle immediately. The prompt queue
|
||||
- Cancel sends `session/cancel`, clears any pending approval and elicitation
|
||||
as `Cancelled`, and ends the turn's client-visible lifecycle immediately. The prompt queue
|
||||
is preserved: after the cancelled turn settles, the queued prompts drain as
|
||||
the next turn. If the session dies instead (settlement timeout), the queue
|
||||
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
||||
@@ -390,8 +430,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
|
||||
|
||||
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
||||
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
||||
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
|
||||
for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
||||
readable) or a `{"maestroEvent": "user"|"approval"|"elicitation", "row": …}`
|
||||
envelope for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
||||
line a hard kill left half-written is terminated before the next append, so
|
||||
it keeps its position and is skipped as malformed rather than corrupting
|
||||
what follows. An append that fails *while the daemon is running* — a
|
||||
@@ -453,12 +493,13 @@ itself failing) leaves it `Dead` with the failure reason as
|
||||
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
||||
missing adapter binary.
|
||||
|
||||
Pending approvals and their nonces never survive any of this. They live only
|
||||
in the in-memory `pending_approvals` map and the live `ReducedState`, both
|
||||
gone the instant the daemon that held them exits. Whatever ACP permission
|
||||
request was mid-flight when the old daemon died was already answered
|
||||
`Cancelled` on that daemon's own shutdown path, so a resumed session always
|
||||
starts with no pending approval.
|
||||
Pending approvals and elicitations and their nonces never survive any of
|
||||
this. They live only in the in-memory `pending_approvals` /
|
||||
`pending_elicitations` maps and the live `ReducedState`, both gone the
|
||||
instant the daemon that held them exits. Whatever ACP permission or
|
||||
elicitation request was mid-flight when the old daemon died was already
|
||||
answered `Cancelled` on that daemon's own shutdown path, so a resumed
|
||||
session always starts with no pending approval or elicitation.
|
||||
|
||||
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
||||
now that structured sessions exist alongside terminal ones.
|
||||
|
||||
Reference in New Issue
Block a user