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
@@ -355,6 +355,15 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
Tone::Muted,
|
||||
false,
|
||||
),
|
||||
TranscriptRowKind::Elicitation => push_wrapped(
|
||||
&mut out,
|
||||
row,
|
||||
"? ",
|
||||
content_text(row),
|
||||
row_width,
|
||||
Tone::Muted,
|
||||
false,
|
||||
),
|
||||
TranscriptRowKind::Plan => {}
|
||||
}
|
||||
if out.len() > start
|
||||
@@ -399,6 +408,7 @@ pub fn copy_text(row: &TranscriptRow) -> String {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("permission requested")
|
||||
.into(),
|
||||
TranscriptRowKind::Elicitation => content_text(row),
|
||||
TranscriptRowKind::Notice => "[notice]".into(),
|
||||
TranscriptRowKind::Plan => String::new(),
|
||||
}
|
||||
|
||||
+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.
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RecentDirs,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
StructuredElicitationAction,
|
||||
StructuredConfigRequest,
|
||||
StructuredPromptRequest,
|
||||
Uuid,
|
||||
@@ -219,4 +220,20 @@ export async function resolveStructuredApproval(
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a pending elicitation. Same idempotency as approvals. */
|
||||
export async function resolveStructuredElicitation(
|
||||
id: Uuid,
|
||||
nonce: string,
|
||||
action: StructuredElicitationAction,
|
||||
): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredElicitation(id, nonce), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(action),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// An `Elicitation` row. The daemon mints the nonce; this only solicits one
|
||||
// of the three possible actions (accept, decline, cancel).
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type {
|
||||
AcpElicitationProperty,
|
||||
AcpElicitationSchema,
|
||||
StructuredElicitationAction,
|
||||
} from "../../types.js";
|
||||
|
||||
export interface ElicitationViewProps {
|
||||
/** The `CreateElicitationRequest` the row carries — serialized without
|
||||
* its nonce, which never leaves the daemon's live wire. */
|
||||
request: {
|
||||
message?: string;
|
||||
requestedSchema?: AcpElicitationSchema;
|
||||
};
|
||||
/** Whether this is the one live elicitation. */
|
||||
pending: boolean;
|
||||
onDecide: (action: StructuredElicitationAction) => Promise<void>;
|
||||
}
|
||||
|
||||
/** For a single-property string enum schema (the common Claude Code
|
||||
* multiple-choice case), extract the property key and its options. */
|
||||
function singleSelectEnum(
|
||||
schema: AcpElicitationSchema | undefined,
|
||||
): { key: string; options: Array<{ value: string; title: string; description?: string }> } | null {
|
||||
if (!schema) return null;
|
||||
const keys = Object.keys(schema.properties);
|
||||
if (keys.length !== 1) return null;
|
||||
const key = keys[0];
|
||||
const prop = schema.properties[key];
|
||||
if (prop.oneOf && prop.oneOf.length > 0) {
|
||||
return {
|
||||
key,
|
||||
options: prop.oneOf.map((o) => ({
|
||||
value: o.const,
|
||||
title: o.title,
|
||||
description: o.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (prop.enum && prop.enum.length > 0) {
|
||||
return {
|
||||
key,
|
||||
options: prop.enum.map((v) => ({ value: v, title: v })),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ElicitationView({ request, pending, onDecide }: ElicitationViewProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [formValues, setFormValues] = useState<Record<string, string | number | boolean>>({});
|
||||
const message = request.message ?? "Input requested";
|
||||
const schema = request.requestedSchema;
|
||||
|
||||
if (!pending) {
|
||||
return <div className="st-row st-row-elicitation st-elicitation-compact">{message}</div>;
|
||||
}
|
||||
|
||||
const decide = (action: StructuredElicitationAction) => {
|
||||
setBusy(true);
|
||||
onDecide(action).catch(() => setBusy(false));
|
||||
};
|
||||
|
||||
const singleSelect = singleSelectEnum(schema);
|
||||
|
||||
// Fast path: single-property string enum → button group (the common
|
||||
// Claude Code multiple-choice question).
|
||||
if (singleSelect) {
|
||||
return (
|
||||
<div className="st-row st-row-elicitation">
|
||||
<div className="st-elicitation">
|
||||
<div className="st-elicitation-title">
|
||||
<span aria-hidden="true">?</span> {message}
|
||||
</div>
|
||||
<div className="st-elicitation-options">
|
||||
{singleSelect.options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className="act"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
decide({ action: "accept", content: { [singleSelect.key]: opt.value } })
|
||||
}
|
||||
>
|
||||
{opt.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="st-elicitation-acts">
|
||||
<button
|
||||
type="button"
|
||||
className="act deny"
|
||||
disabled={busy}
|
||||
onClick={() => decide({ action: "decline" })}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// General form: render inputs for each property.
|
||||
const properties = schema?.properties ?? {};
|
||||
const propertyKeys = Object.keys(properties);
|
||||
const requiredSet = new Set(schema?.required ?? []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
decide({ action: "accept", content: formValues });
|
||||
};
|
||||
|
||||
const updateField = (key: string, value: string | number | boolean) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="st-row st-row-elicitation">
|
||||
<div className="st-elicitation">
|
||||
<div className="st-elicitation-title">
|
||||
<span aria-hidden="true">?</span> {message}
|
||||
</div>
|
||||
<div className="st-elicitation-form">
|
||||
{propertyKeys.map((key) => {
|
||||
const prop = properties[key];
|
||||
return (
|
||||
<ElicitationField
|
||||
key={key}
|
||||
name={key}
|
||||
prop={prop}
|
||||
required={requiredSet.has(key)}
|
||||
disabled={busy}
|
||||
value={formValues[key]}
|
||||
onChange={(v) => updateField(key, v)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="st-elicitation-acts">
|
||||
<button type="button" className="act primary" disabled={busy} onClick={handleSubmit}>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="act deny"
|
||||
disabled={busy}
|
||||
onClick={() => decide({ action: "decline" })}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ElicitationField({
|
||||
name,
|
||||
prop,
|
||||
required,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
prop: AcpElicitationProperty;
|
||||
required: boolean;
|
||||
disabled: boolean;
|
||||
value: string | number | boolean | undefined;
|
||||
onChange: (value: string | number | boolean) => void;
|
||||
}) {
|
||||
const label = prop.title ?? name;
|
||||
const type = prop.type ?? "string";
|
||||
|
||||
if (type === "boolean") {
|
||||
return (
|
||||
<label className="st-elicitation-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={typeof value === "boolean" ? value : (prop.default === true)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "number" || type === "integer") {
|
||||
return (
|
||||
<label className="st-elicitation-field">
|
||||
{label}
|
||||
{required && <span className="st-elicitation-required">*</span>}
|
||||
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||
<input
|
||||
type="number"
|
||||
step={type === "integer" ? 1 : "any"}
|
||||
min={prop.minimum}
|
||||
max={prop.maximum}
|
||||
value={typeof value === "number" ? value : ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: string
|
||||
return (
|
||||
<label className="st-elicitation-field">
|
||||
{label}
|
||||
{required && <span className="st-elicitation-required">*</span>}
|
||||
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
disabled={disabled}
|
||||
placeholder={typeof prop.default === "string" ? prop.default : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
resolveStructuredElicitation,
|
||||
sendStructuredPrompt,
|
||||
setStructuredConfig,
|
||||
uploadStructuredFile,
|
||||
@@ -29,6 +30,7 @@ import { formatContextTokens, formatElapsed } from "./render.js";
|
||||
import type {
|
||||
AcpSessionConfigOption,
|
||||
StructuredApprovalDecision,
|
||||
StructuredElicitationAction,
|
||||
TranscriptRow as Row,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
@@ -36,6 +38,7 @@ import { Composer } from "./Composer.js";
|
||||
import {
|
||||
commandsFromTranscript,
|
||||
pendingApprovalRowId as pendingApprovalRowIdOf,
|
||||
pendingElicitationRowId as pendingElicitationRowIdOf,
|
||||
transcriptItems,
|
||||
turnIsActive,
|
||||
} from "./model.js";
|
||||
@@ -124,9 +127,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
||||
|
||||
const turnActive = turnIsActive(state);
|
||||
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
||||
const pendingElicitationRowId = useMemo(() => pendingElicitationRowIdOf(rows, state), [rows, state]);
|
||||
const items = useMemo(
|
||||
() => transcriptItems(rows, pendingRowId, turnActive),
|
||||
[rows, pendingRowId, turnActive],
|
||||
() => transcriptItems(rows, pendingRowId, turnActive, pendingElicitationRowId),
|
||||
[rows, pendingRowId, turnActive, pendingElicitationRowId],
|
||||
);
|
||||
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
||||
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
||||
@@ -193,6 +197,20 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
||||
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
||||
);
|
||||
|
||||
const decideElicitation = useCallback(
|
||||
async (rowId: string, action: StructuredElicitationAction): Promise<void> => {
|
||||
const nonce = state.pending_elicitation?.nonce;
|
||||
if (!nonce || rowId !== pendingElicitationRowId) return;
|
||||
try {
|
||||
await resolveStructuredElicitation(sessionId, nonce, action);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[sessionId, state.pending_elicitation?.nonce, pendingElicitationRowId],
|
||||
);
|
||||
|
||||
const dispatchPrompt = useCallback(
|
||||
(text: string, files: File[]) => {
|
||||
const generation = ++submission.current;
|
||||
@@ -311,6 +329,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
||||
row={row}
|
||||
pendingApproval={row.id === pendingRowId}
|
||||
onDecide={(decision) => decide(row.id, decision)}
|
||||
pendingElicitation={row.id === pendingElicitationRowId}
|
||||
onElicitationDecide={(action) => decideElicitation(row.id, action)}
|
||||
expanded={expandedRows.has(row.id)}
|
||||
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
||||
imageUrls={imageUrls.current}
|
||||
|
||||
@@ -10,14 +10,17 @@ import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpElicitationSchema,
|
||||
AcpPermissionOption,
|
||||
AcpToolCallContent,
|
||||
AcpToolCallLike,
|
||||
StructuredApprovalDecision,
|
||||
StructuredElicitationAction,
|
||||
TranscriptRow as Row,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { ApprovalView } from "./ApprovalView.js";
|
||||
import { ElicitationView } from "./ElicitationView.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import {
|
||||
@@ -337,6 +340,9 @@ export interface TranscriptRowProps {
|
||||
* rules). Every other approval row is history. */
|
||||
pendingApproval?: boolean;
|
||||
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
/** This row is the one live elicitation. */
|
||||
pendingElicitation?: boolean;
|
||||
onElicitationDecide?: (action: StructuredElicitationAction) => Promise<void>;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
imageUrls?: Map<string, string>;
|
||||
@@ -348,6 +354,8 @@ export function TranscriptRow({
|
||||
row,
|
||||
pendingApproval = false,
|
||||
onDecide,
|
||||
pendingElicitation = false,
|
||||
onElicitationDecide,
|
||||
expanded = false,
|
||||
onExpandedChange,
|
||||
imageUrls,
|
||||
@@ -374,6 +382,16 @@ export function TranscriptRow({
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "elicitation": {
|
||||
const request = (row.data ?? {}) as { message?: string; requestedSchema?: AcpElicitationSchema };
|
||||
return (
|
||||
<ElicitationView
|
||||
request={request}
|
||||
pending={pendingElicitation}
|
||||
onDecide={onElicitationDecide ?? (() => Promise.resolve())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "plan":
|
||||
return null;
|
||||
case "notice": {
|
||||
|
||||
@@ -134,6 +134,11 @@ export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState)
|
||||
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
||||
}
|
||||
|
||||
/** Same for elicitations — one live at a time. */
|
||||
export function pendingElicitationRowId(rows: TranscriptRow[], state: ReducedState): string | null {
|
||||
return state.pending_elicitation ? lastRowIdOfKind(rows, "elicitation") : null;
|
||||
}
|
||||
|
||||
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
return null;
|
||||
@@ -168,6 +173,7 @@ export function activityGroupsByTurn(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
_turnActive: boolean,
|
||||
pendingElicitationId: string | null = null,
|
||||
): Map<string, ActivityGroup> {
|
||||
const groups = new Map<string, ActivityGroup>();
|
||||
let turnId = "before-first-message";
|
||||
@@ -204,8 +210,10 @@ export function activityGroupsByTurn(
|
||||
flushRun();
|
||||
continue;
|
||||
}
|
||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
||||
if (isResolvedApproval) {
|
||||
const isResolvedInteraction =
|
||||
(row.kind === "approval" && row.id !== pendingApprovalId) ||
|
||||
(row.kind === "elicitation" && row.id !== pendingElicitationId);
|
||||
if (isResolvedInteraction) {
|
||||
runPending.push(row);
|
||||
continue;
|
||||
}
|
||||
@@ -239,8 +247,9 @@ export function transcriptItems(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
pendingElicitationId: string | null = null,
|
||||
): TranscriptItem[] {
|
||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
|
||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive, pendingElicitationId);
|
||||
// Reverse index: row id → group id that contains it.
|
||||
const rowToGroup = new Map<string, string>();
|
||||
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
|
||||
@@ -259,9 +268,10 @@ export function transcriptItems(
|
||||
}
|
||||
if (row.kind === "plan") continue;
|
||||
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
||||
// A resolved approval outside a group adds nothing the tool row does not
|
||||
// already say. A pending one always renders.
|
||||
// A resolved approval/elicitation outside a group adds nothing the tool
|
||||
// row does not already say. A pending one always renders.
|
||||
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
||||
if (row.kind === "elicitation" && row.id !== pendingElicitationId) continue;
|
||||
items.push({ type: "row", row });
|
||||
}
|
||||
|
||||
|
||||
@@ -913,6 +913,90 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Elicitation: the structured question the agent asks the operator.
|
||||
Visually parallel to approvals but accent-tinted instead of warn-tinted. */
|
||||
.st-elicitation {
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--r-2);
|
||||
background: color-mix(in oklab, var(--accent) 10%, var(--surface));
|
||||
box-shadow: var(--shadow-1);
|
||||
padding: var(--sp-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.st-elicitation-title {
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-elicitation-options {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.st-elicitation-acts {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.st-elicitation-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-elicitation-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.st-elicitation-field input[type="text"],
|
||||
.st-elicitation-field input[type="number"] {
|
||||
height: 30px;
|
||||
padding: 0 var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.st-elicitation-field input[type="checkbox"] {
|
||||
margin-right: var(--sp-1);
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.st-elicitation-required {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.st-elicitation-desc {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-11);
|
||||
}
|
||||
|
||||
.st-elicitation-compact {
|
||||
font-size: var(--fs-11);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.st-row-elicitation:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.act {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
|
||||
@@ -67,6 +67,12 @@ export function structuredApproval(id: Uuid, nonce: string): string {
|
||||
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/elicitations/:nonce` — answer an
|
||||
* elicitation request. */
|
||||
export function structuredElicitation(id: Uuid, nonce: string): string {
|
||||
return `${session(id)}/structured/elicitations/${encodeURIComponent(nonce)}`;
|
||||
}
|
||||
|
||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||
export function withPathQuery(route: string, value: string): string {
|
||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||
|
||||
@@ -395,6 +395,7 @@ export type TranscriptRowKind =
|
||||
| "tool_call"
|
||||
| "tool_diff"
|
||||
| "approval"
|
||||
| "elicitation"
|
||||
| "plan"
|
||||
| "notice";
|
||||
|
||||
@@ -435,6 +436,7 @@ export interface TurnState {
|
||||
export interface ReducedState {
|
||||
turn: TurnState;
|
||||
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
||||
pending_elicitation?: { nonce: string; message?: string; schema?: AcpElicitationSchema } | null;
|
||||
plan?: AcpPlan | null;
|
||||
usage?: AcpUsage | null;
|
||||
context?: AcpUsage | null;
|
||||
@@ -486,6 +488,35 @@ export interface AcpSessionConfigOption {
|
||||
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
||||
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
||||
|
||||
/** `POST /api/sessions/:id/structured/elicitations/:nonce` body. */
|
||||
export type StructuredElicitationAction =
|
||||
| { action: "accept"; content: Record<string, string | number | boolean> }
|
||||
| { action: "decline" }
|
||||
| { action: "cancel" };
|
||||
|
||||
/** Property schema within an ACP elicitation request. */
|
||||
export interface AcpElicitationProperty {
|
||||
type?: "string" | "number" | "integer" | "boolean";
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: string | number | boolean;
|
||||
enum?: string[];
|
||||
oneOf?: Array<{ const: string; title: string; description?: string }>;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
}
|
||||
|
||||
/** The `requested_schema` carried by an ACP `elicitation/create` request. */
|
||||
export interface AcpElicitationSchema {
|
||||
type?: "object";
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties: Record<string, AcpElicitationProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
||||
|
||||
export interface AcpEmbeddedResource {
|
||||
|
||||
Reference in New Issue
Block a user