Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,16 +552,34 @@ mechanisms based on client capabilities:
"properties": {
"q_0": {
"type": "string",
"enum": ["auth.test.ts", "user.test.ts"],
"title": "Select the files to test"
"title": "Select the files to test",
"oneOf": [
{ "const": "auth.test.ts", "title": "auth.test.ts" },
{ "const": "user.test.ts", "title": "user.test.ts" },
{ "const": "__skip__", "title": "Skip this question" }
]
},
"q_0_other": {
"type": "string",
"title": "↳ or type a custom value (overrides the selection)"
}
},
"required": ["q_0"]
"required": []
}
}
}
```

ACP/MCP elicitation string fields are EITHER an enum (restricted dropdown) OR
free text — the spec forbids a single field that is both. So each question is
rendered as TWO fields: `q_<i>` (a `oneOf`/`anyOf` enum dropdown of the model's
suggested answers, with a trailing "Skip this question" option whose `const` is
the `__skip__` sentinel and whose `title` is the readable label) and
`q_<i>_other` (a free-text companion). On submit, a non-empty `q_<i>_other`
overrides the dropdown (single-select) or is appended to the picked values
(multi-select); selecting "Skip this question" or leaving both blank skips just
that question without cancelling the form.

**elicitation response** (accept/decline/cancel):
```json
{
Expand All @@ -570,6 +588,34 @@ mechanisms based on client capabilities:
}
```

**ExitPlanMode elicitation form** — single `feedback` text field; no
approve/reject dropdown. The client's own submit button is the approve action;
typing into the field is the reject action. Submitting with the field empty
approves the plan; submitting with text rejects it and returns the text to
zcode as the decline `reason` (so the agent sees the redirection when it
re-plans). The cancel/decline button is a plain reject with no reason.
```json
{
"method": "elicitation/create",
"params": {
"mode": "form",
"sessionId": "sess_abc123",
"message": "Ready to code?\n\n1. Implement login\n2. Implement signup\n\nLeave the box empty and submit to approve; type feedback to reject and redirect.",
"requestedSchema": {
"type": "object",
"properties": {
"feedback": {
"type": "string",
"title": "Feedback",
"description": "Empty = approve the plan. Anything typed = reject and use this text as the redirection."
}
},
"required": []
}
}
}
```

## Extension Methods (0.14.8+)

### `session/fork`
Expand Down
148 changes: 103 additions & 45 deletions src/interaction/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,18 +256,30 @@ export function parseAskUserResponse(acpResp: unknown): string | null {

// ---------- elicitation form (preferred when client supports it) ----------

/**
* Sentinel `const` value marking a single-select question as skipped in the
* elicitation form. Paired with a human-readable `title` ("Skip this question")
* via `oneOf`, so the client renders the label — never the raw sentinel.
*/
const ELICIT_SKIP = "__skip__";

/**
* Build an elicitation form schema for an AskUserQuestion request.
*
* Each question becomes one property in the form:
* - Single-select → string enum of labels + a trailing "Skip" option
* - Multi-select → string array enum of labels
* ACP/MCP elicitation string fields are EITHER an enum (restricted dropdown) OR
* free text — the spec ("enum strictly restricts the allowed string values")
* forbids "dropdown whose last row accepts custom input". To give the user both
* a pick list AND custom entry, each question is rendered as TWO fields:
*
* - `q_<i>`: enum dropdown of the model's suggested answers + a trailing
* "Skip this question" option. Uses `oneOf`/`anyOf` with `{const, title}`
* so the skip sentinel has a readable label instead of the raw `__skip__`.
* - `q_<i>_other`: free-text string. If non-empty it OVERRIDES the dropdown
* (single-select) or is APPENDED to the picked values (multi-select).
*
* When the client supports form-based elicitation, this lets the user answer
* all questions in one form rather than one popup per question. Single-select
* fields include a "Skip" option so the user can opt out of a question without
* cancelling the whole form (mirrors the request_permission fallback, which
* appends a Skip option per question).
* A non-required free-text field plus a Skip enum option lets the user opt out
* of a single question without cancelling the whole form. Required is left
* empty so neither field forces an answer.
*/
export function buildAskUserElicitationForm(
params: ZcodeInteractionUserInputParams,
Expand All @@ -286,7 +298,6 @@ export function buildAskUserElicitationForm(
} {
const questions = params.questions?.length ? params.questions : (params.input?.questions ?? []);
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (!q || typeof q.question !== "string") continue;
Expand All @@ -295,22 +306,33 @@ export function buildAskUserElicitationForm(
.map((o) => o.label ?? o.value ?? "")
.filter((l) => l.length > 0);
if (labels.length === 0) continue;
// Titled enum option for "skip": const carries the sentinel value the
// parser recognizes; title is what the client renders in the dropdown.
const skipOption = { const: ELICIT_SKIP, title: "Skip this question" };
if (q.multiSelect) {
properties[key] = {
type: "array",
items: { type: "string", enum: labels },
title: q.question,
items: { anyOf: labels.map((l) => ({ const: l, title: l })) },
};
} else {
// Append a Skip option so the user can opt out of this question without
// cancelling the whole form (parity with the request_permission path).
properties[key] = {
type: "string",
enum: [...labels, ELICIT_SKIP],
title: q.question,
oneOf: [...labels.map((l) => ({ const: l, title: l })), skipOption],
};
required.push(key);
}
// Companion free-text field. Zed renders a string field's title
// prominently, and falls back to the description when title is absent — so
// a bright label here is unavoidable. Prefix with "↳" + padding so it reads
// as a continuation of the question above, and make the title self-contained
// (no description needed).
properties[`${key}_other`] = {
type: "string",
title: q.multiSelect
? "↳ or add a custom value (combined with the selection)"
: "↳ or type a custom value (overrides the selection)",
};
}
// The question text already appears as each field's `title` (its label), so
// the form `message` must NOT repeat it — otherwise Zed shows the question
Expand All @@ -335,21 +357,23 @@ export function buildAskUserElicitationForm(
mode: "form",
sessionId: acpSid,
message,
requestedSchema: { type: "object", properties, required },
// No required fields: the user can skip any question (via Skip option or
// blank free-text) without being blocked by validation.
requestedSchema: { type: "object", properties, required: [] },
};
if (toolCallId) form.toolCallId = toolCallId;
return form;
}

/** Sentinel enum value marking a single-select question as skipped in the form. */
const ELICIT_SKIP = "__skip__";

/**
* Parse an elicitation form response into the zcode answers map.
*
* Maps each `q_<i>` form field back to the original question text. Multi-select
* answers (arrays) are comma-joined to match the request_permission path's
* `"a, b"` format. Returns null if the user declined/cancelled.
* For each question the free-text companion (`q_<i>_other`) takes precedence:
* - Single-select: non-empty free-text OVERRIDES the dropdown. Otherwise the
* dropdown value is used, unless it's the skip sentinel or absent (omitted).
* - Multi-select: free-text is APPENDED to the picked values (de-duped, order
* preserved). The skip sentinel never appears in multi-select.
* Returns null if the user declined/cancelled.
*/
export function parseAskUserElicitationResponse(
acpResp: unknown,
Expand All @@ -364,25 +388,46 @@ export function parseAskUserElicitationResponse(
const q = questions[i];
if (!q || typeof q.question !== "string") continue;
const key = `q_${i}`;
const val = resp.content[key];
if (val == null) continue;
if (Array.isArray(val)) {
answers[q.question] = val.filter((v) => typeof v === "string").join(", ");
} else if (typeof val === "string" && val !== ELICIT_SKIP) {
// Skip the sentinel "__skip__" so a skipped single-select question is
// simply omitted from the answers map (parity with the request_permission
// path, which leaves the question unanswered on Skip).
answers[q.question] = val;
const otherKey = `${key}_other`;
const picked = resp.content[key];
const otherRaw = resp.content[otherKey];
const other = typeof otherRaw === "string" ? otherRaw.trim() : "";

if (q.multiSelect) {
const arr = Array.isArray(picked)
? picked.filter((v): v is string => typeof v === "string" && v.length > 0)
: [];
// Merge selected values + custom free-text, preserving order and dropping dups.
const merged: string[] = [];
for (const v of [...arr, ...(other ? [other] : [])]) {
if (!merged.includes(v)) merged.push(v);
}
if (merged.length > 0) answers[q.question] = merged.join(", ");
} else {
// Free-text override wins when present; else use the dropdown value
// (skip sentinel / absent → question left unanswered).
if (other) {
answers[q.question] = other;
} else if (typeof picked === "string" && picked.length > 0 && picked !== ELICIT_SKIP) {
answers[q.question] = picked;
}
}
}
return answers;
}

/**
* Build an elicitation form for ExitPlanMode (approve/reject).
* Build an elicitation form for ExitPlanMode.
*
* Single `feedback` text field — no approve/reject dropdown. The ACP client
* always renders its own submit/cancel controls, so:
* - submit with feedback empty → approve (proceed with the plan)
* - submit with feedback filled → reject, and the text becomes the decline
* `reason` the agent sees when re-planning
* - cancel/decline button → plain reject (no reason)
*
* Uses a single required boolean-style enum so the client renders a clear
* approve/reject choice in the form UI.
* Typing into the field IS the reject signal — a separate dropdown would be
* redundant (its value is forced by whether feedback is present).
*/
export function buildExitPlanModeElicitationForm(
params: ZcodeInteractionUserInputParams,
Expand All @@ -403,7 +448,10 @@ export function buildExitPlanModeElicitationForm(
params.input && typeof params.input === "object"
? ((params.input as { plan?: string }).plan ?? "")
: "";
const message = planText ? `Ready to code?\n\n${planText}` : "Ready to code?";
const suffix = "Leave the box empty and submit to approve; type feedback to reject and redirect.";
const message = planText
? `Ready to code?\n\n${planText}\n\n${suffix}`
: `Ready to code?\n\n${suffix}`;
const form: {
mode: "form";
sessionId: string;
Expand All @@ -421,34 +469,44 @@ export function buildExitPlanModeElicitationForm(
requestedSchema: {
type: "object",
properties: {
decision: {
feedback: {
type: "string",
enum: ["approve", "reject"],
title: "Decision",
description: "Approve to exit plan mode and start coding, or reject to keep planning.",
title: "Feedback",
description:
"Empty = approve the plan. Anything typed = reject and use this text as the redirection.",
},
},
required: ["decision"],
// Not required: an empty submit is a valid "approve".
required: [],
},
};
if (toolCallId) form.toolCallId = toolCallId;
return form;
}

/** Parse an ExitPlanMode elicitation response → zcode accept/decline. */
/**
* Parse an ExitPlanMode elicitation response → zcode accept/decline.
*
* Accept with non-empty feedback → decline carrying the feedback as `reason`
* (the user typed a redirection). Accept with empty feedback → approve.
* Decline/cancel from the client → plain decline.
*/
export function parseExitPlanModeElicitationResponse(acpResp: unknown): {
action: "accept" | "decline";
reason?: string;
content?: unknown;
} {
if (!acpResp || typeof acpResp !== "object") {
return { action: "decline", reason: "invalid client response" };
}
const resp = acpResp as { action?: string; content?: { decision?: string } | null };
if (resp.action !== "accept" || !resp.content) {
const resp = acpResp as { action?: string; content?: { feedback?: string } | null };
if (resp.action !== "accept") {
return { action: "decline", reason: "cancelled" };
}
if (resp.content.decision === "approve") {
return { action: "accept", content: { answer_0: "approve" } } as never;
const feedback = typeof resp.content?.feedback === "string" ? resp.content.feedback.trim() : "";
if (feedback) {
return { action: "decline", reason: feedback };
}
return { action: "decline", reason: "rejected" };
// content must be an object with answer_0 (zcode reads content.answer_0).
return { action: "accept", content: { answer_0: "approve" } };
}
Loading
Loading