AnoobFeng 47b0f604f4
feat(frontend):enhance the ask_clarification interaction with visualized card (#3956)
* feat(frontend): add structured human input cards for ask_clarification

Implement a reusable Human Input Card flow for ask_clarification while keeping
the existing text fallback for older clients and IM channels.

Backend:
- Add structured ToolMessage.artifact.human_input payloads for clarification requests.
- Preserve ToolMessage.content as the readable Markdown/text fallback.
- Normalize clarification options from native lists, JSON strings, plain strings,
  mixed scalar values, None, and missing options.
- Derive input_mode as choice_with_other when options exist, otherwise free_text.
- Keep disable_clarification non-interactive behavior as a plain ToolMessage with
  no human_input artifact.
- Cover artifact persistence and Gateway message metadata preservation in tests.

Frontend:
- Add human input protocol types, runtime guards, extractors, response builders,
  and thread-state helpers.
- Add reusable HumanInputCard with option buttons, free-text input, pending,
  read-only, disabled, and answered states.
- Render structured clarification cards from artifact.human_input, with Markdown
  fallback for malformed or legacy tool messages.
- Preserve line breaks in structured question/context/option text.
- Hide submitted clarification bridge messages from the chat UI via
  additional_kwargs.hide_from_ui.
- Send structured human_input_response metadata through the fourth sendMessage
  options argument, preserving run context in the third argument.
- Wire submissions for normal chats, custom agent chats, agent bootstrap chats,
  and sidecar chats.
- Derive answered state from raw thread.messages so hidden replies still update
  the original card.
- Clear pending state when the hidden reply arrives, dispatch is dropped, or a
  later async stream failure appears on thread.error.

* perf(frontend): optimize HumanInputCard UI interactions

- Support Enter key to submit text input (Shift+Enter for newline)
- Render question and context fields as Markdown instead of plain text
- Replace deprecated FormEventHandler type with structural typing

* test(frontend): add unit test cover optimize HumanInputCard UI interactions

* feat(frontend): disabled chatbox when has new human-input-card

* fix(style): lint error fix

* fix: sanitize hidden human input replies

- Preserve IME composition safety for human input card Enter submits
- Treat hidden human input responses as genuine user messages for sanitization
- Keep hidden card replies in memory filtering while excluding malformed/internal hidden messages
- Add regression coverage for card IME handling and hidden reply sanitization

* fix: tighten human input response validation

- Reject empty hidden human input response values
- Remove invalid list ARIA role from human input card options
- Add backend coverage for empty response payloads

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-06 22:34:41 +08:00

137 lines
3.9 KiB
TypeScript

import { describe, expect, it } from "@rstest/core";
import { createElement, type KeyboardEvent } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
HumanInputCard,
shouldSubmitHumanInputTextOnKeyDown,
} from "@/components/workspace/messages/human-input-card";
import { I18nContext } from "@/core/i18n/context";
import type {
HumanInputRequest,
HumanInputResponse,
} from "@/core/messages/human-input";
const request: HumanInputRequest = {
version: 1,
kind: "human_input_request",
source: "ask_clarification",
request_id: "clarification:call-abc",
tool_call_id: "call-abc",
clarification_type: "approach_choice",
question: "Which environment should I deploy to?",
context: "Need the target environment.",
input_mode: "choice_with_other",
options: [
{ id: "option-1", label: "development", value: "development" },
{ id: "option-2", label: "staging", value: "staging" },
],
};
describe("HumanInputCard", () => {
it("renders request text, options, and the other-answer input", () => {
const html = renderCard();
expect(html).toContain("Need your help");
expect(html).toContain("Need the target environment.");
expect(html).toContain("Which environment should I deploy to?");
expect(html).toContain("development");
expect(html).toContain("staging");
expect(html).toContain("Other answer");
expect(html).toContain("Type another answer...");
});
it("renders answered state as disabled with the selected value", () => {
const response: HumanInputResponse = {
version: 1,
kind: "human_input_response",
source: "ask_clarification",
request_id: "clarification:call-abc",
response_kind: "option",
option_id: "option-2",
value: "staging",
};
const html = renderCard({ answeredResponse: response });
expect(html).toContain("Answered");
expect(html).toContain("Answered: staging");
expect(html).toContain("disabled");
});
it("renders read-only state when no submit handler is available", () => {
const html = renderCard({ onSubmit: undefined });
expect(html).toContain("Read only");
expect(html).toContain("disabled");
});
it("renders markdown in question field (bold, lists)", () => {
const html = renderCard({
request: {
...request,
question:
"你想写什么样的小说?\n\n1. **题材/类型**:科幻、奇幻\n2. **篇幅**:短篇、中篇",
input_mode: "free_text",
options: undefined,
},
});
expect(html).toContain("题材/类型");
expect(html).toContain("篇幅");
expect(html).not.toContain("**题材/类型**");
expect(html).not.toContain("**篇幅**");
});
it("does not submit text with Enter while IME composition is active", () => {
expect(shouldSubmitHumanInputTextOnKeyDown(keyEvent())).toBe(true);
expect(
shouldSubmitHumanInputTextOnKeyDown(keyEvent({ shiftKey: true })),
).toBe(false);
expect(
shouldSubmitHumanInputTextOnKeyDown(keyEvent({ isComposing: true })),
).toBe(false);
expect(
shouldSubmitHumanInputTextOnKeyDown(keyEvent({ keyCode: 229 })),
).toBe(false);
expect(shouldSubmitHumanInputTextOnKeyDown(keyEvent(), true)).toBe(false);
});
});
function renderCard(props: Partial<Parameters<typeof HumanInputCard>[0]> = {}) {
return renderToStaticMarkup(
createElement(
I18nContext.Provider,
{
value: {
locale: "en-US",
setLocale: () => undefined,
},
},
createElement(HumanInputCard, {
request,
onSubmit: () => undefined,
...props,
}),
),
);
}
function keyEvent({
isComposing = false,
key = "Enter",
keyCode = 13,
shiftKey = false,
}: {
isComposing?: boolean;
key?: string;
keyCode?: number;
shiftKey?: boolean;
} = {}) {
return {
key,
keyCode,
nativeEvent: { isComposing },
shiftKey,
} as unknown as KeyboardEvent<HTMLTextAreaElement>;
}