mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
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:
+8
-1
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 ""}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) == []
|
||||
Reference in New Issue
Block a user