Merge pull request #471 from andrewyng/issue/ope-51-ask_user-upgrades

Enhancements to forms shown by agent for asking Qs from User
This commit is contained in:
Rohit Prasad
2026-08-07 19:16:21 -07:00
committed by GitHub
12 changed files with 930 additions and 100 deletions
+8 -1
View File
@@ -971,6 +971,13 @@ class TurnEngine:
from the Inbox when unattended), and return it as the tool result."""
args = tool_call.arguments or {}
question = str(args.get("question", "")).strip()
# Grouped form (OPE-51): `questions` alone is a valid call — the singular field may be
# empty. The asker normalizes/validates the entries; here only "is anything asked?".
if not question:
for entry in args.get("questions") or []:
if isinstance(entry, dict) and str(entry.get("question", "")).strip():
question = str(entry["question"]).strip()
break
if self.question_asker is None or not question:
result: dict[str, Any] = {
"answer": "",
@@ -992,7 +999,7 @@ class TurnEngine:
"error": "no response",
}
status = "ok" if result.get("answer") else "denied"
status = "ok" if (result.get("answer") or result.get("answers")) else "denied"
self.messages.append(_tool_result_message(tool_call, result))
self._audit(
tool_call,
+17 -1
View File
@@ -78,11 +78,19 @@ class InboxItem:
tool_call_id: Optional[str] = None
# Question metadata (ask_user): optional quick-reply choices + a free-text escape, mirroring
# the structured-but-always-answerable shape of Claude Code's AskUserQuestion.
options: list[str] = field(default_factory=list)
# An option is a plain string OR a rich {label, description, recommended, preview} object
# (OPE-51); old persisted items hold strings and stay valid.
options: list = field(default_factory=list)
allow_text: bool = (
True # accept a typed answer even when options exist (the "Other" escape)
)
multi: bool = False # allow choosing more than one option
header: str = "" # short chip label for the card ("Region")
# Grouped form (OPE-51): up to 4 {question, header, options, allow_text, multi} entries
# rendered as a stepper. When non-empty the singular title/options fields above still hold
# the FIRST question (so old surfaces and channel mirrors degrade to something sensible),
# and the resolution is a JSON object string keyed by header-or-question.
questions: list[dict] = field(default_factory=list)
# Kind-specific payload (directory: suggested path/writable; plan: the plan text; …).
data: dict[str, Any] = field(default_factory=dict)
@@ -126,6 +134,8 @@ class InboxStore:
options=None,
allow_text: bool = True,
multi: bool = False,
header: str = "",
questions=None,
tool_call_id: Optional[str] = None,
) -> InboxItem:
# Idempotent by (session_id, tool_call_id): a durable resume re-raises the same prompt, and
@@ -146,6 +156,8 @@ class InboxStore:
options=list(options or []),
allow_text=bool(allow_text),
multi=bool(multi),
header=str(header or ""),
questions=list(questions or []),
tool_call_id=tool_call_id,
)
with self._lock:
@@ -194,6 +206,8 @@ class InboxStore:
options=None,
allow_text=True,
multi=False,
header="",
questions=None,
tool_call_id=None,
) -> InboxItem:
return self.add(
@@ -206,6 +220,8 @@ class InboxStore:
options=options,
allow_text=allow_text,
multi=multi,
header=header,
questions=questions,
tool_call_id=tool_call_id,
)
+11 -2
View File
@@ -17,6 +17,7 @@ from dataclasses import dataclass
from typing import Optional
from .inbox import KIND_APPROVAL, KIND_QUESTION
from .tools.ask import option_label
@dataclass
@@ -48,7 +49,15 @@ def buttons_for(item) -> list[Button]:
Button("Approve", encode(item.id, "allow")),
Button("Deny", encode(item.id, "deny")),
]
if item.kind == KIND_QUESTION and getattr(item, "questions", None):
# Grouped questions (OPE-51): one button row can't answer 2+ questions — send plain text
# with the open-the-app hint instead.
return []
if item.kind == KIND_QUESTION and getattr(item, "options", None):
# One button per option; the resolution IS the chosen option text (what the agent gets).
return [Button(opt, encode(item.id, opt)) for opt in item.options]
# One button per option; the resolution IS the chosen option's label (what the agent
# gets). Rich {label, description, …} options button as their label.
return [
Button(option_label(opt), encode(item.id, option_label(opt)))
for opt in item.options
]
return []
+9 -6
View File
@@ -1603,15 +1603,17 @@ def create_app(manager: SessionManager) -> FastAPI:
async def question_asker(args: dict, tool_call_id=None) -> dict:
# ask_user (engine does NOT emit the event — we do, only when attended).
from ..tools.ask import answer_result, question_item_fields
fields = question_item_fields(args)
if fields is None: # engine guards too; belt-and-braces
return {"answer": "", "error": "no question"}
item = manager.inbox.add_question(
session_id,
str(args.get("question", "")),
inbox=_route(),
visibility=_visibility(),
options=list(args.get("options") or []),
allow_text=bool(args.get("allow_text", True)),
multi=bool(args.get("multi", False)),
tool_call_id=tool_call_id,
**fields,
)
if item.state == "pending":
manager.persist_session(session_id)
@@ -1626,11 +1628,12 @@ def create_app(manager: SessionManager) -> FastAPI:
"options": item.options,
"allow_text": item.allow_text,
"multi": item.multi,
"header": str(args.get("header", "")),
"header": item.header,
"questions": item.questions,
},
}
)
return {"answer": await manager.inbox.wait(item.id)}
return answer_result(item.questions, await manager.inbox.wait(item.id))
async def directory_requester(args: dict, tool_call_id=None) -> dict:
# The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant.
+7 -8
View File
@@ -751,27 +751,26 @@ class SessionManager:
async def ask(
args: dict[str, Any], tool_call_id: Optional[str] = None
) -> dict[str, Any]:
question = str(args.get("question", "")).strip()
if not question:
from ..tools.ask import answer_result, question_item_fields
fields = question_item_fields(args)
if fields is None:
return {"answer": "", "error": "no question"}
inbox_name = self.inbox_routing.route_for(session_id, agent)
item = self.inbox.add_question(
session_id,
title=question,
inbox=inbox_name,
options=list(args.get("options") or []),
allow_text=bool(args.get("allow_text", True)),
multi=bool(args.get("multi", False)),
tool_call_id=tool_call_id,
**fields,
)
if (
item.state != "pending"
): # durable resume re-raised an already-answered prompt
return {"answer": item.resolution or ""}
return answer_result(item.questions, item.resolution)
self.persist_session(session_id) # the pending tool call is now on disk
await self.mirror_inbox_item(item)
answer = await self.inbox.wait(item.id)
return {"answer": answer}
return answer_result(item.questions, answer)
return ask
+211 -14
View File
@@ -6,36 +6,136 @@ plus `multi` for choose-several. Like `request_directory`, it's intercepted by t
question becomes an Inbox item (answerable inline in the live session, or from the Inbox when the
session runs unattended), the agent suspends until it's resolved, and the answer comes back as the
tool result. The callable here is only a schema carrier + a safe fallback.
OPE-51 upgrades: options may be rich objects ({label, description, recommended, preview}) instead
of plain strings, and `questions` groups up to 4 questions into ONE call (rendered as a stepper
one agent round-trip instead of several). Plain-string options and the singular `question` form
stay valid: old sessions and simple asks render exactly as before.
"""
from __future__ import annotations
import json
from aisuite.agents import ToolMetadata, tool
# How many questions one grouped call may carry (stepper chips get unreadable past this).
MAX_GROUPED_QUESTIONS = 4
# An option is a plain string OR a rich object. `label` is what the user picks (and what comes
# back as the answer); `description` renders under it; `recommended` adds the green tag (put the
# recommended option first); `preview` is monospace text shown in the side pane (code, config,
# ASCII mockups, SQL — any text; when ≥1 option has one the card switches to two-pane layout).
_OPTION_SCHEMA = {
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"label": {"type": "string"},
"description": {"type": "string"},
"recommended": {"type": "boolean"},
"preview": {"type": "string"},
},
"required": ["label"],
},
]
}
# Explicit schema (same pattern as todo.py): the string-or-object option union and the nested
# `questions` array can't be auto-generated from the signature reliably.
_ASK_SCHEMA = {
"type": "function",
"function": {
"name": "ask_user",
"description": (
"Ask the user one or more questions and wait for their answer. Use for decisions or "
"information only the user can provide. Group related questions (up to "
f"{MAX_GROUPED_QUESTIONS}) into one call via `questions` instead of asking serially."
),
"parameters": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The full question, in plain language (single-question form).",
},
"options": {
"type": "array",
"items": _OPTION_SCHEMA,
"description": (
"Optional quick-reply choices: plain strings, or objects with `label` "
"(required — this is the answer value), `description` (why/when to pick "
"it), `recommended` (green tag; list that option first), and `preview` "
"(monospace text — code, config, a mockup — shown in a side pane)."
),
},
"allow_text": {
"type": "boolean",
"description": (
"Keep a free-text answer available even when options exist (default true; "
"the \"Other / type your own\" escape). Set false only when the options "
"are exhaustive."
),
},
"multi": {
"type": "boolean",
"description": "Allow the user to pick more than one option.",
},
"header": {
"type": "string",
"description": "Short (≤ ~12 char) chip label for the card, e.g. \"Region\".",
},
"questions": {
"type": "array",
"maxItems": MAX_GROUPED_QUESTIONS,
"items": {
"type": "object",
"properties": {
"question": {"type": "string"},
"header": {
"type": "string",
"description": (
"Short (≤ ~12 char) label — names this step in the stepper "
"chips and keys its answer in the result."
),
},
"options": {"type": "array", "items": _OPTION_SCHEMA},
"allow_text": {"type": "boolean"},
"multi": {"type": "boolean"},
},
"required": ["question"],
},
"description": (
f"Grouped form: up to {MAX_GROUPED_QUESTIONS} questions asked in ONE "
"round-trip, rendered as a stepper. When set, the singular "
"question/options fields are ignored."
),
},
},
"required": [],
},
},
}
def ask_user_tool() -> object:
def ask_user(
question: str,
options: list[str] | None = None,
question: str = "",
options: list | None = None,
allow_text: bool = True,
multi: bool = False,
header: str = "",
questions: list | None = None,
) -> dict:
"""Ask the user a question and wait for their answer — use when you genuinely need a human
decision or information you can't infer (a preference, a missing fact, a choice between real
alternatives). Prefer this over guessing or stalling.
- `question`: the full question, in plain language.
- `options`: optional quick-reply choices. Offer them when the answer is one of a few
discrete alternatives; leave empty for an open-ended question.
- `allow_text`: keep a free-text answer available even when you give options (the default;
this is the "Other / type your own" escape). Set False only when the options are
exhaustive and a typed answer would be meaningless.
- `multi`: allow the user to pick more than one option.
- `header`: a short ( ~12 char) label for the Inbox card chip, e.g. "Region".
Returns `{"answer": "..."}` the chosen option(s) or the typed text. Don't ask what you can
reasonably decide yourself; reserve this for choices that are actually the user's to make.
Single form returns `{"answer": "..."}` the chosen option label(s) or the typed text.
Grouped form (`questions`) returns `{"answers": {"<header or question>": "..."}}` one
entry per question. Don't ask what you can reasonably decide yourself; reserve this for
choices that are actually the user's to make.
"""
# Real handling lives in the engine (it needs the out-of-band Inbox round-trip). This body
# only runs if no question_asker is wired (e.g. a headless surface).
@@ -44,7 +144,7 @@ def ask_user_tool() -> object:
"error": "asking the user isn't available in this surface",
}
return tool(
wrapped = tool(
ask_user,
metadata=ToolMetadata(
category="interaction",
@@ -56,3 +156,100 @@ def ask_user_tool() -> object:
),
),
)
wrapped.__coworker_schema__ = _ASK_SCHEMA
return wrapped
def normalize_option(opt) -> dict:
"""One option in canonical dict form: {label, description, recommended, preview}. Plain
strings become {label: str, ...empty}. The label doubles as the answer value everywhere
(buttons, pills, resolutions), so it is always a non-empty-able str."""
if isinstance(opt, dict):
return {
"label": str(opt.get("label", "")),
"description": str(opt.get("description", "")),
"recommended": bool(opt.get("recommended", False)),
"preview": str(opt.get("preview", "")),
}
return {"label": str(opt), "description": "", "recommended": False, "preview": ""}
def option_label(opt) -> str:
"""The answer value / button text for a str-or-dict option."""
return str(opt.get("label", "")) if isinstance(opt, dict) else str(opt)
def normalize_questions(raw) -> list[dict]:
"""The grouped `questions` arg in canonical form (capped, blanks dropped). Each entry:
{question, header, options: [canonical option], allow_text, multi}."""
out: list[dict] = []
for entry in list(raw or [])[:MAX_GROUPED_QUESTIONS]:
if not isinstance(entry, dict):
continue
q = str(entry.get("question", "")).strip()
if not q:
continue
out.append(
{
"question": q,
"header": str(entry.get("header", "")),
"options": [normalize_option(o) for o in entry.get("options") or []],
"allow_text": bool(entry.get("allow_text", True)),
"multi": bool(entry.get("multi", False)),
}
)
return out
def question_item_fields(args: dict) -> dict | None:
"""`InboxStore.add_question` kwargs from raw ask_user args, or None when nothing was asked.
A grouped call surfaces its FIRST question as title/options too, so legacy surfaces (channel
mirrors, old persisted-item readers) degrade to a sensible single question."""
grouped = normalize_questions(args.get("questions"))
if grouped:
first = grouped[0]
return {
"title": first["question"],
"options": first["options"],
"allow_text": first["allow_text"],
"multi": first["multi"],
"header": first["header"],
"questions": grouped,
}
question = str(args.get("question", "")).strip()
if not question:
return None
return {
"title": question,
# Strings pass through untouched (simple asks keep rendering as today's pills);
# rich objects are canonicalized so downstream never meets a half-filled dict.
"options": [
o if isinstance(o, str) else normalize_option(o)
for o in args.get("options") or []
],
"allow_text": bool(args.get("allow_text", True)),
"multi": bool(args.get("multi", False)),
"header": str(args.get("header", "")),
"questions": [],
}
def answer_result(item_questions: list, resolution: str | None) -> dict:
"""Shape the ask_user tool result from an Inbox item's resolution string. Grouped items
resolve with a JSON object string keyed by header-or-question `{"answers": {...}}`;
everything else returns the plain `{"answer": str}` shape."""
if item_questions:
try:
parsed = json.loads(resolution or "")
except (ValueError, TypeError):
parsed = None
if isinstance(parsed, dict):
return {"answers": {str(k): str(v) for k, v in parsed.items()}}
if resolution:
# Answered from a text-only surface (e.g. a mirrored channel): attribute the lone
# answer to the first question rather than losing it.
first = item_questions[0] if isinstance(item_questions[0], dict) else {}
key = str(first.get("header") or first.get("question") or "answer")
return {"answers": {key: str(resolution)}}
return {"answer": ""}
return {"answer": resolution or ""}
+153
View File
@@ -0,0 +1,153 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
// OPE-51 — ask_user upgrades: rich options (descriptions, the Recommended tag, monospace
// previews with the two-pane layout) and grouped questions (the stepper). Seeded via a per-test
// inbox route override (later routes match first) so the base fixtures' counts — which
// inbox.spec.ts pins — stay untouched.
const BASE = {
body: "",
state: "pending",
resolution: null as string | null,
inbox: "default",
created_at: "2026-07-29 08:00:00",
resolved_at: null as string | null,
session_title: "Investigate alerts",
session_agent: "ops",
session_workspace: "",
session_exists: true,
};
const RICH_ITEM = {
...BASE,
id: "inb-question-rich",
session_id: "ops-1",
kind: "question",
title: "How should I format the report?",
header: "Format",
options: [
{
label: "Markdown table",
description: "Compact and renders in the app",
recommended: true,
preview: "| env | status |\n| --- | --- |\n| staging | ok |",
},
{
label: "Plain text",
description: "Safest for email forwarding",
preview: "env: staging\nstatus: ok",
},
],
allow_text: true,
multi: false,
questions: [],
};
const GROUPED_ITEM = {
...BASE,
id: "inb-question-grouped",
session_id: "ops-1",
kind: "question",
// The first question doubles as title/options (legacy-surface degradation, server parity).
title: "Chart style?",
header: "Chart style",
options: ["Bar", "Line"],
allow_text: false,
multi: false,
questions: [
{ question: "Chart style?", header: "Chart style", options: ["Bar", "Line"], allow_text: false, multi: false },
{ question: "Which distribution?", header: "Distribution", options: ["Stacked", "Grouped"], allow_text: true, multi: false },
],
};
/** Replace the Inbox's seeded items for this test (resolve mutates the local copy). */
async function seedInbox(page: Page, items: Record<string, unknown>[]) {
const inbox = items.map((i) => ({ ...i }));
const json = (body: unknown) => ({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
await page.route(/\/v1\/inbox\/[^/]+\/resolve$/, (route) => {
const path = new URL(route.request().url()).pathname;
const id = decodeURIComponent(path.split("/").slice(-2)[0]);
const it = inbox.find((x) => x.id === id);
if (it) {
it.state = "resolved";
it.resolution = route.request().postDataJSON().resolution;
}
return route.fulfill(json({ ok: true }));
});
await page.route(/\/v1\/inbox(\?.*)?$/, (route) =>
route.fulfill(json({ items: inbox.filter((i) => i.state === "pending") })),
);
return inbox;
}
async function openInbox(page: Page, expectTitle: string) {
await page.goto("/");
await page.getByTestId("inbox-chip").click();
await expect(page.getByText(expectTitle)).toBeVisible();
}
test("rich options render descriptions + Recommended; the preview pane follows hover", async ({
page,
}) => {
await seedInbox(page, [RICH_ITEM]);
await openInbox(page, "How should I format the report?");
await expect(page.getByText("Compact and renders in the app")).toBeVisible();
await expect(page.getByText("Recommended")).toBeVisible();
// The pane opens on the first option holding a preview…
const pane = page.getByTestId("question-preview");
await expect(pane).toContainText("| env | status |");
// …and follows hover to the other option.
await page.getByRole("button", { name: /Plain text/ }).hover();
await expect(pane).toContainText("env: staging");
// Single-select still resolves on click, with the option's LABEL as the resolution.
const resolved = page.waitForRequest(
(r) => r.url().includes("/resolve") && r.method() === "POST",
);
await page.getByRole("button", { name: /Markdown table/ }).click();
expect((await resolved).postDataJSON().resolution).toBe("Markdown table");
await expect(page.getByText("How should I format the report?")).not.toBeVisible();
});
test("grouped questions step through the header chips and resolve as one answer map", async ({
page,
}) => {
await seedInbox(page, [GROUPED_ITEM]);
await openInbox(page, "Chart style?");
// Step 1: "Chart style · 1 of 2 · Distribution " — and no free-text row (allow_text: false).
const stepper = page.getByTestId("question-stepper");
await expect(stepper).toContainText("Chart style");
await expect(stepper).toContainText("1 of 2");
await expect(stepper).toContainText("Distribution ");
await expect(page.getByPlaceholder("Or type your own answer…")).not.toBeVisible();
// Answering advances to step 2 (its free-text escape is back — allow_text: true).
await page.getByRole("button", { name: "Bar", exact: true }).click();
await expect(stepper).toContainText("2 of 2");
await expect(page.getByText("Which distribution?")).toBeVisible();
await expect(page.getByPlaceholder("Or type your own answer…")).toBeVisible();
// steps back with the first answer re-askable; answer forward again.
await page.getByRole("button", { name: "Previous question" }).click();
await expect(stepper).toContainText("1 of 2");
await page.getByRole("button", { name: "Bar", exact: true }).click();
await expect(stepper).toContainText("2 of 2");
// The final answer resolves the whole card with a JSON map keyed by header.
const resolved = page.waitForRequest(
(r) => r.url().includes("/resolve") && r.method() === "POST",
);
await page.getByRole("button", { name: "Stacked", exact: true }).click();
expect((await resolved).postDataJSON().resolution).toBe(
JSON.stringify({ "Chart style": "Bar", Distribution: "Stacked" }),
);
await expect(page.getByText("Nothing pending.")).toBeVisible();
});
+4
View File
@@ -695,6 +695,8 @@ export function App() {
options: d.options || [],
allow_text: d.allow_text !== false,
multi: !!d.multi,
header: d.header || "",
questions: d.questions || [],
},
]);
break;
@@ -1645,6 +1647,8 @@ export function App() {
options: pendingQuestion.options,
allow_text: pendingQuestion.allow_text,
multi: pendingQuestion.multi,
header: pendingQuestion.header,
questions: pendingQuestion.questions,
}}
onResolve={(_id, answer) => answerQuestion(answer)}
compact
+7 -3
View File
@@ -1,4 +1,4 @@
import type { SessionInfo, WsEvent } from "./types";
import type { GroupedQuestion, QuestionOption, SessionInfo, WsEvent } from "./types";
declare const __COWORKER_DEV_TOKEN__: string;
@@ -1239,10 +1239,14 @@ export interface InboxItem {
created_at: string;
resolved_at: string | null;
visibility?: "inline" | "inbox";
// Question metadata (ask_user): quick-reply choices + a free-text escape.
options?: string[];
// Question metadata (ask_user): quick-reply choices + a free-text escape. Options may be rich
// {label, description, recommended, preview} objects (OPE-51); `questions` is the grouped form
// (stepper), whose resolution is a JSON object string keyed by header-or-question.
options?: QuestionOption[];
allow_text?: boolean;
multi?: boolean;
header?: string;
questions?: GroupedQuestion[];
// Kind-specific payload (directory: {path, writable}; …).
data?: Record<string, any>;
// Originating-session context (server-joined) so the Inbox is self-contained.
+264 -64
View File
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from "react";
import type { InboxItem } from "../api";
import type { QuestionOption } from "../types";
import { humanizeApprovalTitle } from "../humanize";
import {
approvalActionLabels,
@@ -12,7 +13,9 @@ import {
// One Inbox item, rendered identically in the Inbox list and inline in its own session view
// (answer-in-context). Resolving either place hits the same item id — first responder wins.
// Questions (ask_user) mirror Claude Code's AskUserQuestion: optional quick-reply options + an
// always-available free-text escape, with optional multi-select.
// always-available free-text escape, with optional multi-select. OPE-51 adds rich options
// ({label, description, recommended, preview}) and grouped questions (a stepper) — plain-string
// options and single questions render exactly as before.
// Shared styles (mock parity — same language as SourcesDrawer/PersonaView).
const SEC = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
@@ -30,6 +33,260 @@ const OPT_OFF = "border-line bg-paper text-ink hover:border-accent hover:bg-acce
const OPT_ON = "border-accent bg-accentSoft text-accent font-medium";
const INPUT =
"flex-1 min-w-0 rounded-lg bg-paper border border-line px-3 py-2 text-[13px] text-ink placeholder:text-faint outline-none focus:border-lineStrong";
// Rich options stack as full-width rows (pills can't hold a description line).
const ROW_BASE = "w-full text-left rounded-lg border px-3 py-2 transition-colors";
const ROW_OFF = "border-line bg-paper hover:border-accent hover:bg-accentSoft/50";
const ROW_ON = "border-accent bg-accentSoft";
// -- question normalization ---------------------------------------------------
interface NormOption {
label: string;
description: string;
recommended: boolean;
preview: string;
}
const normOption = (o: QuestionOption): NormOption =>
typeof o === "string"
? { label: o, description: "", recommended: false, preview: "" }
: {
label: o.label || "",
description: o.description || "",
recommended: !!o.recommended,
preview: o.preview || "",
};
interface QSpec {
question: string;
header: string;
options: NormOption[];
allowText: boolean;
multi: boolean;
}
// The item's question steps: the grouped `questions` list, or the singular fields as one step.
function specsFor(item: InboxItem): QSpec[] {
const grouped = item.questions || [];
if (grouped.length)
return grouped.map((q) => ({
question: q.question,
header: q.header || "",
options: (q.options || []).map(normOption),
allowText: q.allow_text !== false,
multi: !!q.multi,
}));
return [
{
question: item.title,
header: item.header || "",
options: (item.options || []).map(normOption),
allowText: item.allow_text !== false,
multi: !!item.multi,
},
];
}
// -- one question (options + free-text escape) --------------------------------
function QuestionBlock({ spec, onAnswer }: { spec: QSpec; onAnswer: (a: string) => void }) {
const [selected, setSelected] = useState<string[]>([]);
const [text, setText] = useState("");
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const { options, multi } = spec;
// Any description/preview upgrades pills to stacked rows; any preview adds the side pane.
const rich = options.some((o) => o.description || o.preview);
const hasPreview = options.some((o) => o.preview);
const pick = (o: NormOption) => {
if (multi)
setSelected((s) => (s.includes(o.label) ? s.filter((x) => x !== o.label) : [...s, o.label]));
else onAnswer(o.label); // single-select answers immediately (pill behavior, unchanged)
};
// The pane follows hover/focus, falls back to the selected option, then the first preview.
const selIdx = options.findIndex((o) => selected.includes(o.label));
const previewIdx =
hoverIdx ?? (selIdx >= 0 && options[selIdx].preview ? selIdx : options.findIndex((o) => o.preview));
const preview = previewIdx >= 0 ? options[previewIdx].preview : "";
const recommendedTag = (
<span className="text-[10px] uppercase tracking-[0.04em] font-semibold text-ok bg-okSoft border border-okLine rounded-full px-1.5 py-px shrink-0">
Recommended
</span>
);
const optionRows = (
<div className={hasPreview ? "flex flex-col gap-2 min-w-0 sm:w-[46%] shrink-0" : "flex flex-col gap-2 mt-2.5"}>
{options.map((o, i) => {
const on = selected.includes(o.label);
return (
<button
key={o.label + i}
className={ROW_BASE + " " + (on ? ROW_ON : ROW_OFF)}
onMouseEnter={() => setHoverIdx(i)}
onMouseLeave={() => setHoverIdx(null)}
onFocus={() => setHoverIdx(i)}
onBlur={() => setHoverIdx(null)}
onClick={() => pick(o)}
>
<span
className={
"flex items-center gap-2 text-[13px] " + (on ? "text-accent font-medium" : "text-ink font-medium")
}
>
{multi && on && <span className="text-accent text-[11px] leading-none"></span>}
<span className="min-w-0 truncate">{o.label}</span>
{o.recommended && recommendedTag}
</span>
{o.description && (
<span className="block text-[12px] text-muted mt-0.5 leading-snug">{o.description}</span>
)}
</button>
);
})}
</div>
);
return (
<>
{options.length > 0 &&
(hasPreview ? (
// Two-pane: options left, preview right; stacks vertically on narrow widths.
<div className="flex flex-col sm:flex-row gap-3 mt-2.5">
{optionRows}
<pre
data-testid="question-preview"
className="flex-1 min-w-0 rounded-lg border border-line bg-paper p-3 text-[12px] leading-relaxed font-mono whitespace-pre overflow-auto max-h-72 text-ink"
>
{preview}
</pre>
</div>
) : rich ? (
optionRows
) : (
// Plain-string options: today's pills, untouched.
<div className="flex flex-wrap gap-2 mt-2.5">
{options.map((o) => {
const on = selected.includes(o.label);
return (
<button
key={o.label}
className={OPT_BASE + " " + (on ? OPT_ON : OPT_OFF)}
onClick={() => pick(o)}
>
{multi && on && <span className="text-accent text-[11px] leading-none"></span>}
{o.label}
</button>
);
})}
</div>
))}
{multi && options.length > 0 && (
<div className="mt-2.5">
<button
className={BTN_PRIMARY}
disabled={!selected.length}
onClick={() => onAnswer(selected.join(", "))}
>
Send{selected.length ? ` (${selected.length})` : ""}
</button>
</div>
)}
{(spec.allowText || options.length === 0) && (
<div className="flex items-center gap-2 mt-2.5">
<input
className={INPUT}
placeholder={options.length ? "Or type your own answer…" : "Your answer…"}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && text.trim()) onAnswer(text);
}}
/>
<button className={BTN_PRIMARY} disabled={!text.trim()} onClick={() => onAnswer(text)}>
Send
</button>
</div>
)}
</>
);
}
// -- the question card (single, or grouped as a stepper) ----------------------
function QuestionCard({
item,
onResolve,
chip,
}: {
item: InboxItem;
onResolve: (id: string, resolution: string) => void;
chip?: ReactNode;
}) {
const specs = specsFor(item);
const grouped = (item.questions?.length ?? 0) > 0;
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>>({});
const spec = specs[Math.min(step, specs.length - 1)];
const next = step + 1 < specs.length ? specs[step + 1] : null;
// The answer map is keyed by header (falling back to the question text) — the same key the
// server's answer_result() hands the agent.
const keyFor = (s: QSpec) => s.header || s.question;
const submit = (a: string) => {
if (!grouped) {
onResolve(item.id, a);
return;
}
const all = { ...answers, [keyFor(spec)]: a };
setAnswers(all);
if (step + 1 < specs.length) setStep(step + 1);
else onResolve(item.id, JSON.stringify(all));
};
return (
<>
{/* Stepper chips (grouped): "Chart style · 1 of 2 · Distribution " — steps back. */}
<div className={SEC + " flex items-center gap-1.5"} data-testid={grouped ? "question-stepper" : undefined}>
{grouped && step > 0 && (
<button
className="text-faint hover:text-ink leading-none text-[13px]"
title="Previous question"
aria-label="Previous question"
onClick={() => setStep(step - 1)}
>
</button>
)}
<span className={grouped ? "text-accent" : undefined}>
{spec.header || (grouped ? `Question ${step + 1}` : "question")}
</span>
{grouped && (
<>
<span>·</span>
<span>
{step + 1} of {specs.length}
</span>
{next && (
<>
<span>·</span>
<span>{(next.header || `Question ${step + 2}`) + " "}</span>
</>
)}
</>
)}
</div>
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{spec.question}</div>
{item.body ? (
<div className="text-[13px] text-muted mt-1 whitespace-pre-wrap">{item.body}</div>
) : null}
{chip}
{/* key={step} resets selection/text/hover state when the stepper advances */}
<QuestionBlock key={step} spec={spec} onAnswer={submit} />
</>
);
}
export function InboxItemCard({
item,
@@ -42,29 +299,7 @@ export function InboxItemCard({
chip?: ReactNode; // optional "go to session" affordance (shown in the Inbox list, not inline)
compact?: boolean;
}) {
const [answer, setAnswer] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const options = item.options || [];
const multi = !!item.multi;
const allowText = item.allow_text !== false;
const textRow = (placeholder: string) => (
<div className="flex items-center gap-2 mt-2.5">
<input
className={INPUT}
placeholder={placeholder}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && answer.trim()) onResolve(item.id, answer);
}}
/>
<button className={BTN_PRIMARY} disabled={!answer.trim()} onClick={() => onResolve(item.id, answer)}>
Send
</button>
</div>
);
const isQuestion = item.kind === "question";
return (
<div
className={
@@ -88,7 +323,7 @@ export function InboxItemCard({
);
})()}
</div>
) : (
) : isQuestion ? null : ( // QuestionCard owns its header + title (stepper needs them)
<>
<div className={SEC}>{item.kind}</div>
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{item.title}</div>
@@ -101,10 +336,10 @@ export function InboxItemCard({
<PreviewBlock text={item.data.arguments.content} />
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
<PreviewBlock text={item.data.arguments.command} />
) : item.body ? (
) : !isQuestion && item.body ? (
<div className="text-[13px] text-muted mt-1 whitespace-pre-wrap">{item.body}</div>
) : null}
{chip}
{!isQuestion && chip}
{item.kind === "approval" ? (
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
<button
@@ -132,43 +367,8 @@ export function InboxItemCard({
{item.data?.tool ? approvalActionLabels(item.data.tool).deny : "Deny"}
</button>
</div>
) : item.kind === "question" ? (
<>
{options.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2.5">
{options.map((opt) => {
const on = selected.includes(opt);
return (
<button
key={opt}
className={OPT_BASE + " " + (on ? OPT_ON : OPT_OFF)}
onClick={() => {
if (multi)
setSelected((s) => (on ? s.filter((x) => x !== opt) : [...s, opt]));
else onResolve(item.id, opt); // single-select resolves immediately
}}
>
{multi && on && <span className="text-accent text-[11px] leading-none"></span>}
{opt}
</button>
);
})}
</div>
)}
{multi && options.length > 0 && (
<div className="mt-2.5">
<button
className={BTN_PRIMARY}
disabled={!selected.length}
onClick={() => onResolve(item.id, selected.join(", "))}
>
Send{selected.length ? ` (${selected.length})` : ""}
</button>
</div>
)}
{(allowText || options.length === 0) &&
textRow(options.length ? "Or type your own answer…" : "Your answer…")}
</>
) : isQuestion ? (
<QuestionCard item={item} onResolve={onResolve} chip={chip} />
) : item.kind === "directory" ? (
<div className="flex items-center gap-2 mt-2.5">
<button
+21 -1
View File
@@ -133,9 +133,29 @@ export type Item =
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
kind: "question";
question: string;
options?: string[];
options?: QuestionOption[];
allow_text?: boolean;
multi?: boolean;
header?: string;
questions?: GroupedQuestion[];
resolved?: string;
}
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean };
// -- ask_user question metadata (OPE-51) --------------------------------------
// An option is a plain string (renders as today's pill) or a rich object: `label` is the answer
// value, `description` renders under it, `recommended` adds the green tag, `preview` is monospace
// text shown in the side pane (≥1 preview switches the card to the two-pane layout).
export type QuestionOption =
| string
| { label: string; description?: string; recommended?: boolean; preview?: string };
// One step of a grouped ask_user call (up to 4, rendered as a stepper). The answer map is keyed
// by `header` (falling back to `question`).
export interface GroupedQuestion {
question: string;
header?: string;
options?: QuestionOption[];
allow_text?: boolean;
multi?: boolean;
}
+218
View File
@@ -0,0 +1,218 @@
"""OPE-51 — ask_user upgrades: rich options ({label, description, recommended, preview}),
grouped questions (one call, a stepper, one round-trip), and the {answer}/{answers} result
shapes. Back-compat is load-bearing: plain-string options and old persisted items must be
untouched by all of it."""
import asyncio
import json
from coworker.inbox import InboxItem, InboxStore
from coworker.interactions import buttons_for, decode
from coworker.server.manager import SessionManager
from coworker.tools.ask import (
MAX_GROUPED_QUESTIONS,
answer_result,
ask_user_tool,
normalize_option,
normalize_questions,
option_label,
question_item_fields,
)
from test_durable_resume import ScriptedProvider, _run_until_pending, _text, _tool
# -- schema -------------------------------------------------------------------
def test_schema_advertises_rich_options_and_grouped_questions():
fn = ask_user_tool().__coworker_schema__["function"]
assert fn["name"] == "ask_user"
props = fn["parameters"]["properties"]
# options: string-or-object union, object requires `label`
variants = props["options"]["items"]["anyOf"]
assert {"type": "string"} in variants
obj = next(v for v in variants if v.get("type") == "object")
assert obj["required"] == ["label"]
assert set(obj["properties"]) == {"label", "description", "recommended", "preview"}
# grouped: capped, each entry requires `question`
grouped = props["questions"]
assert grouped["maxItems"] == MAX_GROUPED_QUESTIONS
assert grouped["items"]["required"] == ["question"]
# -- normalization helpers ----------------------------------------------------
def test_normalize_option_and_label():
assert normalize_option("Bar") == {
"label": "Bar",
"description": "",
"recommended": False,
"preview": "",
}
rich = normalize_option({"label": "Line", "recommended": True, "preview": "p"})
assert rich["recommended"] is True and rich["preview"] == "p"
assert option_label("Bar") == "Bar" and option_label({"label": "Line"}) == "Line"
def test_normalize_questions_caps_and_drops_blanks():
entries = [{"question": f"Q{i}?"} for i in range(MAX_GROUPED_QUESTIONS + 2)]
assert len(normalize_questions(entries)) == MAX_GROUPED_QUESTIONS
assert normalize_questions([{"question": " "}, "junk", {"header": "h"}]) == []
def test_question_item_fields_plain_strings_pass_through():
fields = question_item_fields({"question": "Env?", "options": ["staging", "prod"]})
assert fields["title"] == "Env?"
assert fields["options"] == ["staging", "prod"] # simple asks stay today's pills
assert fields["questions"] == []
def test_question_item_fields_rich_options_canonicalized():
fields = question_item_fields(
{"question": "Env?", "options": [{"label": "staging", "recommended": True}]}
)
assert fields["options"] == [
{"label": "staging", "description": "", "recommended": True, "preview": ""}
]
def test_question_item_fields_grouped_surfaces_first_question():
fields = question_item_fields(
{
"questions": [
{"question": "Chart style?", "header": "Chart", "options": ["Bar"]},
{"question": "Colors?", "multi": True},
]
}
)
assert fields["title"] == "Chart style?" and fields["header"] == "Chart"
assert fields["options"][0]["label"] == "Bar"
assert len(fields["questions"]) == 2 and fields["questions"][1]["multi"] is True
def test_question_item_fields_nothing_asked():
assert question_item_fields({}) is None
assert question_item_fields({"question": " "}) is None
assert question_item_fields({"questions": [{"question": ""}]}) is None
# -- result shaping -----------------------------------------------------------
def test_answer_result_shapes():
assert answer_result([], "staging") == {"answer": "staging"}
assert answer_result([], None) == {"answer": ""}
grouped = [{"question": "Chart style?", "header": "Chart"}, {"question": "Colors?"}]
res = answer_result(grouped, json.dumps({"Chart": "Bar", "Colors?": "Blue"}))
assert res == {"answers": {"Chart": "Bar", "Colors?": "Blue"}}
# a text-only surface answered with a bare string → attributed to the first question
assert answer_result(grouped, "Bar") == {"answers": {"Chart": "Bar"}}
assert answer_result(grouped, "") == {"answer": ""} # engine reads this as denied
# -- inbox persistence + back-compat ------------------------------------------
def test_inbox_round_trips_grouped_questions(tmp_path):
store = InboxStore(tmp_path / "inbox.json")
fields = question_item_fields(
{
"questions": [
{
"question": "Format?",
"header": "Format",
"options": [{"label": "Table", "preview": "| a | b |"}],
},
{"question": "Where to?"},
]
}
)
item = store.add_question("s1", **fields)
reloaded = InboxStore(tmp_path / "inbox.json").get(item.id)
assert reloaded.header == "Format"
assert reloaded.questions == item.questions
assert reloaded.options[0]["preview"] == "| a | b |"
def test_old_persisted_items_still_load():
# Items written before OPE-51 carry no header/questions keys and string options.
old = InboxItem(
id="x", session_id="s", kind="question", title="Env?", options=["staging"]
)
assert old.header == "" and old.questions == []
# -- channel buttons ----------------------------------------------------------
def test_buttons_use_rich_option_labels(tmp_path):
store = InboxStore(tmp_path / "inbox.json")
item = store.add_question(
"s1",
"Env?",
options=["staging", {"label": "prod", "description": "the real one"}],
)
btns = buttons_for(item)
assert [b.label for b in btns] == ["staging", "prod"]
assert decode(btns[1].value) == (item.id, "prod") # resolution IS the label
def test_grouped_questions_get_no_buttons(tmp_path):
store = InboxStore(tmp_path / "inbox.json")
fields = question_item_fields(
{"questions": [{"question": "A?", "options": ["x"]}, {"question": "B?"}]}
)
item = store.add_question("s1", **fields)
assert buttons_for(item) == [] # one button row can't answer 2+ questions
# -- full stack: grouped call → Inbox item → JSON resolution → {answers} ------
def test_grouped_ask_round_trip_through_manager(tmp_path):
mgr = SessionManager(
workspace=tmp_path,
provider=ScriptedProvider(
[
_tool(
"ask_user",
{
"questions": [
{
"question": "Chart style?",
"header": "Chart",
"options": [
{"label": "Bar", "recommended": True},
"Line",
],
},
{"question": "Which distribution?", "header": "Distribution"},
]
},
"call_g",
),
_text("Bar it is, stacked."),
]
),
)
sid = "grouped-q"
async def scenario():
engine = mgr.get_engine(sid, agent="cowork", workspace=str(tmp_path))
item = await _run_until_pending(mgr, sid, engine)
assert item.kind == "question" and item.tool_call_id == "call_g"
assert item.title == "Chart style?" and len(item.questions) == 2
await mgr.resolve_inbox(
item.id, json.dumps({"Chart": "Bar", "Distribution": "Stacked"})
)
asyncio.run(scenario())
# The tool result the model saw carries the parsed answers map.
rec = mgr.session_store.load(sid)
tool_msgs = [m for m in rec.messages if m.get("role") == "tool"]
assert tool_msgs, "no tool result was recorded"
payload = json.loads(tool_msgs[-1]["content"])
assert payload == {"answers": {"Chart": "Bar", "Distribution": "Stacked"}}
assert mgr.inbox.pending(sid) == []