mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 08:28:00 +00:00
* feat(clarification): structured form fields for human-input cards Add a request-side v2 `form` mode to the ask_clarification protocol so business flows (e.g. expense reimbursement) can collect several values in one card instead of sequential free-text questions: - `ask_clarification` gains a restricted `fields` parameter (text / textarea / number / select / multi_select / checkbox / date) - ClarificationMiddleware validates and normalizes fields explicitly (whitelisted types, unknown -> text, select-likes without options -> text, duplicate/invalid entries dropped, all-invalid falls back to the legacy modes) since the middleware short-circuits before tool execution; the plain-text fallback lists fields for IM channels - Form payloads carry `version: 2` so older frontends degrade to the text fallback; replies stay on the v1 response protocol — the card submits a readable summary as `response_kind: "text"`, so journal persistence and answered-card recovery are unchanged - Frontend renders typed field controls with required-field validation and compact multi-select chips Part of #4400 (scope narrowed per maintainer feedback: request-side only, no new response kinds, no top-level multi_choice). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(clarification): harden form protocol per review feedback Address the five review points on #4406: - Reject field names colliding with JS Object.prototype members on both sides; frontend reads form values via own-property access only, so `constructor`/`toString`-style names can no longer leak inherited members into required validation or the submitted summary - Close open requests answered through the legacy text fallback: a visible plain human reply (no response metadata) now marks every previously-opened request as answered, so upgrading to a v2-aware frontend cannot leave the composer locked on an already-answered card - Give checkbox fields deterministic boolean semantics: values are seeded to an explicit false ("no" in the summary) and `required` means must-agree/consent; documented in the tool schema - Make middleware field validation atomic: structurally broken entries (bad/duplicate/reserved names, over-cap field/option counts or text lengths) degrade the whole form instead of silently dropping fields; options are trimmed/deduped with blanks removed so the backend never emits payloads the frontend parser rejects - Associate form labels/controls (htmlFor/id), aria-required, aria-invalid, and error descriptions for accessibility Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(clarification): type the fields item schema via TypedDict Replace `fields: list[dict[str, Any]]` with `list[ClarificationFormField]` (a TypedDict with `name` required and the type whitelist as a Literal) so the provider-facing tool schema documents the item shape instead of an opaque object relying on the docstring. Runtime validation is unchanged and stays in ClarificationMiddleware, which intercepts the call before tool execution. Addresses the non-blocking review suggestion on #4406. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): drop unsupported aria-invalid from multi-select group jsx-a11y: role=group does not support aria-invalid; the error linkage stays via aria-describedby. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(clarification): coerce numeric required flags and normalize fields once - `_normalize_bool` now coerces 1/0 (some providers serialize booleans as integers), so `required: 1` no longer silently flips to optional - `_handle_clarification` normalizes `fields` once and passes the result to both the text fallback and the payload builder Addresses the non-blocking review nits on #4406. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(clarification): harden form protocol per contract review round 2 Backend: - Guard unhashable JSON in the intercept path: `type: []`/`{}` degrades the field to text and `clarification_type: []` coerces to str instead of raising TypeError (which, with return_direct, ended the turn with an error and no card or fallback) - Add a total budget over the serialized normalized fields (16KB UTF-8 bytes): per-item caps alone admitted forms whose IM text fallback exceeded channel delivery limits (Slack 40k chars, Feishu ~30KB card), silently truncating trailing fields; a boundary test proves any accepted form's fallback stays deliverable Frontend: - Submission value now appends a JSON block keyed by stable field names (readable summary alone is delimiter-ambiguous), with a collision regression test - Parser boundary tightened to match backend constraints: empty option values (Radix SelectItem crash), duplicate option ids/values, duplicate field names, and the form<->version-2 binding are rejected - Keep the error node mounted while any field is still invalid so aria-describedby never points at a removed element (happy-dom interaction test) - Required semantics are now accessible: native checkbox control (no HTML required attribute — it would intercept the custom submit path), visually-hidden localized "required" markers next to the aria-hidden asterisks - Legacy-fallback closure narrowed to the latest unanswered request: nothing guarantees a single outstanding clarification across runs, and closing all would silently swallow older decisions; an older request left open becomes the active card again Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): keep clarification selects controlled --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e01173d8b2
commit
1baa8ad696
@ -365,7 +365,7 @@ Before changing a later authorization phase, read the [authorization RFC](../doc
|
||||
32. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success
|
||||
33. **ModelLengthFinishReasonMiddleware** - Records `stop_reason=model_length_capped` when provider-specific length detectors match a terminal `AIMessage` without tool-call intent (`finish_reason=length` / `MAX_TOKENS`, or `stop_reason=max_tokens`), preserving the original assistant content and never reparsing textual tool-call-like envelopes
|
||||
34. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first
|
||||
35. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, writes a readable `ToolMessage.content` fallback plus structured `ToolMessage.artifact.human_input` request payload, and interrupts via `Command(goto=END)` (must be last). Because this middleware can short-circuit tool execution before LangChain emits `on_tool_end`, `RunJournal` performs a root-run final reconciliation for allowlisted clarification `ToolMessage`s whose `tool_call_id` was produced by the current run, so human-input request cards remain recoverable from `run_events` after checkpoint compaction. Human Input Card replies are submitted as `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden response sources (currently `ask_clarification`) as `llm.human.input`, which preserves answered-card state after compaction without exposing generic internal hidden context.
|
||||
35. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, writes a readable `ToolMessage.content` fallback plus structured `ToolMessage.artifact.human_input` request payload, and interrupts via `Command(goto=END)` (must be last). Payloads are versioned: legacy modes (`free_text` / `choice_with_other`) keep `version: 1` unchanged, while the v2 `form` mode (from `fields`) carries `version: 2` so older frontends reject the payload and degrade to the plain-text fallback. Field normalization is deterministic and lives in the middleware, not the tool schema — the middleware short-circuits before tool execution, so tool-arg typing alone provides no runtime validation. Validation is atomic: any structurally broken entry (non-dict, bad/duplicate name, a name colliding with a JS `Object.prototype` member like `__proto__`/`constructor`, exceeding the caps of 16 fields / 24 options per field / 200 chars per text, or the whole normalized definition exceeding `MAX_FORM_SERIALIZED_BYTES` = 16KB UTF-8 — the per-item caps alone admit forms whose IM text fallback would blow channel delivery limits and truncate away trailing fields) degrades the whole form to the legacy option/free-text modes, so a card can never render "complete" while silently missing a business field; benign issues keep local degradation (unknown types — including unhashable JSON like `type: []`, which must never raise from the membership probe — and option-less selects become `text`), and options are trimmed/deduped with blanks dropped (both form-level and top-level) because the frontend parser rejects blank option labels. Checkbox fields are booleans that default to an explicit "no"; `required` on a checkbox means must-agree/consent semantics. The response protocol is deliberately unchanged (v1 `text`/`option` only): form cards submit a readable text summary as `response_kind: "text"`, so journal persistence and answered-card recovery need no new allowlist entries. Because this middleware can short-circuit tool execution before LangChain emits `on_tool_end`, `RunJournal` performs a root-run final reconciliation for allowlisted clarification `ToolMessage`s whose `tool_call_id` was produced by the current run, so human-input request cards remain recoverable from `run_events` after checkpoint compaction. Human Input Card replies are submitted as `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden response sources (currently `ask_clarification`) as `llm.human.input`, which preserves answered-card state after compaction without exposing generic internal hidden context.
|
||||
|
||||
### Configuration System
|
||||
|
||||
@ -613,7 +613,7 @@ copying a raw checkpoint because delta state is not self-contained in one tuple.
|
||||
2. **MCP tools** - From enabled MCP servers (lazy initialized, cached with resolved-path + content-signature invalidation)
|
||||
3. **Built-in tools**:
|
||||
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
|
||||
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards)
|
||||
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards). Beyond free text and single choice, the request-side v2 protocol supports `fields` (structured form card collecting several values at once; field types: text/textarea/number/select/multi_select/checkbox/date, validated and normalized server-side in the middleware — invalid entries are dropped, unknown types degrade to `text`; a standalone multi-select question is a one-field form). Replies stay on the v1 response protocol (`text`/`option`): the form card submits a readable text summary
|
||||
- `view_image` - Read image as base64 (added only if model supports vision)
|
||||
- `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`.
|
||||
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
|
||||
|
||||
@ -15,6 +15,47 @@ from langgraph.types import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Whitelisted form field types; anything else degrades to "text" so a bad
|
||||
# model-provided type can never produce an unrenderable card.
|
||||
FORM_FIELD_TYPES = frozenset({"text", "textarea", "number", "select", "multi_select", "checkbox", "date"})
|
||||
_OPTION_FIELD_TYPES = frozenset({"select", "multi_select"})
|
||||
|
||||
# Field names that collide with JavaScript Object.prototype properties. The
|
||||
# frontend stores form values in a plain object keyed by field name, so these
|
||||
# would read inherited prototype members instead of user input.
|
||||
_RESERVED_FIELD_NAMES = frozenset(
|
||||
{
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
"toString",
|
||||
"toLocaleString",
|
||||
"valueOf",
|
||||
"hasOwnProperty",
|
||||
"isPrototypeOf",
|
||||
"propertyIsEnumerable",
|
||||
"__defineGetter__",
|
||||
"__defineSetter__",
|
||||
"__lookupGetter__",
|
||||
"__lookupSetter__",
|
||||
}
|
||||
)
|
||||
|
||||
# Hard caps so a runaway model cannot publish an unbounded form. Exceeding a
|
||||
# cap is a structural error: the whole form degrades to the legacy modes
|
||||
# instead of silently truncating business fields.
|
||||
MAX_FORM_FIELDS = 16
|
||||
MAX_FIELD_OPTIONS = 24
|
||||
MAX_FIELD_TEXT_CHARS = 200
|
||||
# Total budget over the serialized normalized fields, in UTF-8 bytes. The
|
||||
# per-item caps alone still admit forms whose plain-text IM fallback exceeds
|
||||
# channel delivery limits (Slack truncates at 40k chars per message; Feishu
|
||||
# guides ~30KB per card), which would silently drop trailing fields — the very
|
||||
# thing atomic validation exists to prevent. 16KB keeps the fallback text of
|
||||
# any accepted form comfortably inside the strictest supported channel while
|
||||
# leaving headroom for question/context.
|
||||
MAX_FORM_SERIALIZED_BYTES = 16_384
|
||||
|
||||
|
||||
class ClarificationMiddlewareState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
@ -62,21 +103,141 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
if not isinstance(options, list):
|
||||
options = [options]
|
||||
|
||||
return [str(option) for option in options]
|
||||
# Trim, drop blanks, and dedupe (order-preserving): the frontend parser
|
||||
# rejects the whole payload on blank option labels, so they must never
|
||||
# be emitted.
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for option in options:
|
||||
text = str(option).strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
def _build_human_input_payload(self, args: dict[str, Any], *, tool_call_id: str, request_id: str) -> dict[str, Any]:
|
||||
"""Build the structured UI payload while keeping ToolMessage.content as fallback."""
|
||||
@staticmethod
|
||||
def _normalize_bool(raw: Any) -> bool:
|
||||
"""Coerce a model-provided boolean; some models serialize booleans as strings or 1/0."""
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, int | float):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() == "true"
|
||||
return False
|
||||
|
||||
def _normalize_fields(self, raw_fields: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize tool-provided form fields into the validated v2 field schema.
|
||||
|
||||
Validation is atomic: any structurally broken entry (non-dict, bad or
|
||||
reserved or duplicate name, over-cap counts/lengths) invalidates the
|
||||
whole form so the card can never render "complete" while silently
|
||||
missing a required business field. Benign issues keep their local
|
||||
degradation: unknown types and option-less selects become ``text``.
|
||||
"""
|
||||
fields = raw_fields
|
||||
if isinstance(fields, str):
|
||||
try:
|
||||
fields = json.loads(fields)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
if not isinstance(fields, list):
|
||||
return []
|
||||
if len(fields) > MAX_FORM_FIELDS:
|
||||
return []
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen_names: set[str] = set()
|
||||
for entry in fields:
|
||||
if not isinstance(entry, dict):
|
||||
return []
|
||||
raw_name = entry.get("name")
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
return []
|
||||
name = raw_name.strip()
|
||||
if name in _RESERVED_FIELD_NAMES or name in seen_names or len(name) > MAX_FIELD_TEXT_CHARS:
|
||||
return []
|
||||
seen_names.add(name)
|
||||
|
||||
raw_label = entry.get("label")
|
||||
label = raw_label.strip() if isinstance(raw_label, str) and raw_label.strip() else name
|
||||
if len(label) > MAX_FIELD_TEXT_CHARS:
|
||||
return []
|
||||
|
||||
field_type = entry.get("type")
|
||||
# isinstance guard first: `type: []` / `type: {}` are legal JSON
|
||||
# from a model, and an unhashable membership probe would raise
|
||||
# TypeError instead of degrading.
|
||||
if not isinstance(field_type, str) or field_type not in FORM_FIELD_TYPES:
|
||||
field_type = "text"
|
||||
|
||||
options = self._normalize_options(entry.get("options")) if field_type in _OPTION_FIELD_TYPES else []
|
||||
if len(options) > MAX_FIELD_OPTIONS or any(len(option) > MAX_FIELD_TEXT_CHARS for option in options):
|
||||
return []
|
||||
if field_type in _OPTION_FIELD_TYPES and not options:
|
||||
field_type = "text"
|
||||
|
||||
field: dict[str, Any] = {
|
||||
"name": name,
|
||||
"label": label,
|
||||
"type": field_type,
|
||||
"required": self._normalize_bool(entry.get("required")),
|
||||
}
|
||||
if field_type in _OPTION_FIELD_TYPES:
|
||||
field["options"] = [
|
||||
{
|
||||
"id": f"{name}-option-{index}",
|
||||
"label": option,
|
||||
"value": option,
|
||||
}
|
||||
for index, option in enumerate(options, 1)
|
||||
]
|
||||
placeholder = entry.get("placeholder")
|
||||
if isinstance(placeholder, str) and placeholder.strip():
|
||||
if len(placeholder.strip()) > MAX_FIELD_TEXT_CHARS:
|
||||
return []
|
||||
field["placeholder"] = placeholder.strip()
|
||||
normalized.append(field)
|
||||
|
||||
if len(json.dumps(normalized, ensure_ascii=False).encode("utf-8")) > MAX_FORM_SERIALIZED_BYTES:
|
||||
return []
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_human_input_payload(self, args: dict[str, Any], *, tool_call_id: str, request_id: str, fields: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
"""Build the structured UI payload while keeping ToolMessage.content as fallback.
|
||||
|
||||
Protocol versioning: legacy modes (``free_text`` / ``choice_with_other``)
|
||||
keep ``version: 1`` so their wire format is unchanged; the v2 ``form``
|
||||
mode carries ``version: 2`` so older frontends reject the payload and
|
||||
degrade to the plain-text ToolMessage content. Replies stay on the v1
|
||||
response protocol (``text`` / ``option``) — the form card submits a
|
||||
readable ``value`` summary, so no new response kind is introduced.
|
||||
|
||||
``fields`` accepts an already-normalized list so callers rendering both
|
||||
the payload and the text fallback normalize only once.
|
||||
"""
|
||||
if fields is None:
|
||||
fields = self._normalize_fields(args.get("fields"))
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
clarification_type = str(args.get("clarification_type", "missing_info"))
|
||||
|
||||
if fields:
|
||||
version, input_mode = 2, "form"
|
||||
elif options:
|
||||
version, input_mode = 1, "choice_with_other"
|
||||
else:
|
||||
version, input_mode = 1, "free_text"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"version": version,
|
||||
"kind": "human_input_request",
|
||||
"source": "ask_clarification",
|
||||
"request_id": request_id,
|
||||
"clarification_type": clarification_type,
|
||||
"question": str(args.get("question") or ""),
|
||||
"input_mode": "choice_with_other" if options else "free_text",
|
||||
"input_mode": input_mode,
|
||||
}
|
||||
|
||||
if tool_call_id:
|
||||
@ -86,7 +247,9 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
context = args.get("context")
|
||||
payload["context"] = None if context is None else str(context)
|
||||
|
||||
if options:
|
||||
if input_mode == "form":
|
||||
payload["fields"] = fields
|
||||
elif options:
|
||||
payload["options"] = [
|
||||
{
|
||||
"id": f"option-{index}",
|
||||
@ -109,18 +272,24 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
"""
|
||||
return any("\u4e00" <= char <= "\u9fff" for char in text)
|
||||
|
||||
def _format_clarification_message(self, args: dict) -> str:
|
||||
def _format_clarification_message(self, args: dict, fields: list[dict[str, Any]] | None = None) -> str:
|
||||
"""Format the clarification arguments into a user-friendly message.
|
||||
|
||||
Args:
|
||||
args: The tool call arguments containing clarification details
|
||||
fields: Already-normalized form fields, so callers rendering both
|
||||
the payload and this fallback normalize only once
|
||||
|
||||
Returns:
|
||||
Formatted message string
|
||||
"""
|
||||
question = args.get("question", "")
|
||||
clarification_type = args.get("clarification_type", "missing_info")
|
||||
# str() coercion keeps the icon lookup hashable — `clarification_type:
|
||||
# []` is legal JSON from a model and would raise TypeError as a dict key.
|
||||
clarification_type = str(args.get("clarification_type", "missing_info"))
|
||||
context = args.get("context")
|
||||
if fields is None:
|
||||
fields = self._normalize_fields(args.get("fields"))
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
|
||||
# Type-specific icons
|
||||
@ -146,8 +315,22 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
# Just the question with icon
|
||||
message_parts.append(f"{icon} {question}")
|
||||
|
||||
# Add options in a cleaner format
|
||||
if options and len(options) > 0:
|
||||
# Form fields take precedence over options, mirroring the payload logic.
|
||||
if fields:
|
||||
message_parts.append("") # blank line for spacing
|
||||
for i, field in enumerate(fields, 1):
|
||||
line = f" {i}. {field['label']}"
|
||||
if field["required"]:
|
||||
line += " (required)"
|
||||
field_options = field.get("options")
|
||||
if field_options:
|
||||
line += " — options: " + " / ".join(option["label"] for option in field_options)
|
||||
if field["type"] == "multi_select":
|
||||
line += " (multiple allowed)"
|
||||
message_parts.append(line)
|
||||
message_parts.append("")
|
||||
message_parts.append("Please reply with a value for each field.")
|
||||
elif options and len(options) > 0:
|
||||
message_parts.append("") # blank line for spacing
|
||||
for i, option in enumerate(options, 1):
|
||||
message_parts.append(f" {i}. {option}")
|
||||
@ -208,14 +391,18 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
logger.info("Intercepted clarification request")
|
||||
logger.debug("Clarification question: %s", question)
|
||||
|
||||
# Normalize form fields once; both the text fallback and the payload
|
||||
# consume the same result.
|
||||
fields = self._normalize_fields(args.get("fields"))
|
||||
|
||||
# Format the clarification message
|
||||
formatted_message = self._format_clarification_message(args)
|
||||
formatted_message = self._format_clarification_message(args, fields=fields)
|
||||
|
||||
# Get the tool call ID
|
||||
tool_call_id = request.tool_call.get("id", "")
|
||||
|
||||
request_id = self._stable_message_id(tool_call_id, formatted_message)
|
||||
human_input_payload = self._build_human_input_payload(args, tool_call_id=tool_call_id, request_id=request_id)
|
||||
human_input_payload = self._build_human_input_payload(args, tool_call_id=tool_call_id, request_id=request_id, fields=fields)
|
||||
|
||||
# Create a ToolMessage with the formatted question
|
||||
# This will be added to the message history
|
||||
|
||||
@ -1,8 +1,24 @@
|
||||
from typing import Literal
|
||||
from typing import Literal, Required, TypedDict
|
||||
|
||||
from langchain.tools import tool
|
||||
|
||||
|
||||
class ClarificationFormField(TypedDict, total=False):
|
||||
"""One form field definition for a structured clarification card.
|
||||
|
||||
The model-visible schema documents the item shape; runtime validation
|
||||
still happens defensively in ``ClarificationMiddleware`` because the
|
||||
middleware intercepts the call before tool execution.
|
||||
"""
|
||||
|
||||
name: Required[str]
|
||||
label: str
|
||||
type: Literal["text", "textarea", "number", "select", "multi_select", "checkbox", "date"]
|
||||
required: bool
|
||||
options: list[str]
|
||||
placeholder: str
|
||||
|
||||
|
||||
@tool("ask_clarification", parse_docstring=True, return_direct=True)
|
||||
def ask_clarification_tool(
|
||||
question: str,
|
||||
@ -15,6 +31,7 @@ def ask_clarification_tool(
|
||||
],
|
||||
context: str | None = None,
|
||||
options: list[str] | None = None,
|
||||
fields: list[ClarificationFormField] | None = None,
|
||||
) -> str:
|
||||
"""Ask the user for clarification when you need more information to proceed.
|
||||
|
||||
@ -36,11 +53,22 @@ def ask_clarification_tool(
|
||||
- You're about to perform a potentially dangerous operation
|
||||
- You have a recommendation but need user approval
|
||||
|
||||
Choosing the interaction shape:
|
||||
- One open question -> just `question` (free text input)
|
||||
- Pick exactly one option -> `options`
|
||||
- Pick several options -> a single `fields` entry of type `multi_select`
|
||||
- Collect several values at once (e.g. a set of parameters for one action) ->
|
||||
`fields`, which renders a single structured form instead of several
|
||||
sequential questions. Prefer one form over asking field-by-field.
|
||||
|
||||
Best practices:
|
||||
- Ask ONE clarification at a time for clarity
|
||||
- Ask ONE clarification at a time for clarity; a form with several fields
|
||||
still counts as one clarification
|
||||
- Be specific and clear in your question
|
||||
- Don't make assumptions when clarification is needed
|
||||
- For risky operations, ALWAYS ask for confirmation
|
||||
- If a skill provides a predefined field template, pass it through `fields`
|
||||
unchanged instead of redesigning it
|
||||
- After calling this tool, execution will be interrupted automatically
|
||||
|
||||
Args:
|
||||
@ -48,6 +76,15 @@ def ask_clarification_tool(
|
||||
clarification_type: The type of clarification needed (missing_info, ambiguous_requirement, approach_choice, risk_confirmation, suggestion).
|
||||
context: Optional context explaining why clarification is needed. Helps the user understand the situation.
|
||||
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from.
|
||||
fields: Optional form field definitions for collecting multiple values in one card; takes precedence over `options`.
|
||||
Each field is an object with `name` (unique identifier, required; avoid JavaScript prototype names like
|
||||
`constructor` or `toString`), `label` (display text, defaults to name), `type` (one of: text, textarea,
|
||||
number, select, multi_select, checkbox, date; defaults to text), `required` (boolean, defaults to false),
|
||||
`options` (list of strings, required for select/multi_select types), and `placeholder` (optional hint text).
|
||||
A `checkbox` field is a boolean that defaults to "no"; set `required` on a checkbox only for
|
||||
must-agree/consent semantics (the user has to tick it to submit). Keep forms bounded: at most 16 fields,
|
||||
24 options per field, and 200 characters per name/label/option/placeholder — exceeding a limit degrades
|
||||
the whole request to a plain-text question.
|
||||
"""
|
||||
# This is a placeholder implementation
|
||||
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
|
||||
|
||||
@ -215,6 +215,396 @@ class TestHumanInputPayload:
|
||||
assert "options" not in payload
|
||||
|
||||
|
||||
class TestFormPayload:
|
||||
"""v2 protocol: fields render a structured form card."""
|
||||
|
||||
def _fields(self):
|
||||
return [
|
||||
{"name": "amount", "label": "Amount", "type": "number", "required": True},
|
||||
{"name": "category", "label": "Category", "type": "select", "options": ["travel", "meals"], "required": True},
|
||||
{"name": "receipts", "label": "Receipts", "type": "multi_select", "options": ["A-1", "A-2"]},
|
||||
{"name": "note", "type": "textarea"},
|
||||
]
|
||||
|
||||
def test_form_payload_with_native_fields(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Please provide the expense details.",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": self._fields(),
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["version"] == 2
|
||||
assert payload["input_mode"] == "form"
|
||||
assert payload["fields"] == [
|
||||
{"name": "amount", "label": "Amount", "type": "number", "required": True},
|
||||
{
|
||||
"name": "category",
|
||||
"label": "Category",
|
||||
"type": "select",
|
||||
"required": True,
|
||||
"options": [
|
||||
{"id": "category-option-1", "label": "travel", "value": "travel"},
|
||||
{"id": "category-option-2", "label": "meals", "value": "meals"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "receipts",
|
||||
"label": "Receipts",
|
||||
"type": "multi_select",
|
||||
"required": False,
|
||||
"options": [
|
||||
{"id": "receipts-option-1", "label": "A-1", "value": "A-1"},
|
||||
{"id": "receipts-option-2", "label": "A-2", "value": "A-2"},
|
||||
],
|
||||
},
|
||||
{"name": "note", "label": "note", "type": "textarea", "required": False},
|
||||
]
|
||||
|
||||
def test_form_payload_with_json_string_fields(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": json.dumps([{"name": "amount", "type": "number"}]),
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["input_mode"] == "form"
|
||||
assert payload["fields"] == [{"name": "amount", "label": "amount", "type": "number", "required": False}]
|
||||
|
||||
def test_form_takes_precedence_over_options(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": "amount"}],
|
||||
"options": ["dev", "prod"],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["input_mode"] == "form"
|
||||
assert "options" not in payload
|
||||
|
||||
def test_unknown_field_type_degrades_to_text(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": "amount", "type": "slider"}],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["fields"][0]["type"] == "text"
|
||||
|
||||
def test_select_without_options_degrades_to_text(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": "category", "type": "select"}],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["fields"][0]["type"] == "text"
|
||||
assert "options" not in payload["fields"][0]
|
||||
|
||||
def test_any_invalid_field_entry_degrades_whole_form(self, middleware):
|
||||
"""Atomic validation: a structurally broken entry must not silently
|
||||
vanish from an otherwise-rendered form — the whole form degrades."""
|
||||
for bad_entry in ["not-a-dict", {"label": "no name"}, {"name": " "}]:
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [bad_entry, {"name": "amount", "type": "number"}],
|
||||
"options": ["dev", "prod"],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["version"] == 1
|
||||
assert payload["input_mode"] == "choice_with_other"
|
||||
assert "fields" not in payload
|
||||
|
||||
def test_duplicate_field_names_degrade_whole_form(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [
|
||||
{"name": "amount", "type": "number"},
|
||||
{"name": "amount", "type": "text"},
|
||||
],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["version"] == 1
|
||||
assert payload["input_mode"] == "free_text"
|
||||
assert "fields" not in payload
|
||||
|
||||
def test_reserved_prototype_field_names_degrade_whole_form(self, middleware):
|
||||
"""`__proto__`/`constructor`/... collide with JS Object.prototype in the
|
||||
frontend form-value store and must be rejected server-side."""
|
||||
for reserved in ["__proto__", "constructor", "toString", "hasOwnProperty"]:
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": reserved, "type": "text"}],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["input_mode"] == "free_text", reserved
|
||||
assert "fields" not in payload
|
||||
|
||||
def test_field_options_are_trimmed_deduped_and_blank_dropped(self, middleware):
|
||||
"""Backend must never emit blank option labels — the frontend parser
|
||||
rejects the whole payload on them."""
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": "category", "type": "select", "options": [" travel ", "", " ", "travel", "meals"]}],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert [option["label"] for option in payload["fields"][0]["options"]] == ["travel", "meals"]
|
||||
|
||||
def test_field_count_over_cap_degrades_whole_form(self, middleware):
|
||||
fields = [{"name": f"field_{i}", "type": "text"} for i in range(17)]
|
||||
payload = middleware._build_human_input_payload(
|
||||
{"question": "Details please", "clarification_type": "missing_info", "fields": fields},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["input_mode"] == "free_text"
|
||||
assert "fields" not in payload
|
||||
|
||||
def test_option_count_over_cap_degrades_whole_form(self, middleware):
|
||||
options = [f"option-{i}" for i in range(25)]
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": "category", "type": "select", "options": options}],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["input_mode"] == "free_text"
|
||||
assert "fields" not in payload
|
||||
|
||||
def test_overlong_field_text_degrades_whole_form(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": "amount", "label": "x" * 201, "type": "number"}],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["input_mode"] == "free_text"
|
||||
assert "fields" not in payload
|
||||
|
||||
def test_top_level_options_are_trimmed_and_blank_dropped(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Which env?",
|
||||
"clarification_type": "approach_choice",
|
||||
"options": [" dev ", "", "prod", "dev"],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["options"] == [
|
||||
{"id": "option-1", "label": "dev", "value": "dev"},
|
||||
{"id": "option-2", "label": "prod", "value": "prod"},
|
||||
]
|
||||
|
||||
def test_unhashable_field_type_degrades_to_text_not_crash(self, middleware):
|
||||
"""`type: []` / `type: {}` are legal JSON from a model; an unhashable
|
||||
membership check would raise TypeError and (with return_direct=True)
|
||||
end the turn with an error instead of any card or fallback."""
|
||||
for bad_type in [[], {}, ["select"], {"t": "select"}]:
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": "amount", "type": bad_type}],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["fields"][0]["type"] == "text"
|
||||
|
||||
def test_unhashable_clarification_type_does_not_crash(self, middleware):
|
||||
"""Same crash class as unhashable field types: `clarification_type: []`
|
||||
must not raise in the icon lookup or payload builder."""
|
||||
from langgraph.types import Command
|
||||
|
||||
request = SimpleNamespace(
|
||||
tool_call={
|
||||
"name": "ask_clarification",
|
||||
"id": "call-clarify-1",
|
||||
"args": {"question": "q?", "clarification_type": [], "fields": [{"name": "amount", "type": []}]},
|
||||
},
|
||||
runtime=None,
|
||||
)
|
||||
|
||||
result = middleware.wrap_tool_call(request, lambda _req: pytest.fail("handler should not be called"))
|
||||
|
||||
assert isinstance(result, Command)
|
||||
message = result.update["messages"][0]
|
||||
assert message.artifact["human_input"]["input_mode"] == "form"
|
||||
|
||||
def test_serialized_fields_over_byte_budget_degrade_whole_form(self, middleware):
|
||||
"""Per-item caps alone allow a form whose IM text fallback exceeds
|
||||
channel limits (Slack 40k chars, Feishu ~30KB card); a total serialized
|
||||
byte budget must bound the whole definition."""
|
||||
fields = [
|
||||
{
|
||||
"name": f"field_{i}",
|
||||
"label": "标" * 190,
|
||||
"type": "select",
|
||||
"options": ["选" * 190 + str(j) for j in range(20)],
|
||||
}
|
||||
for i in range(16)
|
||||
]
|
||||
payload = middleware._build_human_input_payload(
|
||||
{"question": "Details please", "clarification_type": "missing_info", "fields": fields},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["input_mode"] == "free_text"
|
||||
assert "fields" not in payload
|
||||
|
||||
def test_accepted_forms_keep_text_fallback_under_channel_limits(self, middleware):
|
||||
"""Boundary: any form the byte budget accepts must produce an IM text
|
||||
fallback deliverable to the strictest supported channel (~30KB)."""
|
||||
# Grow a form until the budget rejects it; the largest accepted form's
|
||||
# fallback must stay under the channel bound.
|
||||
largest_accepted_fallback = None
|
||||
for count in range(1, 17):
|
||||
fields = [
|
||||
{
|
||||
"name": f"field_{i}",
|
||||
"label": "标" * 150,
|
||||
"type": "select",
|
||||
"options": ["选" * 100 + str(j) for j in range(10)],
|
||||
}
|
||||
for i in range(count)
|
||||
]
|
||||
args = {"question": "Q", "clarification_type": "missing_info", "fields": fields}
|
||||
payload = middleware._build_human_input_payload(args, tool_call_id="c", request_id="clarification:c")
|
||||
if payload["input_mode"] != "form":
|
||||
break
|
||||
largest_accepted_fallback = middleware._format_clarification_message(args)
|
||||
|
||||
assert largest_accepted_fallback is not None
|
||||
assert len(largest_accepted_fallback.encode("utf-8")) < 30_000
|
||||
|
||||
def test_required_accepts_integer_serialization(self, middleware):
|
||||
"""Some providers emit 1/0 for booleans; `required: 1` must not
|
||||
silently flip a model-intended required field to optional."""
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [
|
||||
{"name": "amount", "type": "number", "required": 1},
|
||||
{"name": "note", "type": "text", "required": 0},
|
||||
],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["fields"][0]["required"] is True
|
||||
assert payload["fields"][1]["required"] is False
|
||||
|
||||
def test_field_placeholder_is_preserved(self, middleware):
|
||||
payload = middleware._build_human_input_payload(
|
||||
{
|
||||
"question": "Details please",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": [{"name": "note", "placeholder": "Optional remarks"}],
|
||||
},
|
||||
tool_call_id="call-abc",
|
||||
request_id="clarification:call-abc",
|
||||
)
|
||||
|
||||
assert payload["fields"][0]["placeholder"] == "Optional remarks"
|
||||
|
||||
def test_form_fallback_text_lists_fields(self, middleware):
|
||||
result = middleware._format_clarification_message(
|
||||
{
|
||||
"question": "Please provide the expense details.",
|
||||
"clarification_type": "missing_info",
|
||||
"fields": self._fields(),
|
||||
}
|
||||
)
|
||||
|
||||
assert "1. Amount (required)" in result
|
||||
assert "2. Category (required) — options: travel / meals" in result
|
||||
assert "3. Receipts — options: A-1 / A-2 (multiple allowed)" in result
|
||||
assert "4. note" in result
|
||||
assert "Please reply with a value for each field." in result
|
||||
|
||||
|
||||
class TestClarificationToolSchema:
|
||||
"""The tool schema must expose the v2 form parameter (request-side only)."""
|
||||
|
||||
def test_tool_exposes_fields_argument(self):
|
||||
from deerflow.tools.builtins.clarification_tool import ask_clarification_tool
|
||||
|
||||
schema = ask_clarification_tool.args
|
||||
assert "fields" in schema
|
||||
# The reply protocol stays v1 (text/option): no top-level multi_select
|
||||
# mode — a standalone multi-select question is a one-field form.
|
||||
assert "multi_select" not in schema
|
||||
|
||||
def test_fields_item_schema_is_typed(self):
|
||||
"""The provider-facing schema must expose the field item shape (typed
|
||||
via ClarificationFormField), not an opaque object relying on the
|
||||
docstring alone."""
|
||||
from langchain_core.utils.function_calling import convert_to_openai_tool
|
||||
|
||||
from deerflow.tools.builtins.clarification_tool import ask_clarification_tool
|
||||
|
||||
parameters = convert_to_openai_tool(ask_clarification_tool)["function"]["parameters"]
|
||||
items = parameters["properties"]["fields"]["anyOf"][0]["items"]
|
||||
|
||||
assert items["required"] == ["name"]
|
||||
assert sorted(items["properties"].keys()) == ["label", "name", "options", "placeholder", "required", "type"]
|
||||
assert items["properties"]["type"]["enum"] == ["text", "textarea", "number", "select", "multi_select", "checkbox", "date"]
|
||||
|
||||
|
||||
class TestClarificationCommandIdempotency:
|
||||
"""Clarification tool-call retries should not duplicate messages in state."""
|
||||
|
||||
|
||||
@ -22,3 +22,10 @@ def test_read_human_input_response_preserves_non_empty_value():
|
||||
|
||||
assert response is not None
|
||||
assert response["value"] == " staging "
|
||||
|
||||
|
||||
def test_read_human_input_response_rejects_unknown_kind():
|
||||
raw = _text_response("staging")
|
||||
raw["response_kind"] = "unknown"
|
||||
|
||||
assert read_human_input_response({"human_input_response": raw}) is None
|
||||
|
||||
@ -81,7 +81,7 @@ Auth UI note: the login page's "keep me signed in" option submits only `remember
|
||||
|
||||
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.
|
||||
|
||||
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points should disable normal bottom input while `hasOpenHumanInputRequest(...)` is true so users answer through the card and preserve response metadata.
|
||||
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. The protocol is versioned on the request side only: v1 covers `free_text` / `choice_with_other`, and v2 adds `form` (typed fields — text/textarea/number/select/multi_select/checkbox/date — with required-field validation in the card). Replies deliberately stay on the v1 response protocol: the form card submits a `response_kind: "text"` reply whose value is the human-readable summary plus one JSON block keyed by stable field names (`buildHumanInputFormSubmissionValue` — the readable part alone is ambiguous because labels/values may contain the separators), so the model can reconstruct the submitted mapping without a structured response kind. The validators reject unknown versions/modes (and field names colliding with JS `Object.prototype` members) so future protocol bumps degrade to the plain-text ToolMessage fallback rather than rendering a broken card. Form values are read through own-property access only (`readHumanInputFormValue`); select fields stay controlled from their empty-string placeholder state through selection; checkbox fields are native `<input type="checkbox">` controls seeded to an explicit `false` (`buildInitialHumanInputFormValues`) so an untouched checkbox submits as "no" while a `required` checkbox keeps must-agree semantics (no HTML `required` attribute — native constraint validation would intercept the custom submit path), and form controls carry label/`htmlFor`, `aria-required` plus a visually-hidden localized "required" marker, and `aria-invalid`/error associations whose error node stays mounted while any field is still invalid. Legacy-fallback closure: `deriveHumanInputThreadState` treats a visible plain human message as answering the latest unanswered request opened before it (only the latest — nothing guarantees a single outstanding request across runs, and closing all would silently swallow older decisions; an older request left open simply becomes the active card again) — an old v1-only frontend degrades a v2 request to text and the user replies through the normal composer without response metadata, and without this rule an upgraded frontend would see that request as still open and lock the composer. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points should disable normal bottom input while `hasOpenHumanInputRequest(...)` is true so users answer through the card and preserve response metadata.
|
||||
|
||||
Tool-calling AI messages can contain user-visible text as well as `tool_calls`. `core/messages/utils.ts` keeps these turns in an `assistant:processing` group, and `components/workspace/messages/message-group.tsx` must render the visible text as a processing step instead of treating the message as only tool metadata. This preserves provider text such as error explanations or "trying another approach" notes during tool-heavy runs.
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
CheckIcon,
|
||||
Loader2Icon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
} from "lucide-react";
|
||||
@ -9,11 +10,25 @@ import { useId, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
buildHumanInputFormSubmissionValue,
|
||||
buildHumanInputFormSummary,
|
||||
buildInitialHumanInputFormValues,
|
||||
createHumanInputOptionResponse,
|
||||
createHumanInputTextResponse,
|
||||
readHumanInputFormValue,
|
||||
type HumanInputField,
|
||||
type HumanInputFormValue,
|
||||
type HumanInputOption,
|
||||
type HumanInputRequest,
|
||||
type HumanInputResponse,
|
||||
@ -36,6 +51,169 @@ export function shouldSubmitHumanInputTextOnKeyDown(
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyFieldValue(value: HumanInputFormValue | undefined) {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length === 0;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 0;
|
||||
}
|
||||
return value === false;
|
||||
}
|
||||
|
||||
export function findMissingRequiredFields(
|
||||
fields: HumanInputField[],
|
||||
values: Record<string, HumanInputFormValue>,
|
||||
) {
|
||||
// Own-property reads only: field names like "toString" must never resolve
|
||||
// to inherited Object.prototype members and satisfy required validation.
|
||||
return fields.filter(
|
||||
(field) =>
|
||||
field.required &&
|
||||
isEmptyFieldValue(readHumanInputFormValue(values, field.name)),
|
||||
);
|
||||
}
|
||||
|
||||
function FormFieldInput({
|
||||
field,
|
||||
value,
|
||||
disabled,
|
||||
selectPlaceholder,
|
||||
controlId,
|
||||
labelId,
|
||||
invalid,
|
||||
errorId,
|
||||
onChange,
|
||||
}: {
|
||||
field: HumanInputField;
|
||||
value: HumanInputFormValue | undefined;
|
||||
disabled: boolean;
|
||||
selectPlaceholder: string;
|
||||
controlId: string;
|
||||
labelId: string;
|
||||
invalid: boolean;
|
||||
errorId?: string;
|
||||
onChange: (value: HumanInputFormValue) => void;
|
||||
}) {
|
||||
const stringValue = typeof value === "string" ? value : "";
|
||||
const ariaProps = {
|
||||
"aria-required": field.required || undefined,
|
||||
"aria-invalid": invalid || undefined,
|
||||
"aria-describedby": invalid ? errorId : undefined,
|
||||
};
|
||||
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<Textarea
|
||||
id={controlId}
|
||||
className="min-h-20 resize-y text-sm"
|
||||
disabled={disabled}
|
||||
placeholder={field.placeholder}
|
||||
value={stringValue}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
{...ariaProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={stringValue}
|
||||
onValueChange={(next) => onChange(next)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={controlId}
|
||||
className="w-full"
|
||||
aria-labelledby={labelId}
|
||||
{...ariaProps}
|
||||
>
|
||||
<SelectValue placeholder={field.placeholder ?? selectPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options ?? []).map((option) => (
|
||||
<SelectItem key={option.id} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "multi_select") {
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
return (
|
||||
<div
|
||||
id={controlId}
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={invalid ? errorId : undefined}
|
||||
>
|
||||
{(field.options ?? []).map((option) => {
|
||||
const selected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"h-8 w-fit rounded-md px-2.5 text-left leading-5 whitespace-normal",
|
||||
selected && "border-primary/40 bg-primary/10",
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onChange(
|
||||
selected
|
||||
? selectedValues.filter((entry) => entry !== option.value)
|
||||
: [...selectedValues, option.value],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"border-border flex size-3.5 shrink-0 items-center justify-center rounded-sm border",
|
||||
selected &&
|
||||
"border-primary bg-primary text-primary-foreground",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{selected ? <CheckIcon className="size-2.5" /> : null}
|
||||
</span>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={controlId}
|
||||
className="text-sm"
|
||||
disabled={disabled}
|
||||
placeholder={field.placeholder}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: "text"
|
||||
}
|
||||
value={stringValue}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
{...ariaProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HumanInputCard({
|
||||
request,
|
||||
disabled = false,
|
||||
@ -55,12 +233,22 @@ export function HumanInputCard({
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [formValues, setFormValues] = useState<
|
||||
Record<string, HumanInputFormValue>
|
||||
>(() => buildInitialHumanInputFormValues(request.fields ?? []));
|
||||
const [invalidFieldNames, setInvalidFieldNames] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const titleId = useId();
|
||||
const textInputId = useId();
|
||||
const formFieldIdBase = useId();
|
||||
const formErrorId = `${formFieldIdBase}-error`;
|
||||
const isForm = request.input_mode === "form";
|
||||
const allowText =
|
||||
request.input_mode === "free_text" ||
|
||||
request.input_mode === "choice_with_other";
|
||||
const options = request.options ?? [];
|
||||
const fields = request.fields ?? [];
|
||||
const readOnly = !onSubmit;
|
||||
const isDisabled =
|
||||
disabled || pending || Boolean(answeredResponse) || readOnly;
|
||||
@ -87,6 +275,42 @@ export function HumanInputCard({
|
||||
void submitResponse(createHumanInputOptionResponse(request, option));
|
||||
};
|
||||
|
||||
const handleFormValueChange = (name: string, value: HumanInputFormValue) => {
|
||||
const remaining = new Set(invalidFieldNames);
|
||||
remaining.delete(name);
|
||||
setInvalidFieldNames(remaining);
|
||||
// Keep the error node mounted while other fields are still invalid —
|
||||
// their aria-describedby must keep pointing at an existing element.
|
||||
if (remaining.size === 0) {
|
||||
setError("");
|
||||
}
|
||||
setFormValues((previous) => ({ ...previous, [name]: value }));
|
||||
};
|
||||
|
||||
const handleFormSubmit = (event: { preventDefault(): void }) => {
|
||||
event.preventDefault();
|
||||
const missing = findMissingRequiredFields(fields, formValues);
|
||||
if (missing.length > 0) {
|
||||
setInvalidFieldNames(new Set(missing.map((field) => field.name)));
|
||||
setError(t.humanInput.requiredError);
|
||||
return;
|
||||
}
|
||||
// Per the request-side-only protocol scope, form answers are submitted as
|
||||
// a readable v1 text response — no structured response kind is introduced.
|
||||
// The submitted value carries a JSON block keyed by stable field names so
|
||||
// the model can reconstruct the mapping unambiguously.
|
||||
if (!buildHumanInputFormSummary(request, formValues).trim()) {
|
||||
setError(t.humanInput.emptyError);
|
||||
return;
|
||||
}
|
||||
void submitResponse(
|
||||
createHumanInputTextResponse(
|
||||
request,
|
||||
buildHumanInputFormSubmissionValue(request, formValues),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleTextSubmit = (event: { preventDefault(): void }) => {
|
||||
event.preventDefault();
|
||||
const value = text.trim();
|
||||
@ -109,6 +333,31 @@ export function HumanInputCard({
|
||||
}
|
||||
};
|
||||
|
||||
const submitFooter = (
|
||||
<div className="flex min-h-9 flex-wrap items-center justify-between gap-2">
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" id={formErrorId} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : answeredResponse ? (
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{t.humanInput.answeredValue(answeredResponse.value)}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
className="min-w-24"
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
>
|
||||
{pending ? <Loader2Icon className="size-4 animate-spin" /> : null}
|
||||
{t.humanInput.submit}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={titleId}
|
||||
@ -159,7 +408,100 @@ export function HumanInputCard({
|
||||
<MarkdownContent content={request.question} isLoading={false} />
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
{isForm ? (
|
||||
<form className="space-y-4" onSubmit={handleFormSubmit}>
|
||||
{fields.map((field, index) => {
|
||||
const controlId = `${formFieldIdBase}-${index}`;
|
||||
const labelId = `${controlId}-label`;
|
||||
const fieldValue = readHumanInputFormValue(
|
||||
formValues,
|
||||
field.name,
|
||||
);
|
||||
const invalid = invalidFieldNames.has(field.name);
|
||||
|
||||
if (field.type === "checkbox") {
|
||||
const checked = fieldValue === true;
|
||||
// Native checkbox: real role/checked semantics for assistive
|
||||
// technology instead of an aria-pressed button. No HTML
|
||||
// `required` attribute — native constraint validation would
|
||||
// intercept the form's custom submit handling.
|
||||
return (
|
||||
<label
|
||||
key={field.name}
|
||||
className="flex w-fit cursor-pointer items-center gap-2 text-sm leading-5"
|
||||
htmlFor={controlId}
|
||||
>
|
||||
<input
|
||||
id={controlId}
|
||||
checked={checked}
|
||||
className="accent-primary size-4"
|
||||
disabled={isDisabled}
|
||||
type="checkbox"
|
||||
aria-required={field.required || undefined}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? formErrorId : undefined}
|
||||
onChange={(event) =>
|
||||
handleFormValueChange(
|
||||
field.name,
|
||||
event.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{field.label}
|
||||
{field.required ? (
|
||||
<>
|
||||
<span className="text-destructive" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{t.humanInput.requiredA11yLabel}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.name} className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm leading-5 font-medium"
|
||||
htmlFor={controlId}
|
||||
id={labelId}
|
||||
>
|
||||
{field.label}
|
||||
{field.required ? (
|
||||
<>
|
||||
<span className="text-destructive ml-0.5" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{t.humanInput.requiredA11yLabel}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</label>
|
||||
<FormFieldInput
|
||||
controlId={controlId}
|
||||
disabled={isDisabled}
|
||||
errorId={formErrorId}
|
||||
field={field}
|
||||
invalid={invalid}
|
||||
labelId={labelId}
|
||||
selectPlaceholder={t.humanInput.selectPlaceholder}
|
||||
value={fieldValue}
|
||||
onChange={(value) =>
|
||||
handleFormValueChange(field.name, value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{submitFooter}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{!isForm && options.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
@ -232,7 +574,9 @@ export function HumanInputCard({
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : answeredResponse ? (
|
||||
) : null}
|
||||
|
||||
{!allowText && !isForm && answeredResponse ? (
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{t.humanInput.answeredValue(answeredResponse.value)}
|
||||
</p>
|
||||
|
||||
@ -629,6 +629,9 @@ export const enUS: Translations = {
|
||||
otherPlaceholder: "Type another answer...",
|
||||
submit: "Submit",
|
||||
emptyError: "Enter an answer before submitting.",
|
||||
requiredError: "Fill in all required fields before submitting.",
|
||||
requiredA11yLabel: "required",
|
||||
selectPlaceholder: "Select...",
|
||||
answeredValue: (value: string) => `Answered: ${value}`,
|
||||
},
|
||||
|
||||
|
||||
@ -515,6 +515,9 @@ export interface Translations {
|
||||
otherPlaceholder: string;
|
||||
submit: string;
|
||||
emptyError: string;
|
||||
requiredError: string;
|
||||
requiredA11yLabel: string;
|
||||
selectPlaceholder: string;
|
||||
answeredValue: (value: string) => string;
|
||||
};
|
||||
|
||||
|
||||
@ -604,6 +604,9 @@ export const zhCN: Translations = {
|
||||
otherPlaceholder: "输入其他回答...",
|
||||
submit: "提交",
|
||||
emptyError: "请输入回答后再提交。",
|
||||
requiredError: "请填写所有必填字段后再提交。",
|
||||
requiredA11yLabel: "必填",
|
||||
selectPlaceholder: "请选择...",
|
||||
answeredValue: (value: string) => `已回答:${value}`,
|
||||
},
|
||||
|
||||
|
||||
@ -3,7 +3,8 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
export type HumanInputMode =
|
||||
| "free_text"
|
||||
| "single_choice"
|
||||
| "choice_with_other";
|
||||
| "choice_with_other"
|
||||
| "form";
|
||||
|
||||
export type HumanInputOption = {
|
||||
id: string;
|
||||
@ -11,8 +12,28 @@ export type HumanInputOption = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type HumanInputFieldType =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "select"
|
||||
| "multi_select"
|
||||
| "checkbox"
|
||||
| "date";
|
||||
|
||||
export type HumanInputField = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: HumanInputFieldType;
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
options?: HumanInputOption[];
|
||||
};
|
||||
|
||||
export type HumanInputFormValue = string | number | boolean | string[];
|
||||
|
||||
export type HumanInputRequest = {
|
||||
version: 1;
|
||||
version: 1 | 2;
|
||||
kind: "human_input_request";
|
||||
source: "ask_clarification" | string;
|
||||
request_id: string;
|
||||
@ -23,6 +44,7 @@ export type HumanInputRequest = {
|
||||
context?: string | null;
|
||||
input_mode: HumanInputMode;
|
||||
options?: HumanInputOption[];
|
||||
fields?: HumanInputField[];
|
||||
};
|
||||
|
||||
export type HumanInputResponse =
|
||||
@ -77,7 +99,63 @@ function isHumanInputMode(value: unknown): value is HumanInputMode {
|
||||
return (
|
||||
value === "free_text" ||
|
||||
value === "single_choice" ||
|
||||
value === "choice_with_other"
|
||||
value === "choice_with_other" ||
|
||||
value === "form"
|
||||
);
|
||||
}
|
||||
|
||||
// Field names that collide with JavaScript Object.prototype members. Form
|
||||
// values live in a plain object keyed by field name, so these would resolve
|
||||
// to inherited properties instead of user input.
|
||||
const RESERVED_FIELD_NAMES = new Set([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
"toString",
|
||||
"toLocaleString",
|
||||
"valueOf",
|
||||
"hasOwnProperty",
|
||||
"isPrototypeOf",
|
||||
"propertyIsEnumerable",
|
||||
"__defineGetter__",
|
||||
"__defineSetter__",
|
||||
"__lookupGetter__",
|
||||
"__lookupSetter__",
|
||||
]);
|
||||
|
||||
export function readHumanInputFormValue(
|
||||
values: Record<string, HumanInputFormValue>,
|
||||
name: string,
|
||||
): HumanInputFormValue | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(values, name)
|
||||
? values[name]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function buildInitialHumanInputFormValues(
|
||||
fields: HumanInputField[],
|
||||
): Record<string, HumanInputFormValue> {
|
||||
// Checkboxes are booleans that default to an explicit "no" — without the
|
||||
// seed an untouched checkbox would be indistinguishable from an unanswered
|
||||
// field and silently vanish from the submitted summary.
|
||||
const values: Record<string, HumanInputFormValue> = {};
|
||||
for (const field of fields) {
|
||||
if (field.type === "checkbox") {
|
||||
values[field.name] = false;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function isHumanInputFieldType(value: unknown): value is HumanInputFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "textarea" ||
|
||||
value === "number" ||
|
||||
value === "select" ||
|
||||
value === "multi_select" ||
|
||||
value === "checkbox" ||
|
||||
value === "date"
|
||||
);
|
||||
}
|
||||
|
||||
@ -94,6 +172,8 @@ function parseOptions(value: unknown): HumanInputOption[] | undefined {
|
||||
}
|
||||
|
||||
const options: HumanInputOption[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const seenValues = new Set<string>();
|
||||
for (const option of value) {
|
||||
if (!isRecord(option)) {
|
||||
return undefined;
|
||||
@ -104,15 +184,78 @@ function parseOptions(value: unknown): HumanInputOption[] | undefined {
|
||||
if (
|
||||
!isNonEmptyString(id) ||
|
||||
!isNonEmptyString(label) ||
|
||||
typeof optionValue !== "string"
|
||||
// An empty option value crashes Radix <SelectItem value="">; a
|
||||
// malformed/replayed artifact must fall back to plain text instead.
|
||||
!isNonEmptyString(optionValue) ||
|
||||
seenIds.has(id) ||
|
||||
seenValues.has(optionValue)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
seenIds.add(id);
|
||||
seenValues.add(optionValue);
|
||||
options.push({ id, label, value: optionValue });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseFields(value: unknown): HumanInputField[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fields: HumanInputField[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
for (const field of value) {
|
||||
if (!isRecord(field)) {
|
||||
return undefined;
|
||||
}
|
||||
const name = field.name;
|
||||
if (typeof name === "string" && seenNames.has(name)) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof name === "string") {
|
||||
seenNames.add(name);
|
||||
}
|
||||
const label = field.label;
|
||||
const type = field.type;
|
||||
const required = field.required;
|
||||
if (
|
||||
!isNonEmptyString(name) ||
|
||||
RESERVED_FIELD_NAMES.has(name) ||
|
||||
!isNonEmptyString(label) ||
|
||||
!isHumanInputFieldType(type) ||
|
||||
(required !== undefined && typeof required !== "boolean")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const options = parseOptions(field.options);
|
||||
if (field.options !== undefined && options === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
(type === "select" || type === "multi_select") &&
|
||||
(!options || options.length === 0)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
fields.push({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
required: required === true,
|
||||
...(readOptionalString(field.placeholder)
|
||||
? { placeholder: readOptionalString(field.placeholder) }
|
||||
: {}),
|
||||
...(options ? { options } : {}),
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function parseHumanInputRequest(
|
||||
value: unknown,
|
||||
): HumanInputRequest | null {
|
||||
@ -120,7 +263,7 @@ export function parseHumanInputRequest(
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
value.version !== 1 ||
|
||||
(value.version !== 1 && value.version !== 2) ||
|
||||
value.kind !== "human_input_request" ||
|
||||
!isNonEmptyString(value.source) ||
|
||||
!isNonEmptyString(value.request_id) ||
|
||||
@ -142,6 +285,19 @@ export function parseHumanInputRequest(
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = parseFields(value.fields);
|
||||
if (value.fields !== undefined && fields === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (value.input_mode === "form" && (!fields || fields.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
// Version/mode binding: `form` is a v2 construct and v2 defines nothing
|
||||
// else — a mismatched pair is a malformed payload, not a variant.
|
||||
if ((value.input_mode === "form") !== (value.version === 2)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = value.context;
|
||||
if (
|
||||
context !== undefined &&
|
||||
@ -152,7 +308,7 @@ export function parseHumanInputRequest(
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
version: value.version,
|
||||
kind: "human_input_request",
|
||||
source: value.source,
|
||||
request_id: value.request_id,
|
||||
@ -169,6 +325,7 @@ export function parseHumanInputRequest(
|
||||
...(context !== undefined ? { context } : {}),
|
||||
input_mode: value.input_mode,
|
||||
...(options ? { options } : {}),
|
||||
...(fields ? { fields } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -243,20 +400,38 @@ export function extractHumanInputResponse(
|
||||
return parseHumanInputResponse(additionalKwargs.human_input_response);
|
||||
}
|
||||
|
||||
function extractPlainMessageText(message: Message): string {
|
||||
const content: unknown = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) =>
|
||||
isRecord(part) && part.type === "text" && typeof part.text === "string"
|
||||
? part.text
|
||||
: "",
|
||||
)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function deriveHumanInputThreadState(
|
||||
messages: Message[],
|
||||
isVisibleMessage: (message: Message) => boolean = (message) =>
|
||||
message.additional_kwargs?.hide_from_ui !== true,
|
||||
): HumanInputThreadState {
|
||||
const answeredResponses = new Map<string, HumanInputResponse>();
|
||||
const seenRequestIds = new Set<string>();
|
||||
const seenRequests = new Map<string, HumanInputRequest>();
|
||||
const requestOrder: string[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (isVisibleMessage(message)) {
|
||||
const request = extractHumanInputRequest(message);
|
||||
if (request) {
|
||||
seenRequestIds.add(request.request_id);
|
||||
seenRequests.set(request.request_id, request);
|
||||
requestOrder.push(request.request_id);
|
||||
}
|
||||
}
|
||||
@ -264,10 +439,39 @@ export function deriveHumanInputThreadState(
|
||||
const response = extractHumanInputResponse(message);
|
||||
if (
|
||||
response &&
|
||||
seenRequestIds.has(response.request_id) &&
|
||||
seenRequests.has(response.request_id) &&
|
||||
!answeredResponses.has(response.request_id)
|
||||
) {
|
||||
answeredResponses.set(response.request_id, response);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Legacy-frontend fallback: a v1-only frontend renders a v2 request as
|
||||
// plain text and the user answers through the normal composer, so the
|
||||
// reply carries no human_input_response metadata. The reply closes only
|
||||
// the LATEST unanswered request — the one the user was presumably
|
||||
// answering. Nothing guarantees at most one outstanding request across
|
||||
// runs, and closing them all would silently swallow older decisions with
|
||||
// the same text; an older request left open simply becomes the active
|
||||
// card again.
|
||||
if (message.type === "human" && isVisibleMessage(message) && !response) {
|
||||
const latestUnansweredId = [...requestOrder]
|
||||
.reverse()
|
||||
.find((requestId) => !answeredResponses.has(requestId));
|
||||
const request =
|
||||
latestUnansweredId === undefined
|
||||
? undefined
|
||||
: seenRequests.get(latestUnansweredId);
|
||||
if (latestUnansweredId !== undefined && request) {
|
||||
answeredResponses.set(latestUnansweredId, {
|
||||
version: 1,
|
||||
kind: "human_input_response",
|
||||
source: request.source,
|
||||
request_id: latestUnansweredId,
|
||||
response_kind: "text",
|
||||
value: extractPlainMessageText(message) || "-",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,6 +508,64 @@ export function createHumanInputOptionResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function formatFormValue(value: HumanInputFormValue) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(", ");
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "yes" : "no";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isEmptyFormValue(value: HumanInputFormValue | undefined) {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length === 0;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildHumanInputFormSummary(
|
||||
request: HumanInputRequest,
|
||||
values: Record<string, HumanInputFormValue>,
|
||||
) {
|
||||
const fields = request.fields ?? [];
|
||||
const parts: string[] = [];
|
||||
for (const field of fields) {
|
||||
const value = readHumanInputFormValue(values, field.name);
|
||||
if (isEmptyFormValue(value)) {
|
||||
continue;
|
||||
}
|
||||
parts.push(`${field.label}: ${formatFormValue(value!)}`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export function buildHumanInputFormSubmissionValue(
|
||||
request: HumanInputRequest,
|
||||
values: Record<string, HumanInputFormValue>,
|
||||
) {
|
||||
// The readable summary alone is ambiguous ("a: x; B: y" could come from
|
||||
// several field mappings), so the submitted value appends the full record
|
||||
// as one JSON block keyed by stable field names — labels/names may contain
|
||||
// the separators themselves, so only whole-record JSON is collision-free.
|
||||
const record: Record<string, HumanInputFormValue> = {};
|
||||
for (const field of request.fields ?? []) {
|
||||
const value = readHumanInputFormValue(values, field.name);
|
||||
if (isEmptyFormValue(value)) {
|
||||
continue;
|
||||
}
|
||||
record[field.name] = value!;
|
||||
}
|
||||
return `${buildHumanInputFormSummary(request, values)} [values: ${JSON.stringify(record)}]`;
|
||||
}
|
||||
|
||||
export function createHumanInputTextResponse(
|
||||
request: HumanInputRequest,
|
||||
value: string,
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { HumanInputCard } from "@/components/workspace/messages/human-input-card";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
import type { HumanInputRequest } from "@/core/messages/human-input";
|
||||
|
||||
const formRequest: HumanInputRequest = {
|
||||
version: 2,
|
||||
kind: "human_input_request",
|
||||
source: "ask_clarification",
|
||||
request_id: "clarification:call-form",
|
||||
question: "Please provide the expense details.",
|
||||
input_mode: "form",
|
||||
fields: [
|
||||
{ name: "amount", label: "Amount", type: "number", required: true },
|
||||
{ name: "category", label: "Category", type: "text", required: true },
|
||||
],
|
||||
};
|
||||
|
||||
function renderCard() {
|
||||
return render(
|
||||
<I18nContext.Provider
|
||||
value={{ locale: "en-US", setLocale: () => undefined }}
|
||||
>
|
||||
<HumanInputCard request={formRequest} onSubmit={() => undefined} />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("HumanInputCard form validation (DOM)", () => {
|
||||
it("keeps the error node mounted while another field is still invalid", () => {
|
||||
renderCard();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
// Both required fields flagged; the error node exists and is referenced.
|
||||
const amount = screen.getByLabelText(/Amount/);
|
||||
const category = screen.getByLabelText(/Category/);
|
||||
expect(amount.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(category.getAttribute("aria-invalid")).toBe("true");
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toContain("required fields");
|
||||
|
||||
// Fixing ONE field must not unmount the error node the other field's
|
||||
// aria-describedby still points at.
|
||||
fireEvent.change(amount, { target: { value: "300" } });
|
||||
expect(amount.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(category.getAttribute("aria-invalid")).toBe("true");
|
||||
const describedBy = category.getAttribute("aria-describedby");
|
||||
expect(describedBy).not.toBeNull();
|
||||
expect(document.getElementById(describedBy!)).not.toBeNull();
|
||||
|
||||
// Fixing the last invalid field clears the error entirely.
|
||||
fireEvent.change(category, { target: { value: "travel" } });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps select fields controlled from placeholder through selection", () => {
|
||||
const warnSpy = rs.spyOn(console, "warn").mockImplementation(() => ({}));
|
||||
render(
|
||||
<I18nContext.Provider
|
||||
value={{ locale: "en-US", setLocale: () => undefined }}
|
||||
>
|
||||
<HumanInputCard
|
||||
request={{
|
||||
...formRequest,
|
||||
fields: [
|
||||
{
|
||||
name: "category",
|
||||
label: "Category",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ id: "category-travel", label: "travel", value: "travel" },
|
||||
{ id: "category-meals", label: "meals", value: "meals" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={() => undefined}
|
||||
/>
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("combobox", {
|
||||
name: "Category required",
|
||||
});
|
||||
fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
||||
fireEvent.click(screen.getByRole("option", { name: "travel" }));
|
||||
|
||||
expect(
|
||||
warnSpy.mock.calls.some(([message]) =>
|
||||
String(message).includes("uncontrolled to controlled"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,7 @@ import { createElement, type KeyboardEvent } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
findMissingRequiredFields,
|
||||
HumanInputCard,
|
||||
shouldSubmitHumanInputTextOnKeyDown,
|
||||
} from "@/components/workspace/messages/human-input-card";
|
||||
@ -82,6 +83,116 @@ describe("HumanInputCard", () => {
|
||||
expect(html).not.toContain("**篇幅**");
|
||||
});
|
||||
|
||||
it("renders a form request with labeled fields and required markers", () => {
|
||||
const html = renderCard({
|
||||
request: {
|
||||
...request,
|
||||
version: 2,
|
||||
request_id: "clarification:call-form",
|
||||
question: "Please provide the expense details.",
|
||||
input_mode: "form",
|
||||
options: undefined,
|
||||
fields: [
|
||||
{ name: "amount", label: "Amount", type: "number", required: true },
|
||||
{
|
||||
name: "receipts",
|
||||
label: "Receipts",
|
||||
type: "multi_select",
|
||||
required: false,
|
||||
options: [
|
||||
{ id: "receipts-option-1", label: "A-1", value: "A-1" },
|
||||
{ id: "receipts-option-2", label: "A-2", value: "A-2" },
|
||||
],
|
||||
},
|
||||
{ name: "note", label: "Note", type: "textarea", required: false },
|
||||
{
|
||||
name: "design",
|
||||
label: "DesignPriority",
|
||||
type: "checkbox",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(html).toContain("Amount");
|
||||
expect(html).toContain("*");
|
||||
expect(html).toContain("Receipts");
|
||||
expect(html).toContain("A-1");
|
||||
expect(html).toContain("Note");
|
||||
expect(html).toContain("Submit");
|
||||
// Checkbox fields render as a single toggle row — the label must not be
|
||||
// duplicated by an additional field label above the control.
|
||||
expect(html.split("DesignPriority").length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("associates form labels with controls and marks required fields", () => {
|
||||
const html = renderCard({
|
||||
request: {
|
||||
...request,
|
||||
version: 2,
|
||||
request_id: "clarification:call-form",
|
||||
question: "Please provide the expense details.",
|
||||
input_mode: "form",
|
||||
options: undefined,
|
||||
fields: [
|
||||
{ name: "amount", label: "Amount", type: "number", required: true },
|
||||
{ name: "note", label: "Note", type: "textarea", required: false },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Every visible label is linked to its control via htmlFor/id.
|
||||
const htmlForIds = [...html.matchAll(/<label[^>]*for="([^"]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
expect(htmlForIds.length).toBe(2);
|
||||
for (const id of htmlForIds) {
|
||||
expect(html).toContain(`id="${id}"`);
|
||||
}
|
||||
expect(html).toContain('aria-required="true"');
|
||||
});
|
||||
|
||||
it("findMissingRequiredFields flags empty required values only", () => {
|
||||
const fields = [
|
||||
{
|
||||
name: "amount",
|
||||
label: "Amount",
|
||||
type: "number" as const,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "note",
|
||||
label: "Note",
|
||||
type: "text" as const,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "receipts",
|
||||
label: "Receipts",
|
||||
type: "multi_select" as const,
|
||||
required: true,
|
||||
options: [{ id: "o1", label: "A-1", value: "A-1" }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
findMissingRequiredFields(fields, {}).map((field) => field.name),
|
||||
).toEqual(["amount", "receipts"]);
|
||||
expect(
|
||||
findMissingRequiredFields(fields, {
|
||||
amount: " ",
|
||||
receipts: [],
|
||||
}).map((field) => field.name),
|
||||
).toEqual(["amount", "receipts"]);
|
||||
expect(
|
||||
findMissingRequiredFields(fields, {
|
||||
amount: "300",
|
||||
receipts: ["A-1"],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not submit text with Enter while IME composition is active", () => {
|
||||
expect(shouldSubmitHumanInputTextOnKeyDown(keyEvent())).toBe(true);
|
||||
expect(
|
||||
|
||||
@ -2,13 +2,17 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
buildHumanInputFormSubmissionValue,
|
||||
buildHumanInputFormSummary,
|
||||
buildHumanInputResponseText,
|
||||
buildInitialHumanInputFormValues,
|
||||
createHumanInputOptionResponse,
|
||||
createHumanInputTextResponse,
|
||||
deriveHumanInputThreadState,
|
||||
extractHumanInputRequest,
|
||||
extractHumanInputResponse,
|
||||
hasOpenHumanInputRequest,
|
||||
readHumanInputFormValue,
|
||||
shouldClearPendingHumanInputOnThreadError,
|
||||
} from "@/core/messages/human-input";
|
||||
|
||||
@ -221,3 +225,347 @@ test("creates option and text responses for a request", () => {
|
||||
'For your clarification "Which environment should I deploy to?", my answer is: staging',
|
||||
);
|
||||
});
|
||||
|
||||
const formPayload = {
|
||||
version: 2,
|
||||
kind: "human_input_request",
|
||||
source: "ask_clarification",
|
||||
request_id: "clarification:call-form",
|
||||
question: "Please provide the expense details.",
|
||||
input_mode: "form",
|
||||
fields: [
|
||||
{ name: "amount", label: "Amount", type: "number", required: true },
|
||||
{
|
||||
name: "category",
|
||||
label: "Category",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ id: "category-option-1", label: "travel", value: "travel" },
|
||||
{ id: "category-option-2", label: "meals", value: "meals" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "receipts",
|
||||
label: "Receipts",
|
||||
type: "multi_select",
|
||||
required: false,
|
||||
options: [
|
||||
{ id: "receipts-option-1", label: "A-1", value: "A-1" },
|
||||
{ id: "receipts-option-2", label: "A-2", value: "A-2" },
|
||||
],
|
||||
},
|
||||
{ name: "note", label: "note", type: "textarea", required: false },
|
||||
],
|
||||
};
|
||||
|
||||
function toolMessage(payload: unknown): Message {
|
||||
return {
|
||||
type: "tool",
|
||||
name: "ask_clarification",
|
||||
content: "fallback",
|
||||
artifact: { human_input: payload },
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
test("parses a v2 form request", () => {
|
||||
expect(extractHumanInputRequest(toolMessage(formPayload))).toEqual(
|
||||
formPayload,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a form request without fields", () => {
|
||||
expect(
|
||||
extractHumanInputRequest(toolMessage({ ...formPayload, fields: [] })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a form request with malformed fields", () => {
|
||||
expect(
|
||||
extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...formPayload,
|
||||
fields: [{ label: "missing name", type: "text" }],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...formPayload,
|
||||
fields: [{ name: "amount", label: "Amount", type: "slider" }],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects unknown protocol versions", () => {
|
||||
expect(
|
||||
extractHumanInputRequest(toolMessage({ ...formPayload, version: 3 })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("builds a readable form summary submitted as a v1 text response", () => {
|
||||
const request = extractHumanInputRequest(toolMessage(formPayload))!;
|
||||
const values = {
|
||||
amount: "300",
|
||||
category: "travel",
|
||||
receipts: ["A-1", "A-2"],
|
||||
note: "",
|
||||
};
|
||||
|
||||
const summary = buildHumanInputFormSummary(request, values);
|
||||
expect(summary).toBe("Amount: 300; Category: travel; Receipts: A-1, A-2");
|
||||
|
||||
// Request-side-only protocol scope: form answers reuse the existing v1
|
||||
// text response — no structured response kind is introduced.
|
||||
expect(createHumanInputTextResponse(request, summary)).toEqual({
|
||||
version: 1,
|
||||
kind: "human_input_response",
|
||||
source: "ask_clarification",
|
||||
request_id: "clarification:call-form",
|
||||
response_kind: "text",
|
||||
value: "Amount: 300; Category: travel; Receipts: A-1, A-2",
|
||||
});
|
||||
});
|
||||
|
||||
test("form submission value is collision-free via the JSON block", () => {
|
||||
const request = extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...formPayload,
|
||||
fields: [
|
||||
{ name: "a", label: "A", type: "text", required: false },
|
||||
{ name: "b", label: "B", type: "text", required: false },
|
||||
],
|
||||
}),
|
||||
)!;
|
||||
|
||||
const first = buildHumanInputFormSubmissionValue(request, {
|
||||
a: "x; B: y",
|
||||
b: "z",
|
||||
});
|
||||
const second = buildHumanInputFormSubmissionValue(request, {
|
||||
a: "x",
|
||||
b: "y; B: z",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).toContain('[values: {"a":"x; B: y","b":"z"}]');
|
||||
expect(second).toContain('[values: {"a":"x","b":"y; B: z"}]');
|
||||
// Readable prefix is retained for display.
|
||||
expect(first.startsWith("A: x; B: y; B: z")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects select options with empty values or duplicates", () => {
|
||||
const withOptions = (
|
||||
options: Array<{ id: string; label: string; value: string }>,
|
||||
) =>
|
||||
extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...formPayload,
|
||||
fields: [
|
||||
{
|
||||
name: "category",
|
||||
label: "Category",
|
||||
type: "select",
|
||||
required: true,
|
||||
options,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Empty option value would crash Radix <SelectItem value="">.
|
||||
expect(withOptions([{ id: "o1", label: "travel", value: "" }])).toBeNull();
|
||||
// Duplicate option ids / values.
|
||||
expect(
|
||||
withOptions([
|
||||
{ id: "o1", label: "travel", value: "travel" },
|
||||
{ id: "o1", label: "meals", value: "meals" },
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
withOptions([
|
||||
{ id: "o1", label: "travel", value: "travel" },
|
||||
{ id: "o2", label: "travel-again", value: "travel" },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects duplicate field names and enforces the form/version binding", () => {
|
||||
expect(
|
||||
extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...formPayload,
|
||||
fields: [
|
||||
{ name: "amount", label: "Amount", type: "number", required: true },
|
||||
{ name: "amount", label: "Again", type: "text", required: false },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
// form mode is a v2 construct; a v1 payload carrying it is malformed.
|
||||
expect(
|
||||
extractHumanInputRequest(toolMessage({ ...formPayload, version: 1 })),
|
||||
).toBeNull();
|
||||
// and v2 without form has no defined meaning yet.
|
||||
expect(
|
||||
extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...requestPayload,
|
||||
version: 2,
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects form fields with reserved prototype names", () => {
|
||||
for (const reserved of ["__proto__", "constructor", "toString"]) {
|
||||
expect(
|
||||
extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...formPayload,
|
||||
fields: [
|
||||
{ name: reserved, label: "Amount", type: "number", required: true },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("readHumanInputFormValue ignores inherited prototype properties", () => {
|
||||
const values: Record<string, string> = { amount: "300" };
|
||||
|
||||
expect(readHumanInputFormValue(values, "amount")).toBe("300");
|
||||
expect(readHumanInputFormValue(values, "toString")).toBeUndefined();
|
||||
expect(readHumanInputFormValue(values, "constructor")).toBeUndefined();
|
||||
expect(readHumanInputFormValue(values, "__proto__")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("buildInitialHumanInputFormValues seeds checkbox fields to false", () => {
|
||||
const request = extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...formPayload,
|
||||
fields: [
|
||||
{ name: "amount", label: "Amount", type: "number", required: true },
|
||||
{ name: "urgent", label: "Urgent", type: "checkbox", required: false },
|
||||
],
|
||||
}),
|
||||
)!;
|
||||
|
||||
expect(buildInitialHumanInputFormValues(request.fields ?? [])).toEqual({
|
||||
urgent: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("summary renders an untouched checkbox as an explicit no", () => {
|
||||
const request = extractHumanInputRequest(
|
||||
toolMessage({
|
||||
...formPayload,
|
||||
fields: [
|
||||
{ name: "amount", label: "Amount", type: "number", required: true },
|
||||
{ name: "urgent", label: "Urgent", type: "checkbox", required: false },
|
||||
],
|
||||
}),
|
||||
)!;
|
||||
const values = {
|
||||
amount: "300",
|
||||
...buildInitialHumanInputFormValues(request.fields ?? []),
|
||||
};
|
||||
|
||||
expect(buildHumanInputFormSummary(request, values)).toBe(
|
||||
"Amount: 300; Urgent: no",
|
||||
);
|
||||
});
|
||||
|
||||
test("a plain reply closes only the latest unanswered request", () => {
|
||||
const olderPayload = {
|
||||
...formPayload,
|
||||
request_id: "clarification:call-older",
|
||||
};
|
||||
const state = deriveHumanInputThreadState([
|
||||
toolMessage(olderPayload),
|
||||
toolMessage(formPayload),
|
||||
{
|
||||
type: "human",
|
||||
content: "answer to the second question",
|
||||
} as unknown as Message,
|
||||
]);
|
||||
|
||||
// The reply answers the request the user was looking at (the latest); the
|
||||
// older decision must not be silently swallowed with the same text.
|
||||
expect(state.answeredResponses.get("clarification:call-form")?.value).toBe(
|
||||
"answer to the second question",
|
||||
);
|
||||
expect(state.answeredResponses.has("clarification:call-older")).toBe(false);
|
||||
expect(state.latestOpenRequestId).toBe("clarification:call-older");
|
||||
});
|
||||
|
||||
test("a visible plain human reply closes an open request (legacy fallback)", () => {
|
||||
// An old (v1-only) frontend renders a v2 request as plain text and the user
|
||||
// answers through the normal composer — the reply has no response metadata.
|
||||
// After upgrading, the request must not stay open and lock the composer.
|
||||
const state = deriveHumanInputThreadState([
|
||||
toolMessage(formPayload),
|
||||
{
|
||||
type: "human",
|
||||
content: "金额 300,类别差旅",
|
||||
} as unknown as Message,
|
||||
]);
|
||||
|
||||
expect(state.latestOpenRequestId).toBeNull();
|
||||
const answered = state.answeredResponses.get("clarification:call-form");
|
||||
expect(answered?.response_kind).toBe("text");
|
||||
expect(answered?.value).toBe("金额 300,类别差旅");
|
||||
expect(hasOpenHumanInputRequest([toolMessage(formPayload)])).toBe(true);
|
||||
});
|
||||
|
||||
test("a visible human message before the request does not close it", () => {
|
||||
const state = deriveHumanInputThreadState([
|
||||
{
|
||||
type: "human",
|
||||
content: "帮我提交报销",
|
||||
} as unknown as Message,
|
||||
toolMessage(formPayload),
|
||||
]);
|
||||
|
||||
expect(state.latestOpenRequestId).toBe("clarification:call-form");
|
||||
});
|
||||
|
||||
test("hidden non-response messages do not close an open request", () => {
|
||||
const state = deriveHumanInputThreadState([
|
||||
toolMessage(formPayload),
|
||||
{
|
||||
type: "human",
|
||||
content: "internal context",
|
||||
additional_kwargs: { hide_from_ui: true },
|
||||
} as unknown as Message,
|
||||
]);
|
||||
|
||||
expect(state.latestOpenRequestId).toBe("clarification:call-form");
|
||||
});
|
||||
|
||||
test("derives answered state for a form request answered by text", () => {
|
||||
const response = {
|
||||
version: 1,
|
||||
kind: "human_input_response",
|
||||
source: "ask_clarification",
|
||||
request_id: "clarification:call-form",
|
||||
response_kind: "text",
|
||||
value: "Amount: 300",
|
||||
};
|
||||
const state = deriveHumanInputThreadState([
|
||||
toolMessage(formPayload),
|
||||
{
|
||||
type: "human",
|
||||
content: "answer",
|
||||
additional_kwargs: { hide_from_ui: true, human_input_response: response },
|
||||
} as unknown as Message,
|
||||
]);
|
||||
|
||||
expect(state.answeredResponses.get("clarification:call-form")).toEqual(
|
||||
response,
|
||||
);
|
||||
expect(state.latestOpenRequestId).toBeNull();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user