mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-13 07:40:18 +00:00
ask_user upgrades: rich options, grouped questions, option previews (OPE-51)
Options accept {label, description, recommended, preview} objects (plain
strings unchanged — old sessions render as today's pills), and `questions`
groups up to 4 questions into one call, rendered as a stepper via the
header chips. Any option preview switches the card to a two-pane layout:
options left, monospace pane right, following hover/focus.
Grouped calls resolve with a JSON map keyed by header-or-question and
return {answers: {...}} to the agent (single stays {answer: ...});
a grouped item's first question doubles as its title/options so channel
mirrors and legacy surfaces degrade sensibly. Channel buttons use option
labels; grouped items mirror as text with the open-the-app hint.
This commit is contained in:
+8
-1
@@ -826,6 +826,13 @@ class TurnEngine:
|
|||||||
from the Inbox when unattended), and return it as the tool result."""
|
from the Inbox when unattended), and return it as the tool result."""
|
||||||
args = tool_call.arguments or {}
|
args = tool_call.arguments or {}
|
||||||
question = str(args.get("question", "")).strip()
|
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:
|
if self.question_asker is None or not question:
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"answer": "",
|
"answer": "",
|
||||||
@@ -847,7 +854,7 @@ class TurnEngine:
|
|||||||
"error": "no response",
|
"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.messages.append(_tool_result_message(tool_call, result))
|
||||||
self._audit(
|
self._audit(
|
||||||
tool_call,
|
tool_call,
|
||||||
|
|||||||
+17
-1
@@ -78,11 +78,19 @@ class InboxItem:
|
|||||||
tool_call_id: Optional[str] = None
|
tool_call_id: Optional[str] = None
|
||||||
# Question metadata (ask_user): optional quick-reply choices + a free-text escape, mirroring
|
# Question metadata (ask_user): optional quick-reply choices + a free-text escape, mirroring
|
||||||
# the structured-but-always-answerable shape of Claude Code's AskUserQuestion.
|
# 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 = (
|
allow_text: bool = (
|
||||||
True # accept a typed answer even when options exist (the "Other" escape)
|
True # accept a typed answer even when options exist (the "Other" escape)
|
||||||
)
|
)
|
||||||
multi: bool = False # allow choosing more than one option
|
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; …).
|
# Kind-specific payload (directory: suggested path/writable; plan: the plan text; …).
|
||||||
data: dict[str, Any] = field(default_factory=dict)
|
data: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
@@ -126,6 +134,8 @@ class InboxStore:
|
|||||||
options=None,
|
options=None,
|
||||||
allow_text: bool = True,
|
allow_text: bool = True,
|
||||||
multi: bool = False,
|
multi: bool = False,
|
||||||
|
header: str = "",
|
||||||
|
questions=None,
|
||||||
tool_call_id: Optional[str] = None,
|
tool_call_id: Optional[str] = None,
|
||||||
) -> InboxItem:
|
) -> InboxItem:
|
||||||
# Idempotent by (session_id, tool_call_id): a durable resume re-raises the same prompt, and
|
# 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 []),
|
options=list(options or []),
|
||||||
allow_text=bool(allow_text),
|
allow_text=bool(allow_text),
|
||||||
multi=bool(multi),
|
multi=bool(multi),
|
||||||
|
header=str(header or ""),
|
||||||
|
questions=list(questions or []),
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -194,6 +206,8 @@ class InboxStore:
|
|||||||
options=None,
|
options=None,
|
||||||
allow_text=True,
|
allow_text=True,
|
||||||
multi=False,
|
multi=False,
|
||||||
|
header="",
|
||||||
|
questions=None,
|
||||||
tool_call_id=None,
|
tool_call_id=None,
|
||||||
) -> InboxItem:
|
) -> InboxItem:
|
||||||
return self.add(
|
return self.add(
|
||||||
@@ -206,6 +220,8 @@ class InboxStore:
|
|||||||
options=options,
|
options=options,
|
||||||
allow_text=allow_text,
|
allow_text=allow_text,
|
||||||
multi=multi,
|
multi=multi,
|
||||||
|
header=header,
|
||||||
|
questions=questions,
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .inbox import KIND_APPROVAL, KIND_QUESTION
|
from .inbox import KIND_APPROVAL, KIND_QUESTION
|
||||||
|
from .tools.ask import option_label
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -48,7 +49,15 @@ def buttons_for(item) -> list[Button]:
|
|||||||
Button("Approve", encode(item.id, "allow")),
|
Button("Approve", encode(item.id, "allow")),
|
||||||
Button("Deny", encode(item.id, "deny")),
|
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):
|
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).
|
# One button per option; the resolution IS the chosen option's label (what the agent
|
||||||
return [Button(opt, encode(item.id, opt)) for opt in item.options]
|
# 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 []
|
return []
|
||||||
|
|||||||
@@ -1525,15 +1525,17 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
|
|
||||||
async def question_asker(args: dict, tool_call_id=None) -> dict:
|
async def question_asker(args: dict, tool_call_id=None) -> dict:
|
||||||
# ask_user (engine does NOT emit the event — we do, only when attended).
|
# 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(
|
item = manager.inbox.add_question(
|
||||||
session_id,
|
session_id,
|
||||||
str(args.get("question", "")),
|
|
||||||
inbox=_route(),
|
inbox=_route(),
|
||||||
visibility=_visibility(),
|
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,
|
tool_call_id=tool_call_id,
|
||||||
|
**fields,
|
||||||
)
|
)
|
||||||
if item.state == "pending":
|
if item.state == "pending":
|
||||||
manager.persist_session(session_id)
|
manager.persist_session(session_id)
|
||||||
@@ -1548,11 +1550,12 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
"options": item.options,
|
"options": item.options,
|
||||||
"allow_text": item.allow_text,
|
"allow_text": item.allow_text,
|
||||||
"multi": item.multi,
|
"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:
|
async def directory_requester(args: dict, tool_call_id=None) -> dict:
|
||||||
# The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant.
|
# The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant.
|
||||||
|
|||||||
@@ -723,27 +723,26 @@ class SessionManager:
|
|||||||
async def ask(
|
async def ask(
|
||||||
args: dict[str, Any], tool_call_id: Optional[str] = None
|
args: dict[str, Any], tool_call_id: Optional[str] = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
question = str(args.get("question", "")).strip()
|
from ..tools.ask import answer_result, question_item_fields
|
||||||
if not question:
|
|
||||||
|
fields = question_item_fields(args)
|
||||||
|
if fields is None:
|
||||||
return {"answer": "", "error": "no question"}
|
return {"answer": "", "error": "no question"}
|
||||||
inbox_name = self.inbox_routing.route_for(session_id, agent)
|
inbox_name = self.inbox_routing.route_for(session_id, agent)
|
||||||
item = self.inbox.add_question(
|
item = self.inbox.add_question(
|
||||||
session_id,
|
session_id,
|
||||||
title=question,
|
|
||||||
inbox=inbox_name,
|
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,
|
tool_call_id=tool_call_id,
|
||||||
|
**fields,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
item.state != "pending"
|
item.state != "pending"
|
||||||
): # durable resume re-raised an already-answered prompt
|
): # 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
|
self.persist_session(session_id) # the pending tool call is now on disk
|
||||||
await self.mirror_inbox_item(item)
|
await self.mirror_inbox_item(item)
|
||||||
answer = await self.inbox.wait(item.id)
|
answer = await self.inbox.wait(item.id)
|
||||||
return {"answer": answer}
|
return answer_result(item.questions, answer)
|
||||||
|
|
||||||
return ask
|
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
|
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
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from aisuite.agents import ToolMetadata, tool
|
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_tool() -> object:
|
||||||
def ask_user(
|
def ask_user(
|
||||||
question: str,
|
question: str = "",
|
||||||
options: list[str] | None = None,
|
options: list | None = None,
|
||||||
allow_text: bool = True,
|
allow_text: bool = True,
|
||||||
multi: bool = False,
|
multi: bool = False,
|
||||||
header: str = "",
|
header: str = "",
|
||||||
|
questions: list | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Ask the user a question and wait for their answer — use when you genuinely need a human
|
"""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
|
decision or information you can't infer (a preference, a missing fact, a choice between real
|
||||||
alternatives). Prefer this over guessing or stalling.
|
alternatives). Prefer this over guessing or stalling.
|
||||||
|
|
||||||
- `question`: the full question, in plain language.
|
Single form returns `{"answer": "..."}` — the chosen option label(s) or the typed text.
|
||||||
- `options`: optional quick-reply choices. Offer them when the answer is one of a few
|
Grouped form (`questions`) returns `{"answers": {"<header or question>": "..."}}` — one
|
||||||
discrete alternatives; leave empty for an open-ended question.
|
entry per question. Don't ask what you can reasonably decide yourself; reserve this for
|
||||||
- `allow_text`: keep a free-text answer available even when you give options (the default;
|
choices that are actually the user's to make.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
# Real handling lives in the engine (it needs the out-of-band Inbox round-trip). This body
|
# 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).
|
# 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",
|
"error": "asking the user isn't available in this surface",
|
||||||
}
|
}
|
||||||
|
|
||||||
return tool(
|
wrapped = tool(
|
||||||
ask_user,
|
ask_user,
|
||||||
metadata=ToolMetadata(
|
metadata=ToolMetadata(
|
||||||
category="interaction",
|
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();
|
||||||
|
});
|
||||||
@@ -679,6 +679,8 @@ export function App() {
|
|||||||
options: d.options || [],
|
options: d.options || [],
|
||||||
allow_text: d.allow_text !== false,
|
allow_text: d.allow_text !== false,
|
||||||
multi: !!d.multi,
|
multi: !!d.multi,
|
||||||
|
header: d.header || "",
|
||||||
|
questions: d.questions || [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
@@ -1601,6 +1603,8 @@ export function App() {
|
|||||||
options: pendingQuestion.options,
|
options: pendingQuestion.options,
|
||||||
allow_text: pendingQuestion.allow_text,
|
allow_text: pendingQuestion.allow_text,
|
||||||
multi: pendingQuestion.multi,
|
multi: pendingQuestion.multi,
|
||||||
|
header: pendingQuestion.header,
|
||||||
|
questions: pendingQuestion.questions,
|
||||||
}}
|
}}
|
||||||
onResolve={(_id, answer) => answerQuestion(answer)}
|
onResolve={(_id, answer) => answerQuestion(answer)}
|
||||||
compact
|
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;
|
declare const __COWORKER_DEV_TOKEN__: string;
|
||||||
|
|
||||||
@@ -1063,10 +1063,14 @@ export interface InboxItem {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
resolved_at: string | null;
|
resolved_at: string | null;
|
||||||
visibility?: "inline" | "inbox";
|
visibility?: "inline" | "inbox";
|
||||||
// Question metadata (ask_user): quick-reply choices + a free-text escape.
|
// Question metadata (ask_user): quick-reply choices + a free-text escape. Options may be rich
|
||||||
options?: string[];
|
// {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;
|
allow_text?: boolean;
|
||||||
multi?: boolean;
|
multi?: boolean;
|
||||||
|
header?: string;
|
||||||
|
questions?: GroupedQuestion[];
|
||||||
// Kind-specific payload (directory: {path, writable}; …).
|
// Kind-specific payload (directory: {path, writable}; …).
|
||||||
data?: Record<string, any>;
|
data?: Record<string, any>;
|
||||||
// Originating-session context (server-joined) so the Inbox is self-contained.
|
// Originating-session context (server-joined) so the Inbox is self-contained.
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import type { InboxItem } from "../api";
|
import type { InboxItem } from "../api";
|
||||||
|
import type { QuestionOption } from "../types";
|
||||||
import { humanizeApprovalTitle } from "../humanize";
|
import { humanizeApprovalTitle } from "../humanize";
|
||||||
import { PreviewBlock, scopeNote, TitleText } from "./ApprovalCard";
|
import { PreviewBlock, scopeNote, TitleText } from "./ApprovalCard";
|
||||||
|
|
||||||
// One Inbox item, rendered identically in the Inbox list and inline in its own session view
|
// 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.
|
// (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
|
// 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).
|
// Shared styles (mock parity — same language as SourcesDrawer/PersonaView).
|
||||||
const SEC = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
|
const SEC = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
|
||||||
@@ -24,6 +27,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 OPT_ON = "border-accent bg-accentSoft text-accent font-medium";
|
||||||
const INPUT =
|
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";
|
"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({
|
export function InboxItemCard({
|
||||||
item,
|
item,
|
||||||
@@ -36,29 +293,7 @@ export function InboxItemCard({
|
|||||||
chip?: ReactNode; // optional "go to session" affordance (shown in the Inbox list, not inline)
|
chip?: ReactNode; // optional "go to session" affordance (shown in the Inbox list, not inline)
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [answer, setAnswer] = useState("");
|
const isQuestion = item.kind === "question";
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@@ -82,7 +317,7 @@ export function InboxItemCard({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : isQuestion ? null : ( // QuestionCard owns its header + title (stepper needs them)
|
||||||
<>
|
<>
|
||||||
<div className={SEC}>{item.kind}</div>
|
<div className={SEC}>{item.kind}</div>
|
||||||
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{item.title}</div>
|
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{item.title}</div>
|
||||||
@@ -92,10 +327,10 @@ export function InboxItemCard({
|
|||||||
<PreviewBlock text={item.data.arguments.content} />
|
<PreviewBlock text={item.data.arguments.content} />
|
||||||
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
|
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
|
||||||
<PreviewBlock text={item.data.arguments.command} />
|
<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>
|
<div className="text-[13px] text-muted mt-1 whitespace-pre-wrap">{item.body}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{chip}
|
{!isQuestion && chip}
|
||||||
{item.kind === "approval" ? (
|
{item.kind === "approval" ? (
|
||||||
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
|
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
|
||||||
<button
|
<button
|
||||||
@@ -123,43 +358,8 @@ export function InboxItemCard({
|
|||||||
Deny
|
Deny
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : item.kind === "question" ? (
|
) : isQuestion ? (
|
||||||
<>
|
<QuestionCard item={item} onResolve={onResolve} chip={chip} />
|
||||||
{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…")}
|
|
||||||
</>
|
|
||||||
) : item.kind === "directory" ? (
|
) : item.kind === "directory" ? (
|
||||||
<div className="flex items-center gap-2 mt-2.5">
|
<div className="flex items-center gap-2 mt-2.5">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -131,9 +131,29 @@ export type Item =
|
|||||||
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
|
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
|
||||||
kind: "question";
|
kind: "question";
|
||||||
question: string;
|
question: string;
|
||||||
options?: string[];
|
options?: QuestionOption[];
|
||||||
allow_text?: boolean;
|
allow_text?: boolean;
|
||||||
multi?: boolean;
|
multi?: boolean;
|
||||||
|
header?: string;
|
||||||
|
questions?: GroupedQuestion[];
|
||||||
resolved?: string;
|
resolved?: string;
|
||||||
}
|
}
|
||||||
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean };
|
| { 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