From 5da3d20b0d1ea3a420baf2ff821dd4d6c66decbc Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 16:41:17 +0200 Subject: [PATCH 01/13] test(runner): add history-driven client-tool relay integration tests Layer B for the interaction-kinds emit/resume path: drive buildClientToolRelay with the real ApprovalResponder built from run history (extract -> ConversationDecisions -> ApprovalResponder -> relay), exactly as a live /run wires it. Pins the whole emit/pause/resume decision end to end, not just the relay's mechanics. - cross-turn: a new identical call in a later turn pauses and emits a fresh form (verified red without the current-turn scoping fix; a FE-only mock could not have caught this). - in-turn: a genuine resume fulfills from its own output without re-emitting. Also admit request_input's elicitation render in the runner's TS-only RenderHint union, mirroring the existing connect member (render rides as an opaque dict on the wire, so this is type-only; no wire.py or golden change). --- services/runner/src/protocol.ts | 6 +- .../runner/tests/unit/client-tools.test.ts | 97 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index 4b8c44e056..75b94d441b 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -310,7 +310,11 @@ export type RenderHint = // stamps it so the frontend renders the OAuth/API-key connect dialog when the tool pauses. No // payload — the widget is fully described by the paused call's tool name + input. `wire.py` does // not pin RenderHint (render rides as an opaque dict), so this member is TS-only. - | { kind: "connect" }; + | { kind: "connect" } + // `elicitation` requests the built-in schema-driven form (interaction kinds M1): the `request_input` + // client tool stamps it so the frontend renders a form from the paused call's `requestedSchema`. Like + // `connect`, it carries no payload here and is TS-only (the render rides through as an opaque dict). + | { kind: "elicitation" }; export type AgentEvent = | { type: "message"; text: string } diff --git a/services/runner/tests/unit/client-tools.test.ts b/services/runner/tests/unit/client-tools.test.ts index 90d94ca356..fb74d3f9c3 100644 --- a/services/runner/tests/unit/client-tools.test.ts +++ b/services/runner/tests/unit/client-tools.test.ts @@ -9,8 +9,14 @@ import { describe, it } from "vitest"; import assert from "node:assert/strict"; -import type { AgentEvent } from "../../src/protocol.ts"; +import type { AgentEvent, AgentRunRequest } from "../../src/protocol.ts"; import type { ClientToolVerdict, Responder } from "../../src/responder.ts"; +import { + ApprovalResponder, + ConversationDecisions, + extractApprovalDecisions, + extractClientToolOutputs, +} from "../../src/responder.ts"; import type { ClientToolRelayRequest } from "../../src/tools/client-tool-relay.ts"; import { PendingApprovalLatch } from "../../src/permission-plan.ts"; import { @@ -312,3 +318,92 @@ describe("buildClientToolRelay", () => { ]); }); }); + +/** + * Layer-B integration: the relay driven by the REAL responder built from run history, exactly + * as a live /run wires it (extract{Approval,ClientTool} → ConversationDecisions → ApprovalResponder + * → buildClientToolRelay). The mock-responder tests above pin the relay's mechanics; this pins the + * whole emit/pause/resume decision end to end — the seam where the coalescing bug lived (a FE-only + * mock could not have caught it). See docs/design/agent-chat-interaction-kinds/decisions.md. + */ +describe("buildClientToolRelay with the real responder (history-driven)", () => { + const input = { + message: "What is your city?", + requestedSchema: { type: "object", properties: { city: { type: "string" } } }, + }; + const liveReq: ClientToolRelayRequest = { + id: "i-live", + toolCallId: "tc-live", + toolName: "request_input", + input, + spec: { name: "request_input", kind: "client", render: { kind: "elicitation" } }, + }; + + // A prior turn's request_input call and its answer, correlated by id. The tool_result carries + // no args (the real wire shape) — the store recovers them from the tool_call via the shape index. + const priorTurn = (output: unknown) => [ + { + role: "assistant" as const, + content: [{ type: "tool_call", toolCallId: "c-1", toolName: "request_input", input }], + }, + { + role: "tool" as const, + content: [{ type: "tool_result", toolCallId: "c-1", toolName: "request_input", output }], + }, + ]; + + function realSeam(request: AgentRunRequest) { + const events: AgentEvent[] = []; + const paused: string[] = []; + const responder = new ApprovalResponder( + { default: "allow", rules: [] }, + new ConversationDecisions( + extractApprovalDecisions(request), + extractClientToolOutputs(request), + ), + ); + const relay = buildClientToolRelay({ + responder, + run: { emitEvent: (e) => events.push(e) }, + latch: new PendingApprovalLatch(), + pause: { markPausedToolCall: (id) => paused.push(id), pause: () => {} }, + recordPendingInteraction: () => {}, + }); + return { relay, events, paused }; + } + + it("pauses (emits a fresh form) for a new identical call in a later turn, not reusing the prior answer", async () => { + const request: AgentRunRequest = { + sessionId: "s", + messages: [ + { role: "user", content: "ask" }, + ...priorTurn({ action: "accept", content: { city: "Berlin" } }), + { role: "user", content: "ask again" }, // latest user msg: the prior answer is a past turn + ], + }; + const s = realSeam(request); + const outcome = await s.relay.onClientTool(liveReq); + assert.equal(outcome, "pendingApproval", "a fresh identical call must pause for a new answer"); + assert.equal(s.events.length, 1, "the client_tool interaction is emitted for the new form"); + assert.equal((s.events[0] as { kind: string }).kind, "client_tool"); + assert.deepEqual((s.events[0] as { payload: { render: unknown } }).payload.render, { + kind: "elicitation", + }); + assert.deepEqual(s.paused, ["tc-live"]); + }); + + it("fulfills an in-turn resume from its own output without emitting a new form", async () => { + const request: AgentRunRequest = { + sessionId: "s", + messages: [ + { role: "user", content: "ask" }, + ...priorTurn({ action: "accept", content: { city: "Berlin" } }), + ], + }; + const s = realSeam(request); + const outcome = await s.relay.onClientTool(liveReq); + assert.deepEqual(outcome, { output: { action: "accept", content: { city: "Berlin" } } }); + assert.equal(s.events.length, 0, "a genuine resume must not emit a new form"); + assert.deepEqual(s.paused, []); + }); +}); From 2a0a6a1093b3599223f754538ca0149b1adbee78 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 13:48:28 +0200 Subject: [PATCH 02/13] =?UTF-8?q?feat(frontend):=20add=20"Other=E2=80=A6"?= =?UTF-8?q?=20custom=20value=20to=20elicitation=20enum=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An enum in an elicitation form rendered as a strict dropdown, so the user was locked into the agent's suggested options. But the consumer of the answer is the agent (an LLM), so those options are suggestions, not a hard constraint — the user should be able to go off-menu. Add an opt-in openEnums build option (@agenta/shared) that stamps allowCustomEnum onto enum descriptors; SchemaForm then renders enum fields with an "Other…" entry that reveals a free-text input. ElicitationWidget opts in. Gateway-tool execution forms do NOT (their enums are real API params) — guarded by a flag-off regression test. No wire/validator/golden change: the schema still declares enum, only the rendering changes, and the answer already rides as free-form content. --- .../clientTools/ElicitationWidget.tsx | 1 + .../src/gatewayTool/components/SchemaForm.tsx | 86 +++++++++++++++++-- .../src/utils/gatewayToolSchema.ts | 5 ++ .../tests/unit/gatewayToolSchema.test.ts | 18 ++++ 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/ElicitationWidget.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/ElicitationWidget.tsx index 46d2f73e1c..778e1bd809 100644 --- a/web/oss/src/components/AgentChatSlice/components/clientTools/ElicitationWidget.tsx +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/ElicitationWidget.tsx @@ -171,6 +171,7 @@ const ElicitationWidget = ({meta, settle, degradedEarlierInTurn}: ClientToolHand schema={parsed.payload.requestedSchema as unknown as Record} form={form} formats + openEnums />
diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index f1fdb61c3c..1346b078d3 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -29,13 +29,19 @@ interface Props { flat?: boolean /** Opt-in `format` handling (date/date-time/multiline/email/uri) — see BuildFormFieldsOptions. */ formats?: boolean + /** Opt-in: render enum fields with an "Other…" custom-value escape hatch (elicitation forms). */ + openEnums?: boolean } const SchemaForm = forwardRef( - ({schema, form, disabled, jsonMode, flat, formats}, ref) => { + ({schema, form, disabled, jsonMode, flat, formats, openEnums}, ref) => { const fields = useMemo( - () => buildFormFieldsFromSchema(schema, "", {formats: !!formats}), - [schema, formats], + () => + buildFormFieldsFromSchema(schema, "", { + formats: !!formats, + openEnums: !!openEnums, + }), + [schema, formats, openEnums], ) const requiredFields = useMemo(() => fields.filter((f) => f.required), [fields]) const optionalFields = useMemo(() => fields.filter((f) => !f.required), [fields]) @@ -235,6 +241,62 @@ function FieldLabel({field}: {field: FormFieldDescriptor}) { ) } +const OTHER_ENUM_OPTION = "__ag_enum_other__" + +/** Enum control with an "Other…" entry that reveals a free-text input (elicitation escape hatch). */ +function EnumWithOther({ + value, + onChange, + options, + placeholder, + allowClear, + disabled, +}: { + value?: string + onChange?: (v: string | undefined) => void + options: string[] + placeholder?: string + allowClear?: boolean + disabled?: boolean +}) { + const inOptions = value != null && options.includes(value) + const [otherMode, setOtherMode] = useState(value != null && !inOptions) + const selectValue = otherMode ? OTHER_ENUM_OPTION : inOptions ? value : undefined + + return ( +
+ onChange?.(e.target.value || undefined)} + /> + )} +
+ ) +} + function SchemaFormField({field, depth = 0}: {field: FormFieldDescriptor; depth?: number}) { const rules = field.required ? [{required: true, message: `${field.label} is required`}] : [] const label = @@ -341,11 +403,19 @@ function SchemaFormField({field, depth = 0}: {field: FormFieldDescriptor; depth? rules={rules} initialValue={field.default} > - ({value: v, label: v}))} + /> + )} ) diff --git a/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts b/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts index 8967e13527..a6c5458102 100644 --- a/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts +++ b/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts @@ -10,6 +10,8 @@ export interface FormFieldDescriptor { enumValues?: string[] /** Known string format (date/date-time/email/uri/multiline) — set only under `{formats: true}` */ format?: string + /** Render enum with an "Other…" custom-value escape hatch — set only under `{openEnums: true}`. */ + allowCustomEnum?: boolean children?: FormFieldDescriptor[] // nested fields for object type /** For arrays: schema of each item (JSON Schema) */ itemSchema?: Record @@ -22,6 +24,8 @@ export interface FormFieldDescriptor { export interface BuildFormFieldsOptions { /** Opt-in: surface known string formats so renderers can map them to dedicated controls. */ formats?: boolean + /** Opt-in (elicitation): let enum fields accept a custom "Other…" value beyond the listed options. */ + openEnums?: boolean } /** @@ -111,6 +115,7 @@ export function buildFormFieldsFromSchema( default: prop.default, enumValues: prop.enum as string[] | undefined, ...(format !== undefined ? {format} : {}), + ...(opts?.openEnums && fieldType === "enum" ? {allowCustomEnum: true} : {}), children, itemSchema, itemChildren, diff --git a/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts b/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts index faa3f0aeea..a1c3d4a62f 100644 --- a/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts +++ b/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts @@ -106,3 +106,21 @@ describe("buildFormFieldsFromSchema — formats flag", () => { expect(byName.rows.itemChildren?.[0]?.format).toBe("date") }) }) + +describe("buildFormFieldsFromSchema — openEnums flag", () => { + // CRITICAL regression: gateway-tool execution forms call without opts — enums stay strict, so + // no `allowCustomEnum` key may appear with the flag off. + it("flag off (default): no allowCustomEnum key anywhere", () => { + const off = buildFormFieldsFromSchema(schemaWithFormats()) + expect(off.some((f) => "allowCustomEnum" in f)).toBe(false) + expect(off).toEqual(buildFormFieldsFromSchema(schemaWithFormats(), "", {openEnums: false})) + }) + + it("flag on: enum fields get allowCustomEnum; non-enum fields do not", () => { + const fields = buildFormFieldsFromSchema(schemaWithFormats(), "", {openEnums: true}) + const byName = Object.fromEntries(fields.map((f) => [f.name, f])) + expect(byName.level.allowCustomEnum).toBe(true) + expect("allowCustomEnum" in byName.note).toBe(false) + expect("allowCustomEnum" in byName.count).toBe(false) + }) +}) From 06a4a0c3792b98e37715d80875a1c3f24862ab22 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 13:54:45 +0200 Subject: [PATCH 03/13] test(frontend): add elicitation E2E scaffold with SSE transport mock Layer A of the interaction-kinds emit-harness: a deterministic Playwright acceptance spec for the elicitation kind that mocks only the agent run (page.route on **/invoke*) with byte-accurate AI SDK v6 SSE, keeping auth, project, the seeded agent revision, and the playground shell real. - assets/elicitationStream.ts: canned SSE builders (paused elicitation turn + resume turn), pinned against the Python vercel adapter wire. - tests.ts: the transport mock, an is_agent app seed (data.uri = agenta:builtin:agent:v0), navigation, and the composer helper. - index.ts: round-trip, required-gate, settled-replay, reload-while-pending. First-run pending: the composer/field selectors and the reload rehydration source are flagged in-file to confirm on first green run against a live stack. --- .../acceptance/agent-chat/README.md | 55 ++++ .../agent-chat/assets/elicitationStream.ts | 122 +++++++++ .../acceptance/agent-chat/assets/types.ts | 30 +++ .../acceptance/agent-chat/elicitation.spec.ts | 4 + .../playwright/acceptance/agent-chat/index.ts | 237 ++++++++++++++++++ .../playwright/acceptance/agent-chat/tests.ts | 166 ++++++++++++ 6 files changed, 614 insertions(+) create mode 100644 web/oss/tests/playwright/acceptance/agent-chat/README.md create mode 100644 web/oss/tests/playwright/acceptance/agent-chat/assets/elicitationStream.ts create mode 100644 web/oss/tests/playwright/acceptance/agent-chat/assets/types.ts create mode 100644 web/oss/tests/playwright/acceptance/agent-chat/elicitation.spec.ts create mode 100644 web/oss/tests/playwright/acceptance/agent-chat/index.ts create mode 100644 web/oss/tests/playwright/acceptance/agent-chat/tests.ts diff --git a/web/oss/tests/playwright/acceptance/agent-chat/README.md b/web/oss/tests/playwright/acceptance/agent-chat/README.md new file mode 100644 index 0000000000..7936148f4f --- /dev/null +++ b/web/oss/tests/playwright/acceptance/agent-chat/README.md @@ -0,0 +1,55 @@ +# Agent chat — Elicitation E2E (interaction kinds M1, layer A) + +Deterministic Playwright coverage for the `elicitation` interaction kind: emit → render → settle → +resume → replay, driven by a **transport mock** rather than a live LLM. + +## How it works + +Auth, the ephemeral project, the seeded agent revision, and the playground shell stay **real**. Only +the agent run (`**/invoke*`) is intercepted (`mockElicitationInvoke` in `tests.ts`) and fulfilled with +byte-accurate AI SDK v6 SSE (`assets/elicitationStream.ts`): + +- **1st run** → a paused turn: `request_input` left `input-available` (no output) + the sibling + `data-render` part (`{kind: "elicitation"}`) → the FE renders the form. +- **2nd run** (the auto-resume after the form settles) → a normal text turn echoing the values. + +The SSE shapes are pinned against the real producer, +`sdks/python/agenta/sdk/agents/adapters/vercel/{stream,sse}.py`. The mocked response **must** set +`Content-Type: text/event-stream` or the FE's negotiating fetch parses it as batch JSON. + +## Specs (`index.ts`) + +1. **Round-trip** — form renders, accept resumes with the submitted values (asserts the resume POST + carried the settled output). Mock-only, no reload. +2. **Required-field gate** — empty Accept shows an inline error and does not resume. Mock-only. +3. **Settled replay** — after accept, a reload shows the read-only chip. +4. **Reload-while-pending** — reload with the form pending, then accept. + +## First-run seams (resolve against the live stack — do NOT assume) + +These are isolated on purpose; the SSE/transport core above is solid, these need one live pass: + +- **`seedAgentChatApp` (`tests.ts`) — the one hard blocker.** The playground mounts `AgentChatPanel` + only for an `is_agent` workflow (`Playground.tsx:106`); the base fixture only seeds + completion/chat. Since `/invoke` is mocked, the agent's config is irrelevant — a **minimal rendered + `is_agent` revision** is all that's needed. Seed it by replaying the product's create-agent API + calls (mirror `apiHelpers.createApp`) or by driving the create flow once and capturing the appId + + latest revision id. The fixture currently throws until this is done. +- **Composer + field selectors** — `sendChatMessage` and `getByLabel("First Name")` are best-effort + against `RichChatInput` and the `SchemaForm` DOM; confirm and adjust on first run. +- **Reload rehydration source (specs 3 & 4)** — a mocked run records no server-side session + transcript. If reload rehydrates the chat from client persistence these pass as-is; if it loads from + the server transcript, the mock must also serve the session-history endpoint. Confirm first. + +## Run one spec (from `web/tests/`, with the stack up) + +```bash +AGENTA_LICENSE=oss \ +AGENTA_WEB_URL="http://localhost:3000" \ +AGENTA_API_URL="http://localhost:3000/api" \ +AGENTA_TEST_LLM_PROVIDER=mock \ +npx playwright test ../oss/tests/playwright/acceptance/agent-chat/elicitation.spec.ts \ + --workers=1 --retries=0 --headed +``` + +Start with spec 1 (round-trip) — it needs no reload and proves the transport-mock pattern end to end. diff --git a/web/oss/tests/playwright/acceptance/agent-chat/assets/elicitationStream.ts b/web/oss/tests/playwright/acceptance/agent-chat/assets/elicitationStream.ts new file mode 100644 index 0000000000..726ad381c4 --- /dev/null +++ b/web/oss/tests/playwright/acceptance/agent-chat/assets/elicitationStream.ts @@ -0,0 +1,122 @@ +/** + * Canned AI SDK v6 (beta) UI-message-stream builders for the elicitation E2E (layer A). + * + * These reproduce, byte for byte, the SSE a real agent run streams when a `request_input` + * client tool pauses — WITHOUT a live LLM or backend agent. A Playwright `page.route` on + * `**​/invoke*` fulfils with these bytes, so the whole emit → render → settle → resume → + * replay path is exercised deterministically. The wire shapes are pinned against the real + * producer (`sdks/python/agenta/sdk/agents/adapters/vercel/{stream,sse}.py`): + * + * - framing: one `data: \n\n` per chunk, terminated by `data: [DONE]\n\n` + * - a paused client-tool turn ends with the tool part left in `input-available` (no output), + * the render kind riding a sibling `data-render` part, and `finish` reason `"other"` + * - the response Content-Type MUST be `text/event-stream`, else the FE's negotiating fetch + * (`agentNegotiation.ts`) treats a 200 as batch JSON and never runs the SSE parser + * + * If the wire ever changes, these builders are the single place to update on the FE-test side. + */ + +/** The flat elicitation payload the mocked `request_input` call carries (drives the form). */ +export interface ElicitationFieldFixture { + type: "string" | "number" | "integer" | "boolean" + title?: string + enum?: string[] + format?: string +} + +export interface ElicitationPayloadFixture { + message: string + requestedSchema: { + type: "object" + properties: Record + required?: string[] + } +} + +/** A two-field form (required text + enum dropdown) plus an optional multiline field. */ +export const ELICITATION_PAYLOAD: ElicitationPayloadFixture = { + message: "Tell me a bit about you.", + requestedSchema: { + type: "object", + properties: { + name: {type: "string", title: "First Name"}, + color: {type: "string", title: "Favorite Color", enum: ["red", "green", "blue"]}, + notes: {type: "string", title: "Notes", format: "multiline"}, + }, + required: ["name"], + }, +} + +/** The reserved static-catalog client-tool name the platform emits for elicitation. */ +export const REQUEST_INPUT_TOOL_NAME = "__ag__request_input" + +const frame = (chunk: Record): string => `data: ${JSON.stringify(chunk)}\n\n` + +const DONE = "data: [DONE]\n\n" + +/** + * A paused elicitation turn: optional preamble text, then the `request_input` tool call left + * unsettled (`tool-input-available`, no output) with its sibling `data-render` part, then the + * turn closes with `finish` reason `"other"` (the runner's `paused` maps to `other`). The FE + * reads the unsettled last-message tool part as a parked client tool and renders the form. + */ +export function elicitationPausedTurn(opts: { + messageId: string + toolCallId: string + payload: ElicitationPayloadFixture + preamble?: string + toolName?: string +}): string { + const {messageId, toolCallId, payload, preamble, toolName = REQUEST_INPUT_TOOL_NAME} = opts + const chunks: string[] = [frame({type: "start", messageId}), frame({type: "start-step"})] + if (preamble) { + chunks.push( + frame({type: "text-start", id: `${messageId}-t`}), + frame({type: "text-delta", id: `${messageId}-t`, delta: preamble}), + frame({type: "text-end", id: `${messageId}-t`}), + ) + } + chunks.push( + frame({type: "tool-input-start", toolCallId, toolName}), + frame({type: "tool-input-available", toolCallId, toolName, input: payload}), + frame({type: "data-render", data: {toolCallId, render: {kind: "elicitation"}}}), + frame({type: "finish-step"}), + frame({type: "finish", finishReason: "other"}), + DONE, + ) + return chunks.join("") +} + +/** + * A normal resume turn (streamed text) — what the agent "says" after the form is submitted and + * the settled tool output is resent. `finish` reason is `"stop"`. Use the echoed text to assert + * the run genuinely resumed with the submitted values. + */ +export function resumeTextTurn(opts: {messageId: string; text: string}): string { + const {messageId, text} = opts + return [ + frame({type: "start", messageId}), + frame({type: "start-step"}), + frame({type: "text-start", id: `${messageId}-t`}), + frame({type: "text-delta", id: `${messageId}-t`, delta: text}), + frame({type: "text-end", id: `${messageId}-t`}), + frame({type: "finish-step"}), + frame({type: "finish", finishReason: "stop"}), + DONE, + ].join("") +} + +/** Playwright `route.fulfill` options for an SSE body — Content-Type is load-bearing (see header). */ +export function sseFulfill(body: string) { + return { + status: 200, + contentType: "text/event-stream", + headers: { + "x-vercel-ai-ui-message-stream": "v1", + "x-ag-messages-format": "vercel", + "x-ag-messages-version": "v1", + "cache-control": "no-cache", + }, + body, + } +} diff --git a/web/oss/tests/playwright/acceptance/agent-chat/assets/types.ts b/web/oss/tests/playwright/acceptance/agent-chat/assets/types.ts new file mode 100644 index 0000000000..18efcd3cb1 --- /dev/null +++ b/web/oss/tests/playwright/acceptance/agent-chat/assets/types.ts @@ -0,0 +1,30 @@ +import {BaseFixture} from "@agenta/web-tests/tests/fixtures/base.fixture/types" + +import type {ElicitationPayloadFixture} from "./elicitationStream" + +/** A handle the mock returns so a spec can assert what the transport actually sent. */ +export interface ElicitationInvokeMock { + /** Every POST body seen on `**​/invoke*`, in order (index 0 = initial send, 1 = resume). */ + readonly calls: Array> + /** How the second (resume) turn "replies" — override to echo the submitted values. */ + setResumeText: (text: string) => void +} + +export interface AgentChatFixtures extends BaseFixture { + /** + * Seed a rendered agent (`is_agent`) app+revision and return its appId. The playground only + * mounts `AgentChatPanel` for an agent workflow, so a completion/chat app will NOT exercise the + * elicitation widget. See tests.ts for the first-run resolution note — this is the one seam that + * needs the live stack to pin down. + */ + seedAgentChatApp: () => Promise + /** Navigate to the agent playground for `appId` and wait until the chat panel is interactive. */ + navigateToAgentPlayground: (appId: string) => Promise + /** + * Intercept `**​/invoke*`: the first run streams the paused elicitation form; the second (the + * auto-resume after settle) streams a normal text turn. Returns the mock handle. + */ + mockElicitationInvoke: (payload?: ElicitationPayloadFixture) => Promise + /** Type a message into the agent chat composer and send it. */ + sendChatMessage: (text: string) => Promise +} diff --git a/web/oss/tests/playwright/acceptance/agent-chat/elicitation.spec.ts b/web/oss/tests/playwright/acceptance/agent-chat/elicitation.spec.ts new file mode 100644 index 0000000000..0ac244b5ae --- /dev/null +++ b/web/oss/tests/playwright/acceptance/agent-chat/elicitation.spec.ts @@ -0,0 +1,4 @@ +import {test} from "@agenta/web-tests/tests/fixtures/base.fixture" +import agentChatTests from "." + +test.describe("Agent chat: Elicitation forms (interaction kinds M1)", agentChatTests) diff --git a/web/oss/tests/playwright/acceptance/agent-chat/index.ts b/web/oss/tests/playwright/acceptance/agent-chat/index.ts new file mode 100644 index 0000000000..06d98029bc --- /dev/null +++ b/web/oss/tests/playwright/acceptance/agent-chat/index.ts @@ -0,0 +1,237 @@ +import { + TestCoverage, + TestcaseType, + TestPath, + TestScope, + TestLensType, + TestCostType, + TestLicenseType, + TestRoleType, + TestSpeedType, +} from "@agenta/web-tests/playwright/config/testTags" +import {expect} from "@agenta/web-tests/utils" + +import {expectAuthenticatedSession} from "../utils/auth" +import {createScenarios} from "../utils/scenarios" +import {buildAcceptanceTags} from "../utils/tags" + +import {ELICITATION_PAYLOAD} from "./assets/elicitationStream" +import {test as baseAgentChatTest} from "./tests" + +const scenarios = createScenarios(baseAgentChatTest) + +const sharedTags = { + path: TestPath.HAPPY, + lens: TestLensType.FUNCTIONAL, + cost: TestCostType.Free, + license: TestLicenseType.OSS, + role: TestRoleType.Owner, + caseType: TestcaseType.TYPICAL, +} + +const elicitationTags = buildAcceptanceTags({ + scope: [TestScope.PLAYGROUND], + coverage: [TestCoverage.SMOKE, TestCoverage.LIGHT, TestCoverage.FULL], + speed: TestSpeedType.SLOW, + ...sharedTags, +}) + +const agentChatTests = () => { + // ── Spec 1: round-trip (mock-only; no reload) ────────────────────────────────────────────── + baseAgentChatTest( + "Elicitation round-trip: form renders, accept resumes with the submitted values", + {tag: elicitationTags}, + async ({ + page, + seedAgentChatApp, + navigateToAgentPlayground, + mockElicitationInvoke, + sendChatMessage, + }) => { + baseAgentChatTest.setTimeout(120000) + let appId = "" + let mock!: Awaited> + + await scenarios.given("the user is authenticated", async () => { + await expectAuthenticatedSession(page) + }) + + await scenarios.and("a rendered agent app is open in the playground", async () => { + appId = await seedAgentChatApp() + await navigateToAgentPlayground(appId) + }) + + await scenarios.and("the agent run is mocked to request input", async () => { + mock = await mockElicitationInvoke() + mock.setResumeText("First Name: Ada · Favorite Color: green") + }) + + await scenarios.when("the user sends a message", async () => { + await sendChatMessage("hi") + }) + + await scenarios.then( + "the elicitation form renders (not JSON, not the apology)", + async () => { + await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByText(/Asked by .*request_input/)).toBeVisible() + await expect( + page.getByRole("button", {name: "Accept", exact: true}), + ).toBeVisible() + }, + ) + + await scenarios.and("the user fills the required field and accepts", async () => { + // First-run: confirm the field locator against the live SchemaForm DOM + // (labels above fields; key by the schema `title`). + await page.getByLabel("First Name").fill("Ada") + await page.getByRole("button", {name: "Accept", exact: true}).click() + }) + + await scenarios.then("the run resumes with the submitted values", async () => { + await expect(page.getByText("Provided the requested input.")).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByText("First Name: Ada")).toBeVisible() + // The resume POST carried the settled output back through the same /invoke path. + expect(mock.calls.length).toBeGreaterThanOrEqual(2) + expect(JSON.stringify(mock.calls[1] ?? {})).toContain("Ada") + }) + }, + ) + + // ── Spec 2: required-field gate (mock-only; regression) ───────────────────────────────────── + baseAgentChatTest( + "Elicitation required-field gate: empty Accept shows an inline error and does not resume", + {tag: elicitationTags}, + async ({ + page, + seedAgentChatApp, + navigateToAgentPlayground, + mockElicitationInvoke, + sendChatMessage, + }) => { + baseAgentChatTest.setTimeout(120000) + + await scenarios.given("the user is authenticated", async () => { + await expectAuthenticatedSession(page) + }) + + let mock!: Awaited> + await scenarios.and("a mocked elicitation run is open", async () => { + const appId = await seedAgentChatApp() + await navigateToAgentPlayground(appId) + mock = await mockElicitationInvoke() + }) + + await scenarios.when( + "the user sends a message and accepts with an empty required field", + async () => { + await sendChatMessage("hi") + await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({ + timeout: 30000, + }) + await page.getByRole("button", {name: "Accept", exact: true}).click() + }, + ) + + await scenarios.then("the form stays and the run does not resume", async () => { + await expect(page.getByText(/required/i)).toBeVisible() + await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible() + // No auto-resume fired: only the initial run POST was made. + expect(mock.calls.length).toBe(1) + }) + }, + ) + + // ── Spec 3: settled-state replay after reload ─────────────────────────────────────────────── + // NOTE (first-run): reload rehydration source is the open question for the mock approach. A + // real run records a server-side session transcript; a MOCKED run does not. If reload rehydrates + // the chat from client persistence, this passes as-is; if it loads from the server transcript, + // the mock must also seed/serve the session history (add a route for the history endpoint). Do + // NOT assume — confirm on first run and adjust. + baseAgentChatTest( + "Elicitation settled replay: after accept, a reload shows the read-only chip (no live form)", + {tag: elicitationTags}, + async ({ + page, + seedAgentChatApp, + navigateToAgentPlayground, + mockElicitationInvoke, + sendChatMessage, + }) => { + baseAgentChatTest.setTimeout(120000) + + await expectAuthenticatedSession(page) + const appId = await seedAgentChatApp() + await navigateToAgentPlayground(appId) + const mock = await mockElicitationInvoke() + mock.setResumeText("Recorded.") + + await sendChatMessage("hi") + await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({timeout: 30000}) + await page.getByLabel("First Name").fill("Ada") + await page.getByRole("button", {name: "Accept", exact: true}).click() + await expect(page.getByText("Provided the requested input.")).toBeVisible({ + timeout: 30000, + }) + + await scenarios.when("the user reloads the page", async () => { + await page.reload({waitUntil: "domcontentloaded"}) + }) + + await scenarios.then("the settled chip replays read-only", async () => { + await expect(page.getByText("Provided the requested input.")).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByRole("button", {name: "Accept", exact: true})).toHaveCount(0) + }) + }, + ) + + // ── Spec 4: reload-while-PENDING then accept (design's riskiest transition) ────────────────── + // Same reload-rehydration caveat as spec 3 — but here the form is PENDING (pre-settle), which the + // design states replays from localStorage before the server store. Confirm the source on first run. + baseAgentChatTest( + "Elicitation reload-while-pending: the live form re-renders and still accepts", + {tag: elicitationTags}, + async ({ + page, + seedAgentChatApp, + navigateToAgentPlayground, + mockElicitationInvoke, + sendChatMessage, + }) => { + baseAgentChatTest.setTimeout(120000) + + await expectAuthenticatedSession(page) + const appId = await seedAgentChatApp() + await navigateToAgentPlayground(appId) + const mock = await mockElicitationInvoke() + mock.setResumeText("First Name: Ada") + + await sendChatMessage("hi") + await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({timeout: 30000}) + + await scenarios.when("the user reloads while the form is pending", async () => { + await page.reload({waitUntil: "domcontentloaded"}) + }) + + await scenarios.then("the live form re-renders and accept resumes", async () => { + await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({ + timeout: 30000, + }) + await page.getByLabel("First Name").fill("Ada") + await page.getByRole("button", {name: "Accept", exact: true}).click() + await expect(page.getByText("Provided the requested input.")).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByText("First Name: Ada")).toBeVisible() + }) + }, + ) +} + +export default agentChatTests diff --git a/web/oss/tests/playwright/acceptance/agent-chat/tests.ts b/web/oss/tests/playwright/acceptance/agent-chat/tests.ts new file mode 100644 index 0000000000..474f4a6da7 --- /dev/null +++ b/web/oss/tests/playwright/acceptance/agent-chat/tests.ts @@ -0,0 +1,166 @@ +import type {Page} from "@playwright/test" + +import {test as baseTest} from "@agenta/web-tests/tests/fixtures/base.fixture" +import {expect} from "@agenta/web-tests/utils" + +import {AgentChatFixtures} from "./assets/types" +import { + ELICITATION_PAYLOAD, + elicitationPausedTurn, + resumeTextTurn, + sseFulfill, + type ElicitationPayloadFixture, +} from "./assets/elicitationStream" + +/** + * Agent-chat acceptance fixtures (elicitation / interaction-kinds M1, layer A). + * + * Strategy: keep auth, project, the seeded agent revision, and the playground shell REAL, and mock + * only the agent run (`**​/invoke*`) with deterministic SSE. That isolates the FE contract — emit → + * render → settle → resume → replay — with no LLM in the loop. + */ + +/** The built-in agent URI — the backend derives `is_agent` from `data.uri` key "agent". */ +const AGENT_URI = "agenta:builtin:agent:v0" + +/** Revision id per seeded app, so navigation can deep-link `?revisions=`. */ +const seededRevisionByApp = new Map() + +const apiBase = (page: Page): string => { + if (process.env.AGENTA_API_URL) return process.env.AGENTA_API_URL + const origin = new URL(page.url() || process.env.AGENTA_WEB_URL || "http://localhost:3000") + .origin + return `${origin}/api` +} + +const testWithAgentChatFixtures = baseTest.extend({ + // Seed a minimal is_agent app via the API (Artifact → Variant → Revision). `is_agent` falls out + // of the revision's `data.uri` — no provider/model needed (the run is mocked). Auth rides the + // browser context's session cookies (storageState), same as apiHelpers' direct calls. + seedAgentChatApp: async ({page, apiHelpers}, use) => { + await use(async () => { + const base = apiBase(page) + const projectId = apiHelpers.getProjectScopedBasePath().match(/\/p\/([^/]+)/)?.[1] + if (!projectId) throw new Error("[agent-chat E2E] could not derive projectId") + const q = `?project_id=${projectId}` + const unique = `${Date.now()}` + const slug = `e2e-agent-${unique}` + + const post = async (path: string, data: Record) => { + const res = await page.request.post(`${base}${path}${q}`, {data}) + if (!res.ok()) { + throw new Error( + `[agent-chat E2E] POST ${path} -> ${res.status()} ${await res + .text() + .catch(() => "")}`, + ) + } + return res.json() + } + + const wf = await post("/workflows/", { + workflow: { + slug, + name: "E2E Agent", + flags: {is_application: true, is_evaluator: false, is_snippet: false}, + }, + }) + const workflowId = wf.workflow?.id as string + + const variant = await post("/workflows/variants/", { + workflow_variant: { + workflow_id: workflowId, + slug: `${slug}.default`, + name: "default", + }, + }) + const variantId = variant.workflow_variant?.id as string + + const commit = await post("/workflows/revisions/commit", { + workflow_revision: { + workflow_id: workflowId, + workflow_variant_id: variantId, + slug: `${unique}rev`, + name: "default", + data: {uri: AGENT_URI, parameters: {agent: {}}, schemas: {}}, + message: "Agent", + }, + }) + const revisionId = commit.workflow_revision?.id as string + if (workflowId && revisionId) seededRevisionByApp.set(workflowId, revisionId) + return workflowId + }) + }, + + navigateToAgentPlayground: async ({page, uiHelpers}, use) => { + await use(async (appId: string) => { + const scopedPrefix = + new URL(page.url() || "http://localhost").pathname.match( + /^(\/w\/[^/]+\/p\/[^/]+)/, + )?.[1] ?? "" + const playgroundUrl = `${scopedPrefix}/apps/${appId}/playground` + const revisionId = seededRevisionByApp.get(appId) + + await page.goto(scopedPrefix ? `${scopedPrefix}/apps` : "/apps", { + waitUntil: "domcontentloaded", + }) + await uiHelpers.expectPath("/apps") + const target = revisionId ? `${playgroundUrl}?revisions=${revisionId}` : playgroundUrl + await page.goto(target, {waitUntil: "domcontentloaded"}) + await uiHelpers.expectPath(`/apps/${appId}/playground`) + + // The agent chat panel is interactive once the composer textbox is mounted. + // (First-run: confirm this selector against the live RichChatInput composer.) + await expect(page.getByRole("textbox").last()).toBeVisible({timeout: 30000}) + }) + }, + + mockElicitationInvoke: async ({page}, use) => { + await use(async (payload: ElicitationPayloadFixture = ELICITATION_PAYLOAD) => { + const calls: Array> = [] + let resumeText = "Thanks — I've recorded your answers." + let n = 0 + const toolCallId = "call_elicit_1" + + await page.route("**/invoke*", async (route) => { + const post = route.request().postData() + try { + calls.push(post ? JSON.parse(post) : {}) + } catch { + calls.push({raw: post}) + } + n += 1 + const body = + n === 1 + ? elicitationPausedTurn({ + messageId: `msg-${n}`, + toolCallId, + payload, + preamble: "One moment — I need a couple of details.", + }) + : resumeTextTurn({messageId: `msg-${n}`, text: resumeText}) + await route.fulfill(sseFulfill(body)) + }) + + return { + calls, + setResumeText: (text: string) => { + resumeText = text + }, + } + }) + }, + + sendChatMessage: async ({page}, use) => { + await use(async (text: string) => { + const composer = page.getByRole("textbox").last() + await composer.click() + await composer.fill(text) + // First-run: confirm send is Enter (RichChatInput) vs a Send button. + await composer.press("Enter") + }) + }, +}) + +export {testWithAgentChatFixtures as test} +export {expect} From 6bc3f51315876ff0ff796cbfa75863133dc51b85 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 19:10:00 +0200 Subject: [PATCH 04/13] =?UTF-8?q?test(frontend):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20validate=20seed=20ids,=20consistent=20scenario=20st?= =?UTF-8?q?eps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seedAgentChatApp now validates every step's response id (workflow, variant, revision) via a requireId helper, failing fast at the exact malformed step instead of silently returning a broken appId; drops the unsafe `as string` casts. - specs 3 & 4 wrap setup in scenarios.given/and to match specs 1 & 2 (readable BDD report; no behavior change). (CodeRabbit's "duplicate assertion" flag on client-tools.test.ts was a false positive — no duplicate exists; verified. The page.unroute nit is unnecessary — Playwright's per-test isolation discards routes.) --- .../playwright/acceptance/agent-chat/index.ts | 54 ++++++++----- .../playwright/acceptance/agent-chat/tests.ts | 81 ++++++++++++------- 2 files changed, 87 insertions(+), 48 deletions(-) diff --git a/web/oss/tests/playwright/acceptance/agent-chat/index.ts b/web/oss/tests/playwright/acceptance/agent-chat/index.ts index 06d98029bc..d7ad36c6a0 100644 --- a/web/oss/tests/playwright/acceptance/agent-chat/index.ts +++ b/web/oss/tests/playwright/acceptance/agent-chat/index.ts @@ -164,18 +164,27 @@ const agentChatTests = () => { }) => { baseAgentChatTest.setTimeout(120000) - await expectAuthenticatedSession(page) - const appId = await seedAgentChatApp() - await navigateToAgentPlayground(appId) - const mock = await mockElicitationInvoke() - mock.setResumeText("Recorded.") + await scenarios.given("the user is authenticated", async () => { + await expectAuthenticatedSession(page) + }) - await sendChatMessage("hi") - await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({timeout: 30000}) - await page.getByLabel("First Name").fill("Ada") - await page.getByRole("button", {name: "Accept", exact: true}).click() - await expect(page.getByText("Provided the requested input.")).toBeVisible({ - timeout: 30000, + await scenarios.and("a mocked elicitation run is open", async () => { + const appId = await seedAgentChatApp() + await navigateToAgentPlayground(appId) + const mock = await mockElicitationInvoke() + mock.setResumeText("Recorded.") + }) + + await scenarios.and("the user fills and accepts the form", async () => { + await sendChatMessage("hi") + await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({ + timeout: 30000, + }) + await page.getByLabel("First Name").fill("Ada") + await page.getByRole("button", {name: "Accept", exact: true}).click() + await expect(page.getByText("Provided the requested input.")).toBeVisible({ + timeout: 30000, + }) }) await scenarios.when("the user reloads the page", async () => { @@ -206,14 +215,23 @@ const agentChatTests = () => { }) => { baseAgentChatTest.setTimeout(120000) - await expectAuthenticatedSession(page) - const appId = await seedAgentChatApp() - await navigateToAgentPlayground(appId) - const mock = await mockElicitationInvoke() - mock.setResumeText("First Name: Ada") + await scenarios.given("the user is authenticated", async () => { + await expectAuthenticatedSession(page) + }) - await sendChatMessage("hi") - await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({timeout: 30000}) + await scenarios.and( + "a mocked elicitation run is open with a pending form", + async () => { + const appId = await seedAgentChatApp() + await navigateToAgentPlayground(appId) + const mock = await mockElicitationInvoke() + mock.setResumeText("First Name: Ada") + await sendChatMessage("hi") + await expect(page.getByText(ELICITATION_PAYLOAD.message)).toBeVisible({ + timeout: 30000, + }) + }, + ) await scenarios.when("the user reloads while the form is pending", async () => { await page.reload({waitUntil: "domcontentloaded"}) diff --git a/web/oss/tests/playwright/acceptance/agent-chat/tests.ts b/web/oss/tests/playwright/acceptance/agent-chat/tests.ts index 474f4a6da7..ff30d66a3e 100644 --- a/web/oss/tests/playwright/acceptance/agent-chat/tests.ts +++ b/web/oss/tests/playwright/acceptance/agent-chat/tests.ts @@ -57,37 +57,58 @@ const testWithAgentChatFixtures = baseTest.extend({ } return res.json() } + // Fail fast at the exact step whose response is missing its id, rather than seeding a + // broken app that only surfaces as a confusing timeout downstream. + const requireId = (body: any, key: string, step: string): string => { + const id = body?.[key]?.id + if (typeof id !== "string" || !id) { + throw new Error( + `[agent-chat E2E] ${step} returned no id (body: ${JSON.stringify(body).slice(0, 200)})`, + ) + } + return id + } - const wf = await post("/workflows/", { - workflow: { - slug, - name: "E2E Agent", - flags: {is_application: true, is_evaluator: false, is_snippet: false}, - }, - }) - const workflowId = wf.workflow?.id as string - - const variant = await post("/workflows/variants/", { - workflow_variant: { - workflow_id: workflowId, - slug: `${slug}.default`, - name: "default", - }, - }) - const variantId = variant.workflow_variant?.id as string - - const commit = await post("/workflows/revisions/commit", { - workflow_revision: { - workflow_id: workflowId, - workflow_variant_id: variantId, - slug: `${unique}rev`, - name: "default", - data: {uri: AGENT_URI, parameters: {agent: {}}, schemas: {}}, - message: "Agent", - }, - }) - const revisionId = commit.workflow_revision?.id as string - if (workflowId && revisionId) seededRevisionByApp.set(workflowId, revisionId) + const workflowId = requireId( + await post("/workflows/", { + workflow: { + slug, + name: "E2E Agent", + flags: {is_application: true, is_evaluator: false, is_snippet: false}, + }, + }), + "workflow", + "create workflow", + ) + + const variantId = requireId( + await post("/workflows/variants/", { + workflow_variant: { + workflow_id: workflowId, + slug: `${slug}.default`, + name: "default", + }, + }), + "workflow_variant", + "create variant", + ) + + const revisionId = requireId( + await post("/workflows/revisions/commit", { + workflow_revision: { + workflow_id: workflowId, + workflow_variant_id: variantId, + slug: `${unique}rev`, + name: "default", + data: {uri: AGENT_URI, parameters: {agent: {}}, schemas: {}}, + message: "Agent", + }, + }), + "workflow_revision", + "commit revision", + ) + + seededRevisionByApp.set(workflowId, revisionId) return workflowId }) }, From c2e189c4c1c6c437a7fd254223ee4462079e7ac5 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 02:57:41 +0200 Subject: [PATCH 05/13] feat(frontend): support default values on elicitation form fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #5190 (AGE-3935). A playbook could not prefill a proposed value — the contract typed `default` away (`default?: never`), so agent-templates worked around it with an enum-first "figure it out" option. The render path already existed: SchemaForm threads `default` into every field's initialValue for its other consumer (gateway forms). The actual changes: - contract: `default?: string | number | boolean`, with a validation rule rejecting non-primitive defaults (stable degradation reason). - request_input description now tells the model to propose defaults (one- click accept) and that enum options are suggestions, not exhaustive — the guidance change that retires the playbook workaround. - golden fixture gains a defaulted enum + boolean field; the pytest golden check pins primitive defaults and requires the fixture to exercise one. - EnumWithOther: an off-options value arriving after mount (a default via Form initialValue, or a replayed draft) opens Other-mode prefilled; the Other input only autofocuses when the user picked "Other…" themselves. - date/date-time fields ignore `default` instead of crashing: a wire default is an ISO string and antd DatePicker requires dayjs. Dates are outside the issue's scope (string/number/integer/boolean/enum). --- api/oss/src/core/workflows/static_catalog.py | 9 +++-- .../unit/workflows/test_static_catalog.py | 4 +++ .../src/gatewayTool/components/SchemaForm.tsx | 28 ++++++++++----- .../agenta-shared/src/utils/elicitation.ts | 8 ++++- .../tests/fixtures/elicitation_request.json | 6 ++-- .../tests/unit/elicitation.test.ts | 35 +++++++++++++++++++ 6 files changed, 77 insertions(+), 13 deletions(-) diff --git a/api/oss/src/core/workflows/static_catalog.py b/api/oss/src/core/workflows/static_catalog.py index d11f9d10b3..07fef3fcf2 100644 --- a/api/oss/src/core/workflows/static_catalog.py +++ b/api/oss/src/core/workflows/static_catalog.py @@ -163,8 +163,13 @@ def _request_input_revision() -> WorkflowRevision: "(subdomain, workspace) before request_connection; or collect schedule " "details (frequency, time of day, timezone) before create_schedule. " "`requestedSchema` must be a FLAT JSON object schema: top-level " - "string/number/integer/boolean properties only (enum, format and title " - "allowed) — no nested objects or arrays. Supported `format` values: " + "string/number/integer/boolean properties only (enum, format, title " + "and default allowed) — no nested objects or arrays. When you can " + "propose a sensible value, set it as the field's `default` (a " + "primitive): it prefills the form so the user can accept everything " + "in one click. Enum options are SUGGESTIONS, not a hard constraint — " + "the form lets the user type their own value, so keep enums short and " + "likely rather than exhaustive. Supported `format` values: " "'date', 'date-time', 'email', 'uri', and 'multiline' — use 'multiline' " "for any long or free-form text field (notes, a description, a message " "body). NEVER request secrets " diff --git a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py index 2fbbe5107f..04cc3add6f 100644 --- a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py +++ b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py @@ -945,7 +945,11 @@ def test_request_input_matches_golden_request_fixture(): for name, prop in requested["properties"].items(): assert prop["type"] in _ELICITATION_PRIMITIVES, name assert "properties" not in prop and "items" not in prop, name + if "default" in prop: + assert isinstance(prop["default"], (str, int, float, bool)), name assert set(requested.get("required", [])) <= set(requested["properties"]) + # The golden must exercise a prefilled field (defaults are part of the dialect, #5190). + assert any("default" in prop for prop in requested["properties"].values()) def test_request_input_matches_golden_response_fixture(): diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index 1346b078d3..36ffbbf239 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -1,4 +1,12 @@ -import {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from "react" +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react" import {buildFormFieldsFromSchema, type FormFieldDescriptor} from "@agenta/shared/utils" import {Editor} from "@agenta/ui/editor" @@ -261,6 +269,11 @@ function EnumWithOther({ }) { const inOptions = value != null && options.includes(value) const [otherMode, setOtherMode] = useState(value != null && !inOptions) + // An off-options value can also arrive AFTER mount (schema `default` via Form initialValue, + // or a replayed draft) — it must open Other-mode with the text prefilled. + useEffect(() => { + if (value != null && !options.includes(value)) setOtherMode(true) + }, [value, options]) const selectValue = otherMode ? OTHER_ENUM_OPTION : inOptions ? value : undefined return ( @@ -286,7 +299,9 @@ function EnumWithOther({ /> {otherMode && ( + typeof v !== "string")) return {ok: false, reason: `property "${name}" enum must be strings`} } + if ( + prop.default !== undefined && + !["string", "number", "boolean"].includes(typeof prop.default) + ) + return {ok: false, reason: `property "${name}" default must be a primitive`} const title = typeof prop.title === "string" ? prop.title : "" if (SECRET_FIELD_PATTERN.test(name) || SECRET_FIELD_PATTERN.test(title)) return {ok: false, reason: `property "${name}" is secret-shaped — use a connect flow`} diff --git a/web/packages/agenta-shared/tests/fixtures/elicitation_request.json b/web/packages/agenta-shared/tests/fixtures/elicitation_request.json index bf4620022a..5c18ee9692 100644 --- a/web/packages/agenta-shared/tests/fixtures/elicitation_request.json +++ b/web/packages/agenta-shared/tests/fixtures/elicitation_request.json @@ -18,7 +18,8 @@ "timezone": { "type": "string", "title": "Timezone", - "enum": ["Europe/Berlin", "Europe/Istanbul", "UTC", "America/New_York"] + "enum": ["Europe/Berlin", "Europe/Istanbul", "UTC", "America/New_York"], + "default": "UTC" }, "delivery_message": { "type": "string", @@ -28,7 +29,8 @@ }, "active": { "type": "boolean", - "title": "Start active" + "title": "Start active", + "default": true } }, "required": ["frequency", "time_of_day", "timezone"] diff --git a/web/packages/agenta-shared/tests/unit/elicitation.test.ts b/web/packages/agenta-shared/tests/unit/elicitation.test.ts index 3b302058bb..7f0bdbabd6 100644 --- a/web/packages/agenta-shared/tests/unit/elicitation.test.ts +++ b/web/packages/agenta-shared/tests/unit/elicitation.test.ts @@ -151,6 +151,41 @@ describe("parseElicitationPayload", () => { }) }) + it("accepts primitive defaults and keeps them on the parsed payload", () => { + const payload = validPayload() + const props = payload.requestedSchema.properties as Record + props.name = {type: "string", title: "Name", default: "Ada"} + props.count = {type: "integer", minimum: 1, default: 3} + props.active = {type: "boolean", default: true} + props.level = {type: "string", enum: ["low", "high"], default: "high"} + const result = parseElicitationPayload(payload) + expect(result.ok).toBe(true) + if (!result.ok) return + const parsed = result.payload.requestedSchema.properties + expect(parsed.name.default).toBe("Ada") + expect(parsed.count.default).toBe(3) + expect(parsed.active.default).toBe(true) + expect(parsed.level.default).toBe("high") + }) + + it("rejects non-primitive defaults", () => { + const payload = validPayload() + ;(payload.requestedSchema.properties as Record).name = { + type: "string", + default: {nested: true}, + } + expect(parseElicitationPayload(payload)).toEqual({ + ok: false, + reason: 'property "name" default must be a primitive', + }) + const arr = validPayload() + ;(arr.requestedSchema.properties as Record).name = { + type: "string", + default: ["a"], + } + expect(parseElicitationPayload(arr).ok).toBe(false) + }) + it("rejects secret-shaped fields by name and by title", () => { const byName = validPayload() ;(byName.requestedSchema.properties as Record).api_key = {type: "string"} From 0196a6d46afc297761e7f92a1c6784efe97f3093 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 03:11:04 +0200 Subject: [PATCH 06/13] feat(frontend): multi-select fields in elicitation forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat dialect had no way to express "pick several of these" — arrays were banned wholesale, so a which-actions-to-enable question could only be N booleans or a repeated single-select. Admit the ONE canonical JSON Schema multi-select shape: {type: "array", items: {type: "string", enum?: [...]}}. String leaves only, nothing deeper; content carries an array of strings; defaults extend to arrays of strings on these fields. - contract: array branch in the validator with stable degradation reasons (items must be strings, no deeper nesting, array-of-strings default). - renderer (openEnums-gated, elicitation only): a chip picker with the same "Other…" escape hatch as single-select — picking Other… reveals a text input that appends one custom chip, repeatable. Enum-less string arrays degrade to free chip entry. Gateway forms keep their Form.List arrays untouched (flag-off regression test). - request_input description documents the multi-pick shape; the golden fixture gains a defaulted multi-select field, pinned by the pytest golden check (which now requires the fixture to exercise one). --- api/oss/src/core/workflows/static_catalog.py | 6 +- .../unit/workflows/test_static_catalog.py | 13 ++- .../src/gatewayTool/components/SchemaForm.tsx | 90 +++++++++++++++++++ .../agenta-shared/src/utils/elicitation.ts | 49 +++++++--- .../src/utils/gatewayToolSchema.ts | 16 ++++ .../tests/fixtures/elicitation_request.json | 6 ++ .../tests/unit/elicitation.test.ts | 43 +++++++++ .../tests/unit/gatewayToolSchema.test.ts | 29 ++++++ 8 files changed, 237 insertions(+), 15 deletions(-) diff --git a/api/oss/src/core/workflows/static_catalog.py b/api/oss/src/core/workflows/static_catalog.py index 07fef3fcf2..d8c591bddb 100644 --- a/api/oss/src/core/workflows/static_catalog.py +++ b/api/oss/src/core/workflows/static_catalog.py @@ -163,8 +163,10 @@ def _request_input_revision() -> WorkflowRevision: "(subdomain, workspace) before request_connection; or collect schedule " "details (frequency, time of day, timezone) before create_schedule. " "`requestedSchema` must be a FLAT JSON object schema: top-level " - "string/number/integer/boolean properties only (enum, format, title " - "and default allowed) — no nested objects or arrays. When you can " + "string/number/integer/boolean properties (enum, format, title and " + "default allowed). For a multi-pick question use {type: 'array', " + "items: {type: 'string', enum: [...]}} — the ONLY array shape " + "allowed; no nested objects or deeper arrays. When you can " "propose a sensible value, set it as the field's `default` (a " "primitive): it prefills the form so the user can accept everything " "in one click. Enum options are SUGGESTIONS, not a hard constraint — " diff --git a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py index 04cc3add6f..7cf42d65ca 100644 --- a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py +++ b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py @@ -943,13 +943,24 @@ def test_request_input_matches_golden_request_fixture(): requested = golden["requestedSchema"] assert requested["type"] == "object" for name, prop in requested["properties"].items(): + if prop["type"] == "array": + # Multi-select: the ONE admitted array shape — string items, optional enum. + items = prop["items"] + assert items["type"] == "string", name + assert "properties" not in items and "items" not in items, name + if "default" in prop: + assert isinstance(prop["default"], list), name + assert all(isinstance(v, str) for v in prop["default"]), name + continue assert prop["type"] in _ELICITATION_PRIMITIVES, name assert "properties" not in prop and "items" not in prop, name if "default" in prop: assert isinstance(prop["default"], (str, int, float, bool)), name assert set(requested.get("required", [])) <= set(requested["properties"]) - # The golden must exercise a prefilled field (defaults are part of the dialect, #5190). + # The golden must exercise a prefilled field (defaults are part of the dialect, #5190) + # and a multi-select field (the one admitted array shape). assert any("default" in prop for prop in requested["properties"].values()) + assert any(prop["type"] == "array" for prop in requested["properties"].values()) def test_request_input_matches_golden_response_fixture(): diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index 36ffbbf239..f430c551dc 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -312,6 +312,82 @@ function EnumWithOther({ ) } +/** + * Multi-select with the same "Other…" escape hatch as EnumWithOther: picking Other… reveals a + * text input that appends ONE custom chip (repeatable). Without options (a free string list), + * it degrades to tags mode — plain typed entries. Off-options values (defaults, replays) render + * as chips natively. + */ +function MultiEnumWithOther({ + value, + onChange, + options, + placeholder, + disabled, +}: { + value?: string[] + onChange?: (v: string[] | undefined) => void + options: string[] + placeholder?: string + disabled?: boolean +}) { + const [otherDraft, setOtherDraft] = useState(null) + const selected = value ?? [] + + if (options.length === 0) { + return ( + { + if (next.includes(OTHER_ENUM_OPTION)) { + setOtherDraft("") + next = next.filter((v) => v !== OTHER_ENUM_OPTION) + } + onChange?.(next.length ? next : undefined) + }} + options={[ + ...options.map((v) => ({value: v, label: v})), + {value: OTHER_ENUM_OPTION, label: "Other…"}, + ]} + /> + {otherDraft !== null && ( + setOtherDraft(e.target.value)} + onPressEnter={commitDraft} + onBlur={commitDraft} + /> + )} +
+ ) +} + function SchemaFormField({field, depth = 0}: {field: FormFieldDescriptor; depth?: number}) { const rules = field.required ? [{required: true, message: `${field.label} is required`}] : [] const label = @@ -380,6 +456,20 @@ function SchemaFormField({field, depth = 0}: {field: FormFieldDescriptor; depth? ) } + // Multi-select (elicitation, openEnums): string-items arrays render as a chip picker. + if (field.type === "array" && field.multiple) { + return ( + + + + ) + } + // Array with structured item schema → Form.List with add/remove if (field.type === "array") { return diff --git a/web/packages/agenta-shared/src/utils/elicitation.ts b/web/packages/agenta-shared/src/utils/elicitation.ts index a905a1e8b8..da6e23f8d4 100644 --- a/web/packages/agenta-shared/src/utils/elicitation.ts +++ b/web/packages/agenta-shared/src/utils/elicitation.ts @@ -3,7 +3,8 @@ * * The `elicitation` render kind lets a platform op request typed input mid-run: the wire * carries `{message, requestedSchema}` (the MCP-elicitation FLAT dialect — top-level - * primitives/enums only, plus `x-ag-*` presentation hints), the chat renders a form, and the + * primitives/enums plus string multi-select arrays, with `x-ag-*` presentation hints), the chat + * renders a form, and the * settling tool result carries `{action: accept|decline|cancel, content?}`. This module is the * single source of truth for that contract on the TS side: payload validation (which doubles as * the fallback-tier dispatch check), the result envelope, and part-state derivation. Pinned by @@ -48,13 +49,18 @@ export function normalizeStringFormat(format: unknown): string | undefined { } export interface ElicitationFieldSchema { - type: "string" | "number" | "integer" | "boolean" + type: "string" | "number" | "integer" | "boolean" | "array" title?: string description?: string enum?: string[] format?: string + /** + * Multi-select (`type: "array"` only): the ONE array shape the dialect admits — string items, + * optionally constrained by an enum (the multi-pick options). Nothing deeper. + */ + items?: {type: "string"; enum?: string[]} /** Proposed value prefilling the field, so the user can accept the whole form in one click. */ - default?: string | number | boolean + default?: string | number | boolean | string[] minimum?: number maximum?: number minLength?: number @@ -93,6 +99,9 @@ export type ElicitationParseResult = const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((v) => typeof v === "string") + /** * Validate an incoming client-tool input as an elicitation payload (tolerant reader: unknown * extra keys are ignored; the hard rules below are the dialect). Failure reasons are stable @@ -116,19 +125,35 @@ export function parseElicitationPayload(input: unknown): ElicitationParseResult for (const [name, prop] of Object.entries(requestedSchema.properties)) { if (!isRecord(prop)) return {ok: false, reason: `property "${name}" is not an object`} const type = prop.type - if (typeof type !== "string" || !FIELD_TYPES.has(type)) + if (typeof type !== "string" || (!FIELD_TYPES.has(type) && type !== "array")) return {ok: false, reason: `property "${name}" has unsupported type "${String(type)}"`} - if ("properties" in prop || "items" in prop) + if ("properties" in prop) return {ok: false, reason: `property "${name}" is nested — flat dialect only`} - if (prop.enum !== undefined) { - if (!Array.isArray(prop.enum) || prop.enum.some((v) => typeof v !== "string")) + if (type === "array") { + // Multi-select: the ONE admitted array shape — string items, optional enum, no deeper. + const items = prop.items + if (!isRecord(items) || items.type !== "string") + return {ok: false, reason: `property "${name}" array items must be strings`} + if ("properties" in items || "items" in items) + return {ok: false, reason: `property "${name}" is nested — flat dialect only`} + if (items.enum !== undefined && !isStringArray(items.enum)) + return {ok: false, reason: `property "${name}" items enum must be strings`} + if (prop.default !== undefined && !isStringArray(prop.default)) + return { + ok: false, + reason: `property "${name}" default must be an array of strings`, + } + } else { + if ("items" in prop) + return {ok: false, reason: `property "${name}" is nested — flat dialect only`} + if (prop.enum !== undefined && !isStringArray(prop.enum)) return {ok: false, reason: `property "${name}" enum must be strings`} + if ( + prop.default !== undefined && + !["string", "number", "boolean"].includes(typeof prop.default) + ) + return {ok: false, reason: `property "${name}" default must be a primitive`} } - if ( - prop.default !== undefined && - !["string", "number", "boolean"].includes(typeof prop.default) - ) - return {ok: false, reason: `property "${name}" default must be a primitive`} const title = typeof prop.title === "string" ? prop.title : "" if (SECRET_FIELD_PATTERN.test(name) || SECRET_FIELD_PATTERN.test(title)) return {ok: false, reason: `property "${name}" is secret-shaped — use a connect flow`} diff --git a/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts b/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts index a6c5458102..0703863077 100644 --- a/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts +++ b/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts @@ -12,6 +12,8 @@ export interface FormFieldDescriptor { format?: string /** Render enum with an "Other…" custom-value escape hatch — set only under `{openEnums: true}`. */ allowCustomEnum?: boolean + /** Multi-select (string-items array) — set only under `{openEnums: true}`; options ride enumValues. */ + multiple?: boolean children?: FormFieldDescriptor[] // nested fields for object type /** For arrays: schema of each item (JSON Schema) */ itemSchema?: Record @@ -106,6 +108,13 @@ export function buildFormFieldsFromSchema( const format = opts?.formats && fieldType === "string" ? normalizeStringFormat(prop.format) : undefined + // Opt-in (elicitation): a string-items array renders as a multi-select control instead + // of the add/remove Form.List; its options ride enumValues (from items.enum). + const multiple = + !!opts?.openEnums && + fieldType === "array" && + (prop.items as Record | undefined)?.type === "string" + return { name: fullName, label: (prop.title as string) ?? name, @@ -116,6 +125,13 @@ export function buildFormFieldsFromSchema( enumValues: prop.enum as string[] | undefined, ...(format !== undefined ? {format} : {}), ...(opts?.openEnums && fieldType === "enum" ? {allowCustomEnum: true} : {}), + ...(multiple + ? { + multiple: true, + allowCustomEnum: true, + enumValues: (prop.items as {enum?: string[]}).enum, + } + : {}), children, itemSchema, itemChildren, diff --git a/web/packages/agenta-shared/tests/fixtures/elicitation_request.json b/web/packages/agenta-shared/tests/fixtures/elicitation_request.json index 5c18ee9692..047bb6089b 100644 --- a/web/packages/agenta-shared/tests/fixtures/elicitation_request.json +++ b/web/packages/agenta-shared/tests/fixtures/elicitation_request.json @@ -31,6 +31,12 @@ "type": "boolean", "title": "Start active", "default": true + }, + "notify_on": { + "type": "array", + "title": "Notify on", + "items": {"type": "string", "enum": ["success", "failure", "skipped"]}, + "default": ["failure"] } }, "required": ["frequency", "time_of_day", "timezone"] diff --git a/web/packages/agenta-shared/tests/unit/elicitation.test.ts b/web/packages/agenta-shared/tests/unit/elicitation.test.ts index 7f0bdbabd6..e4c8bd04df 100644 --- a/web/packages/agenta-shared/tests/unit/elicitation.test.ts +++ b/web/packages/agenta-shared/tests/unit/elicitation.test.ts @@ -186,6 +186,49 @@ describe("parseElicitationPayload", () => { expect(parseElicitationPayload(arr).ok).toBe(false) }) + it("accepts multi-select arrays (string items, optional enum, array-of-strings default)", () => { + const payload = validPayload() + const props = payload.requestedSchema.properties as Record + props.actions = { + type: "array", + title: "Actions", + items: {type: "string", enum: ["send", "list", "read"]}, + default: ["send"], + } + props.repos = {type: "array", title: "Repos", items: {type: "string"}} + const result = parseElicitationPayload(payload) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.payload.requestedSchema.properties.actions.default).toEqual(["send"]) + }) + + it("rejects array fields beyond the multi-select shape", () => { + const cases: [Record, string][] = [ + [{type: "array"}, 'property "bad" array items must be strings'], + [ + {type: "array", items: {type: "number"}}, + 'property "bad" array items must be strings', + ], + [ + {type: "array", items: {type: "string", items: {type: "string"}}}, + 'property "bad" is nested — flat dialect only', + ], + [ + {type: "array", items: {type: "string", enum: [1]}}, + 'property "bad" items enum must be strings', + ], + [ + {type: "array", items: {type: "string"}, default: "send"}, + 'property "bad" default must be an array of strings', + ], + ] + for (const [prop, reason] of cases) { + const payload = validPayload() + ;(payload.requestedSchema.properties as Record).bad = prop + expect(parseElicitationPayload(payload)).toEqual({ok: false, reason}) + } + }) + it("rejects secret-shaped fields by name and by title", () => { const byName = validPayload() ;(byName.requestedSchema.properties as Record).api_key = {type: "string"} diff --git a/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts b/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts index a1c3d4a62f..6d1312d6f7 100644 --- a/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts +++ b/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts @@ -123,4 +123,33 @@ describe("buildFormFieldsFromSchema — openEnums flag", () => { expect("allowCustomEnum" in byName.note).toBe(false) expect("allowCustomEnum" in byName.count).toBe(false) }) + + it("flag on: string-items arrays become multi-select; object-items arrays do not", () => { + const schema = { + type: "object", + properties: { + actions: { + type: "array", + items: {type: "string", enum: ["send", "list"]}, + default: ["send"], + }, + repos: {type: "array", items: {type: "string"}}, + rows: {type: "array", items: {type: "object", properties: {a: {type: "string"}}}}, + }, + } + const byName = Object.fromEntries( + buildFormFieldsFromSchema(schema, "", {openEnums: true}).map((f) => [f.name, f]), + ) + expect(byName.actions.multiple).toBe(true) + expect(byName.actions.allowCustomEnum).toBe(true) + expect(byName.actions.enumValues).toEqual(["send", "list"]) + expect(byName.actions.default).toEqual(["send"]) + expect(byName.repos.multiple).toBe(true) + expect(byName.repos.enumValues).toBeUndefined() + expect("multiple" in byName.rows).toBe(false) + + // Flag off (gateway forms): arrays keep the Form.List shape — no multiple key anywhere. + const off = buildFormFieldsFromSchema(schema) + expect(off.some((f) => "multiple" in f)).toBe(false) + }) }) From 0affc05400aa98b809a13977ac24ca4d95573736 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 03:40:47 +0200 Subject: [PATCH 07/13] feat(frontend): choice cards for context-ful elicitation options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare Select flattens options that need explaining ("merge to main" vs "GitHub releases" vs "release branches" each deserve a sentence). Adopt the standard JSON Schema idiom for labeled options — oneOf: [{const, title, description}] — and render options as selectable cards when any option carries a description; bare enums keep the Select. - contract: oneOf admitted on single fields AND array items (multi-pick cards designed in from the start); parse canonicalizes consts into enum so downstream never branches on shape; malformed options reject with a stable reason. - renderer: ChoiceCards (radio semantics; checkbox when multiple) with the same "Other…" escape hatch as the Selects — the last card reveals a text input; custom multi values render as removable tags. Options with titles but no descriptions upgrade Select labels instead. - gateway forms unaffected: oneOf is ignored without openEnums (flag-off regression test — no enumOptions key, no enum promotion). - request_input description documents the shape; the golden fixture's frequency field now uses oneOf with descriptions, pinned by the pytest golden check (which now requires the fixture to exercise oneOf). --- api/oss/src/core/workflows/static_catalog.py | 5 +- .../unit/workflows/test_static_catalog.py | 17 +- .../src/gatewayTool/components/SchemaForm.tsx | 207 ++++++++++++++++-- .../agenta-shared/src/utils/elicitation.ts | 40 +++- .../src/utils/gatewayToolSchema.ts | 37 +++- .../tests/fixtures/elicitation_request.json | 23 +- .../tests/unit/elicitation.test.ts | 41 ++++ .../tests/unit/gatewayToolSchema.test.ts | 36 +++ 8 files changed, 377 insertions(+), 29 deletions(-) diff --git a/api/oss/src/core/workflows/static_catalog.py b/api/oss/src/core/workflows/static_catalog.py index d8c591bddb..ed8018b048 100644 --- a/api/oss/src/core/workflows/static_catalog.py +++ b/api/oss/src/core/workflows/static_catalog.py @@ -166,7 +166,10 @@ def _request_input_revision() -> WorkflowRevision: "string/number/integer/boolean properties (enum, format, title and " "default allowed). For a multi-pick question use {type: 'array', " "items: {type: 'string', enum: [...]}} — the ONLY array shape " - "allowed; no nested objects or deeper arrays. When you can " + "allowed; no nested objects or deeper arrays. When options need " + "explaining, replace `enum` with oneOf: [{const, title, description}] " + "(works inside `items` too) — such options render as selectable cards " + "with the description under each title. When you can " "propose a sensible value, set it as the field's `default` (a " "primitive): it prefills the form so the user can accept everything " "in one click. Enum options are SUGGESTIONS, not a hard constraint — " diff --git a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py index 7cf42d65ca..1a1a97ed1f 100644 --- a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py +++ b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py @@ -940,27 +940,38 @@ def test_request_input_matches_golden_request_fixture(): assert payload_keys == set(tool["input_schema"]["properties"]) assert isinstance(golden["message"], str) and golden["message"] + def _assert_one_of(one_of, name): + # Context-ful options: [{const, title?, description?}] — consts must be strings. + assert isinstance(one_of, list) and one_of, name + for option in one_of: + assert isinstance(option["const"], str), name + requested = golden["requestedSchema"] assert requested["type"] == "object" for name, prop in requested["properties"].items(): if prop["type"] == "array": - # Multi-select: the ONE admitted array shape — string items, optional enum. + # Multi-select: the ONE admitted array shape — string items, optional enum/oneOf. items = prop["items"] assert items["type"] == "string", name assert "properties" not in items and "items" not in items, name + if "oneOf" in items: + _assert_one_of(items["oneOf"], name) if "default" in prop: assert isinstance(prop["default"], list), name assert all(isinstance(v, str) for v in prop["default"]), name continue assert prop["type"] in _ELICITATION_PRIMITIVES, name assert "properties" not in prop and "items" not in prop, name + if "oneOf" in prop: + _assert_one_of(prop["oneOf"], name) if "default" in prop: assert isinstance(prop["default"], (str, int, float, bool)), name assert set(requested.get("required", [])) <= set(requested["properties"]) - # The golden must exercise a prefilled field (defaults are part of the dialect, #5190) - # and a multi-select field (the one admitted array shape). + # The golden must exercise the dialect's optional shapes: a prefilled field (#5190), + # a multi-select array, and context-ful oneOf options (choice cards). assert any("default" in prop for prop in requested["properties"].values()) assert any(prop["type"] == "array" for prop in requested["properties"].values()) + assert any("oneOf" in prop for prop in requested["properties"].values()) def test_request_input_matches_golden_response_fixture(): diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index f430c551dc..cedccfda10 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -13,13 +13,16 @@ import {Editor} from "@agenta/ui/editor" import {MinusCircle, Plus} from "@phosphor-icons/react" import { Button, + Checkbox, Collapse, DatePicker, Form, Input, InputNumber, + Radio, Switch, Select, + Tag, Typography, } from "antd" import type {FormInstance} from "antd" @@ -251,6 +254,18 @@ function FieldLabel({field}: {field: FormFieldDescriptor}) { const OTHER_ENUM_OPTION = "__ag_enum_other__" +/** A renderable option: bare enum values get {value}, oneOf options add label/description. */ +interface EnumOption { + value: string + label?: string + description?: string +} + +const selectOptionsWithOther = (options: EnumOption[]) => [ + ...options.map((o) => ({value: o.value, label: o.label ?? o.value})), + {value: OTHER_ENUM_OPTION, label: "Other…"}, +] + /** Enum control with an "Other…" entry that reveals a free-text input (elicitation escape hatch). */ function EnumWithOther({ value, @@ -262,18 +277,19 @@ function EnumWithOther({ }: { value?: string onChange?: (v: string | undefined) => void - options: string[] + options: EnumOption[] placeholder?: string allowClear?: boolean disabled?: boolean }) { - const inOptions = value != null && options.includes(value) + const values = options.map((o) => o.value) + const inOptions = value != null && values.includes(value) const [otherMode, setOtherMode] = useState(value != null && !inOptions) // An off-options value can also arrive AFTER mount (schema `default` via Form initialValue, // or a replayed draft) — it must open Other-mode with the text prefilled. useEffect(() => { - if (value != null && !options.includes(value)) setOtherMode(true) - }, [value, options]) + if (value != null && !values.includes(value)) setOtherMode(true) + }, [value, values]) const selectValue = otherMode ? OTHER_ENUM_OPTION : inOptions ? value : undefined return ( @@ -292,10 +308,7 @@ function EnumWithOther({ onChange?.(next) } }} - options={[ - ...options.map((v) => ({value: v, label: v})), - {value: OTHER_ENUM_OPTION, label: "Other…"}, - ]} + options={selectOptionsWithOther(options)} /> {otherMode && ( void - options: string[] + options: EnumOption[] placeholder?: string disabled?: boolean }) { @@ -368,10 +381,7 @@ function MultiEnumWithOther({ } onChange?.(next.length ? next : undefined) }} - options={[ - ...options.map((v) => ({value: v, label: v})), - {value: OTHER_ENUM_OPTION, label: "Other…"}, - ]} + options={selectOptionsWithOther(options)} /> {otherDraft !== null && ( { + const metas = field.enumOptions ?? [] + const values = field.enumValues ?? metas.map((m) => m.value) + return values.map((v) => metas.find((m) => m.value === v) ?? {value: v}) +} + +/** Any option description upgrades the control from a Select to choice cards. */ +const wantsChoiceCards = (field: FormFieldDescriptor): boolean => + !!field.enumOptions?.some((o) => o.description) + +const choiceCardCls = (selected: boolean) => + `flex cursor-pointer items-start gap-2 rounded-lg border border-solid p-3 transition-colors ${ + selected + ? "border-colorPrimary bg-[var(--ant-color-primary-bg)]" + : "border-colorBorderSecondary hover:border-colorPrimary" + }` + +/** + * Context-ful options rendered as selectable cards (radio semantics; checkbox when `multiple`) — + * used when any option carries a description a bare Select would flatten. Includes the same + * "Other…" escape hatch as the Select controls: the last card reveals a free-text input. + */ +function ChoiceCards({ + value, + onChange, + options, + multiple, + disabled, +}: { + value?: string | string[] + onChange?: (v: string | string[] | undefined) => void + options: EnumOption[] + multiple?: boolean + disabled?: boolean +}) { + const [otherDraft, setOtherDraft] = useState(null) + const selected = multiple + ? ((value as string[] | undefined) ?? []) + : value != null + ? [value as string] + : [] + const optionValues = options.map((o) => o.value) + const customValues = selected.filter((v) => !optionValues.includes(v)) + const isChecked = (v: string) => selected.includes(v) + + const pick = (v: string) => { + if (disabled) return + if (multiple) { + const next = isChecked(v) ? selected.filter((x) => x !== v) : [...selected, v] + onChange?.(next.length ? next : undefined) + } else { + onChange?.(v) + } + } + const commitDraft = () => { + const custom = otherDraft?.trim() + setOtherDraft(null) + if (!custom) return + if (multiple) { + if (!isChecked(custom)) onChange?.([...selected, custom]) + } else { + onChange?.(custom) + } + } + const otherActive = otherDraft !== null || customValues.length > 0 + const Control = multiple ? Checkbox : Radio + + return ( +
+ {options.map((o) => ( +
pick(o.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + pick(o.value) + } + }} + className={choiceCardCls(isChecked(o.value))} + > + +
+ + {o.label ?? o.value} + + {o.description && ( + + {o.description} + + )} +
+
+ ))} +
{ + if (!disabled && otherDraft === null) setOtherDraft("") + }} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === " ") && otherDraft === null) { + e.preventDefault() + if (!disabled) setOtherDraft("") + } + }} + className={choiceCardCls(otherActive)} + > + +
+ Other… + {multiple && customValues.length > 0 && ( +
+ {customValues.map((v) => ( + { + e.preventDefault() + const next = selected.filter((x) => x !== v) + onChange?.(next.length ? next : undefined) + }} + > + {v} + + ))} +
+ )} + {!multiple && customValues.length > 0 && otherDraft === null && ( + + {customValues[0]} + + )} + {otherDraft !== null && ( + e.stopPropagation()} + onChange={(e) => setOtherDraft(e.target.value)} + onPressEnter={commitDraft} + onBlur={commitDraft} + /> + )} +
+
+
+ ) +} + function SchemaFormField({field, depth = 0}: {field: FormFieldDescriptor; depth?: number}) { const rules = field.required ? [{required: true, message: `${field.label} is required`}] : [] const label = @@ -456,7 +622,8 @@ function SchemaFormField({field, depth = 0}: {field: FormFieldDescriptor; depth? ) } - // Multi-select (elicitation, openEnums): string-items arrays render as a chip picker. + // Multi-select (elicitation, openEnums): string-items arrays render as a chip picker, + // upgraded to checkbox choice cards when the options carry descriptions. if (field.type === "array" && field.multiple) { return ( - + {wantsChoiceCards(field) ? ( + + ) : ( + + )} ) } @@ -508,9 +679,11 @@ function SchemaFormField({field, depth = 0}: {field: FormFieldDescriptor; depth? rules={rules} initialValue={field.default} > - {field.allowCustomEnum ? ( + {wantsChoiceCards(field) ? ( + + ) : field.allowCustomEnum ? ( diff --git a/web/packages/agenta-shared/src/utils/elicitation.ts b/web/packages/agenta-shared/src/utils/elicitation.ts index da6e23f8d4..a4b97c4790 100644 --- a/web/packages/agenta-shared/src/utils/elicitation.ts +++ b/web/packages/agenta-shared/src/utils/elicitation.ts @@ -48,17 +48,30 @@ export function normalizeStringFormat(format: unknown): string | undefined { return KNOWN_STRING_FORMATS.has(canonical) ? canonical : undefined } +/** + * A context-ful option (standard JSON Schema `oneOf` + `const` idiom): `title`/`description` + * give the option a card-worthy explanation. Parse canonicalizes the consts into `enum`, so + * downstream consumers never branch on which shape the author used. + */ +export interface ElicitationOptionSchema { + const: string + title?: string + description?: string +} + export interface ElicitationFieldSchema { type: "string" | "number" | "integer" | "boolean" | "array" title?: string description?: string enum?: string[] + /** Context-ful options; when descriptions are present the renderer shows choice cards. */ + oneOf?: ElicitationOptionSchema[] format?: string /** * Multi-select (`type: "array"` only): the ONE array shape the dialect admits — string items, - * optionally constrained by an enum (the multi-pick options). Nothing deeper. + * optionally constrained by an enum or context-ful `oneOf` options. Nothing deeper. */ - items?: {type: "string"; enum?: string[]} + items?: {type: "string"; enum?: string[]; oneOf?: ElicitationOptionSchema[]} /** Proposed value prefilling the field, so the user can accept the whole form in one click. */ default?: string | number | boolean | string[] minimum?: number @@ -102,6 +115,17 @@ const isRecord = (value: unknown): value is Record => const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((v) => typeof v === "string") +const isValidOneOf = (value: unknown): value is ElicitationOptionSchema[] => + Array.isArray(value) && + value.length > 0 && + value.every( + (o) => + isRecord(o) && + typeof o.const === "string" && + (o.title === undefined || typeof o.title === "string") && + (o.description === undefined || typeof o.description === "string"), + ) + /** * Validate an incoming client-tool input as an elicitation payload (tolerant reader: unknown * extra keys are ignored; the hard rules below are the dialect). Failure reasons are stable @@ -138,6 +162,8 @@ export function parseElicitationPayload(input: unknown): ElicitationParseResult return {ok: false, reason: `property "${name}" is nested — flat dialect only`} if (items.enum !== undefined && !isStringArray(items.enum)) return {ok: false, reason: `property "${name}" items enum must be strings`} + if (items.oneOf !== undefined && !isValidOneOf(items.oneOf)) + return {ok: false, reason: `property "${name}" oneOf options need a string const`} if (prop.default !== undefined && !isStringArray(prop.default)) return { ok: false, @@ -148,6 +174,8 @@ export function parseElicitationPayload(input: unknown): ElicitationParseResult return {ok: false, reason: `property "${name}" is nested — flat dialect only`} if (prop.enum !== undefined && !isStringArray(prop.enum)) return {ok: false, reason: `property "${name}" enum must be strings`} + if (prop.oneOf !== undefined && !isValidOneOf(prop.oneOf)) + return {ok: false, reason: `property "${name}" oneOf options need a string const`} if ( prop.default !== undefined && !["string", "number", "boolean"].includes(typeof prop.default) @@ -168,14 +196,18 @@ export function parseElicitationPayload(input: unknown): ElicitationParseResult if (unknown) return {ok: false, reason: `required field "${unknown}" is not a property`} } - // Canonicalize format hints once at the boundary so the renderer and the serializer never - // diverge (aliases like "datetime" → "date-time"); unknown formats are dropped. + // Canonicalize once at the boundary so the renderer and the serializer never diverge: + // format aliases → canonical ("datetime" → "date-time", unknown dropped), and oneOf consts + // → enum (downstream consumers key on enum; oneOf stays for the option titles/descriptions). const properties = Object.fromEntries( Object.entries(requestedSchema.properties).map(([name, prop]) => { const field = {...(prop as ElicitationFieldSchema)} const canonical = normalizeStringFormat(field.format) if (canonical) field.format = canonical else delete field.format + if (field.oneOf) field.enum = field.oneOf.map((o) => o.const) + if (field.items?.oneOf) + field.items = {...field.items, enum: field.items.oneOf.map((o) => o.const)} return [name, field] }), ) as Record diff --git a/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts b/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts index 0703863077..7412cdb1d8 100644 --- a/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts +++ b/web/packages/agenta-shared/src/utils/gatewayToolSchema.ts @@ -14,6 +14,9 @@ export interface FormFieldDescriptor { allowCustomEnum?: boolean /** Multi-select (string-items array) — set only under `{openEnums: true}`; options ride enumValues. */ multiple?: boolean + /** Context-ful options (JSON Schema oneOf+const) — set only under `{openEnums: true}`; a + * description on any option upgrades the control to choice cards. */ + enumOptions?: {value: string; label?: string; description?: string}[] children?: FormFieldDescriptor[] // nested fields for object type /** For arrays: schema of each item (JSON Schema) */ itemSchema?: Record @@ -30,6 +33,24 @@ export interface BuildFormFieldsOptions { openEnums?: boolean } +/** Tolerant read of JSON Schema `oneOf` const-options into renderer option descriptors. */ +function toEnumOptions( + raw: unknown, +): {value: string; label?: string; description?: string}[] | undefined { + if (!Array.isArray(raw)) return undefined + const options = raw + .filter( + (o): o is Record => + !!o && typeof o === "object" && typeof (o as {const?: unknown}).const === "string", + ) + .map((o) => ({ + value: o.const as string, + ...(typeof o.title === "string" ? {label: o.title} : {}), + ...(typeof o.description === "string" ? {description: o.description} : {}), + })) + return options.length ? options : undefined +} + /** * Convert a JSON Schema `properties` object into a list of form field descriptors. * Objects with their own `properties` are expanded into nested children. @@ -51,7 +72,7 @@ export function buildFormFieldsFromSchema( const propType = (prop.type as string) ?? "string" let fieldType: FormFieldDescriptor["type"] = "string" - if (prop.enum) { + if (prop.enum || (opts?.openEnums && propType !== "array" && Array.isArray(prop.oneOf))) { fieldType = "enum" } else if (propType === "integer" || propType === "number") { fieldType = "number" @@ -115,6 +136,11 @@ export function buildFormFieldsFromSchema( fieldType === "array" && (prop.items as Record | undefined)?.type === "string" + // Opt-in (elicitation): context-ful oneOf options for the choice-card/labeled rendering. + const enumOptions = opts?.openEnums + ? toEnumOptions(multiple ? (prop.items as Record).oneOf : prop.oneOf) + : undefined + return { name: fullName, label: (prop.title as string) ?? name, @@ -122,16 +148,21 @@ export function buildFormFieldsFromSchema( required: requiredSet.has(name), description: prop.description as string | undefined, default: prop.default, - enumValues: prop.enum as string[] | undefined, + enumValues: + (prop.enum as string[] | undefined) ?? + (fieldType === "enum" ? enumOptions?.map((o) => o.value) : undefined), ...(format !== undefined ? {format} : {}), ...(opts?.openEnums && fieldType === "enum" ? {allowCustomEnum: true} : {}), ...(multiple ? { multiple: true, allowCustomEnum: true, - enumValues: (prop.items as {enum?: string[]}).enum, + enumValues: + (prop.items as {enum?: string[]}).enum ?? + enumOptions?.map((o) => o.value), } : {}), + ...(enumOptions ? {enumOptions} : {}), children, itemSchema, itemChildren, diff --git a/web/packages/agenta-shared/tests/fixtures/elicitation_request.json b/web/packages/agenta-shared/tests/fixtures/elicitation_request.json index 047bb6089b..3da3011128 100644 --- a/web/packages/agenta-shared/tests/fixtures/elicitation_request.json +++ b/web/packages/agenta-shared/tests/fixtures/elicitation_request.json @@ -7,7 +7,28 @@ "frequency": { "type": "string", "title": "How often", - "enum": ["hourly", "daily", "weekdays", "weekly"] + "oneOf": [ + { + "const": "hourly", + "title": "Hourly", + "description": "Runs every hour, on the hour." + }, + { + "const": "daily", + "title": "Daily", + "description": "Runs once a day at the chosen time." + }, + { + "const": "weekdays", + "title": "Weekdays", + "description": "Monday through Friday only." + }, + { + "const": "weekly", + "title": "Weekly", + "description": "Runs once a week at the chosen time." + } + ] }, "time_of_day": { "type": "string", diff --git a/web/packages/agenta-shared/tests/unit/elicitation.test.ts b/web/packages/agenta-shared/tests/unit/elicitation.test.ts index e4c8bd04df..460037d720 100644 --- a/web/packages/agenta-shared/tests/unit/elicitation.test.ts +++ b/web/packages/agenta-shared/tests/unit/elicitation.test.ts @@ -202,6 +202,47 @@ describe("parseElicitationPayload", () => { expect(result.payload.requestedSchema.properties.actions.default).toEqual(["send"]) }) + it("accepts oneOf options and canonicalizes their consts into enum (single + items)", () => { + const payload = validPayload() + const props = payload.requestedSchema.properties as Record + props.process = { + type: "string", + oneOf: [ + {const: "merge_main", title: "Merge to main", description: "Daily check"}, + {const: "gh_releases", title: "GitHub releases"}, + ], + } + props.channels = { + type: "array", + items: {type: "string", oneOf: [{const: "slack"}, {const: "email"}]}, + } + const result = parseElicitationPayload(payload) + expect(result.ok).toBe(true) + if (!result.ok) return + const parsed = result.payload.requestedSchema.properties + expect(parsed.process.enum).toEqual(["merge_main", "gh_releases"]) + expect(parsed.process.oneOf?.[0]?.description).toBe("Daily check") + expect(parsed.channels.items?.enum).toEqual(["slack", "email"]) + }) + + it("rejects malformed oneOf options", () => { + const payload = validPayload() + ;(payload.requestedSchema.properties as Record).process = { + type: "string", + oneOf: [{title: "No const"}], + } + expect(parseElicitationPayload(payload)).toEqual({ + ok: false, + reason: 'property "process" oneOf options need a string const', + }) + const badItems = validPayload() + ;(badItems.requestedSchema.properties as Record).channels = { + type: "array", + items: {type: "string", oneOf: [{const: 1}]}, + } + expect(parseElicitationPayload(badItems).ok).toBe(false) + }) + it("rejects array fields beyond the multi-select shape", () => { const cases: [Record, string][] = [ [{type: "array"}, 'property "bad" array items must be strings'], diff --git a/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts b/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts index 6d1312d6f7..069f04849d 100644 --- a/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts +++ b/web/packages/agenta-shared/tests/unit/gatewayToolSchema.test.ts @@ -152,4 +152,40 @@ describe("buildFormFieldsFromSchema — openEnums flag", () => { const off = buildFormFieldsFromSchema(schema) expect(off.some((f) => "multiple" in f)).toBe(false) }) + + it("flag on: oneOf options surface as enumOptions (single + multi), enum derived from consts", () => { + const schema = { + type: "object", + properties: { + process: { + type: "string", + oneOf: [ + {const: "merge_main", title: "Merge to main", description: "Daily check"}, + {const: "gh_releases", title: "GitHub releases"}, + ], + }, + channels: { + type: "array", + items: {type: "string", oneOf: [{const: "slack", title: "Slack"}]}, + }, + }, + } + const byName = Object.fromEntries( + buildFormFieldsFromSchema(schema, "", {openEnums: true}).map((f) => [f.name, f]), + ) + expect(byName.process.type).toBe("enum") + expect(byName.process.enumValues).toEqual(["merge_main", "gh_releases"]) + expect(byName.process.enumOptions).toEqual([ + {value: "merge_main", label: "Merge to main", description: "Daily check"}, + {value: "gh_releases", label: "GitHub releases"}, + ]) + expect(byName.channels.multiple).toBe(true) + expect(byName.channels.enumValues).toEqual(["slack"]) + expect(byName.channels.enumOptions).toEqual([{value: "slack", label: "Slack"}]) + + // Flag off (gateway forms): oneOf is ignored — no enumOptions key, no enum promotion. + const off = buildFormFieldsFromSchema(schema) + expect(off.some((f) => "enumOptions" in f)).toBe(false) + expect(off.find((f) => f.name === "process")?.type).toBe("string") + }) }) From d665f127de175bda26556d6aa6081bc688f36eee Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 04:08:30 +0200 Subject: [PATCH 08/13] test(frontend): extract and pin the enum controls' decision logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three elicitation enum controls (EnumWithOther, MultiEnumWithOther, ChoiceCards) carried untested state machines — Other-mode transitions, custom-chip commit/dedupe, single-vs-multi toggle semantics. Extract the pure logic into schemaFormOptions.ts (the getEnumOptions pattern: pure exported helper behind a control, node-vitest tested) and consume it from the components, so tests and render share one implementation. 21 tests pin the transitions, including the load-bearing invariant that the __ag_enum_other__ sentinel never survives into the form value — it would otherwise leak into the accepted elicitation content the agent consumes — and that empty selections normalize to undefined so the antd required rule keeps firing. --- .../src/gatewayTool/components/SchemaForm.tsx | 80 +++------ .../components/schemaFormOptions.ts | 89 ++++++++++ .../tests/unit/schemaFormOptions.test.ts | 157 ++++++++++++++++++ 3 files changed, 273 insertions(+), 53 deletions(-) create mode 100644 web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormOptions.ts create mode 100644 web/packages/agenta-entity-ui/tests/unit/schemaFormOptions.test.ts diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index cedccfda10..bf1c75702c 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -27,6 +27,19 @@ import { } from "antd" import type {FormInstance} from "antd" +import { + OTHER_ENUM_OPTION, + commitCustomValue, + enumOptionsOf, + isOffOptionsValue, + partitionCustomValues, + selectOptionsWithOther, + splitOtherFromSelection, + toggleCardSelection, + wantsChoiceCards, + type EnumOption, +} from "./schemaFormOptions" + export interface SchemaFormHandle { getValues: () => Promise> } @@ -252,20 +265,6 @@ function FieldLabel({field}: {field: FormFieldDescriptor}) { ) } -const OTHER_ENUM_OPTION = "__ag_enum_other__" - -/** A renderable option: bare enum values get {value}, oneOf options add label/description. */ -interface EnumOption { - value: string - label?: string - description?: string -} - -const selectOptionsWithOther = (options: EnumOption[]) => [ - ...options.map((o) => ({value: o.value, label: o.label ?? o.value})), - {value: OTHER_ENUM_OPTION, label: "Other…"}, -] - /** Enum control with an "Other…" entry that reveals a free-text input (elicitation escape hatch). */ function EnumWithOther({ value, @@ -282,15 +281,14 @@ function EnumWithOther({ allowClear?: boolean disabled?: boolean }) { - const values = options.map((o) => o.value) - const inOptions = value != null && values.includes(value) - const [otherMode, setOtherMode] = useState(value != null && !inOptions) + const offOptions = isOffOptionsValue(value, options) + const [otherMode, setOtherMode] = useState(offOptions) // An off-options value can also arrive AFTER mount (schema `default` via Form initialValue, // or a replayed draft) — it must open Other-mode with the text prefilled. useEffect(() => { - if (value != null && !values.includes(value)) setOtherMode(true) - }, [value, values]) - const selectValue = otherMode ? OTHER_ENUM_OPTION : inOptions ? value : undefined + if (isOffOptionsValue(value, options)) setOtherMode(true) + }, [value, options]) + const selectValue = otherMode ? OTHER_ENUM_OPTION : offOptions ? undefined : value return (
@@ -362,9 +360,9 @@ function MultiEnumWithOther({ } const commitDraft = () => { - const custom = otherDraft?.trim() + const commit = commitCustomValue(selected, otherDraft, true) setOtherDraft(null) - if (custom && !selected.includes(custom)) onChange?.([...selected, custom]) + if (commit.changed) onChange?.(commit.value as string[]) } return ( @@ -375,11 +373,9 @@ function MultiEnumWithOther({ disabled={disabled} value={selected} onChange={(next: string[]) => { - if (next.includes(OTHER_ENUM_OPTION)) { - setOtherDraft("") - next = next.filter((v) => v !== OTHER_ENUM_OPTION) - } - onChange?.(next.length ? next : undefined) + const {values, openOther} = splitOtherFromSelection(next) + if (openOther) setOtherDraft("") + onChange?.(values) }} options={selectOptionsWithOther(options)} /> @@ -398,17 +394,6 @@ function MultiEnumWithOther({ ) } -/** Merge enumValues with oneOf option metadata into the renderable option list. */ -const enumOptionsOf = (field: FormFieldDescriptor): EnumOption[] => { - const metas = field.enumOptions ?? [] - const values = field.enumValues ?? metas.map((m) => m.value) - return values.map((v) => metas.find((m) => m.value === v) ?? {value: v}) -} - -/** Any option description upgrades the control from a Select to choice cards. */ -const wantsChoiceCards = (field: FormFieldDescriptor): boolean => - !!field.enumOptions?.some((o) => o.description) - const choiceCardCls = (selected: boolean) => `flex cursor-pointer items-start gap-2 rounded-lg border border-solid p-3 transition-colors ${ selected @@ -440,28 +425,17 @@ function ChoiceCards({ : value != null ? [value as string] : [] - const optionValues = options.map((o) => o.value) - const customValues = selected.filter((v) => !optionValues.includes(v)) + const customValues = partitionCustomValues(selected, options) const isChecked = (v: string) => selected.includes(v) const pick = (v: string) => { if (disabled) return - if (multiple) { - const next = isChecked(v) ? selected.filter((x) => x !== v) : [...selected, v] - onChange?.(next.length ? next : undefined) - } else { - onChange?.(v) - } + onChange?.(toggleCardSelection(selected, v, !!multiple)) } const commitDraft = () => { - const custom = otherDraft?.trim() + const commit = commitCustomValue(selected, otherDraft, !!multiple) setOtherDraft(null) - if (!custom) return - if (multiple) { - if (!isChecked(custom)) onChange?.([...selected, custom]) - } else { - onChange?.(custom) - } + if (commit.changed) onChange?.(commit.value) } const otherActive = otherDraft !== null || customValues.length > 0 const Control = multiple ? Checkbox : Radio diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormOptions.ts b/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormOptions.ts new file mode 100644 index 0000000000..7f554c1559 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormOptions.ts @@ -0,0 +1,89 @@ +/** + * Pure decision logic behind SchemaForm's enum controls (EnumWithOther, MultiEnumWithOther, + * ChoiceCards) — extracted so the state transitions are unit-testable under the package's + * node-environment vitest (same pattern as DrillInView's getEnumOptions). + * + * Load-bearing invariant: OTHER_ENUM_OPTION is a UI-only sentinel. It may appear as a Select + * option value, but must NEVER survive into the form value (and from there into the accepted + * elicitation `content`). Every transition here strips it. + */ +import type {FormFieldDescriptor} from "@agenta/shared/utils" + +export const OTHER_ENUM_OPTION = "__ag_enum_other__" + +/** A renderable option: bare enum values get {value}, oneOf options add label/description. */ +export interface EnumOption { + value: string + label?: string + description?: string +} + +/** Merge enumValues with oneOf option metadata into the renderable option list. */ +export const enumOptionsOf = (field: FormFieldDescriptor): EnumOption[] => { + const metas = field.enumOptions ?? [] + const values = field.enumValues ?? metas.map((m) => m.value) + return values.map((v) => metas.find((m) => m.value === v) ?? {value: v}) +} + +/** Any option description upgrades the control from a Select to choice cards. */ +export const wantsChoiceCards = (field: FormFieldDescriptor): boolean => + !!field.enumOptions?.some((o) => o.description) + +/** Select options with the trailing "Other…" escape-hatch entry. */ +export const selectOptionsWithOther = (options: EnumOption[]) => [ + ...options.map((o) => ({value: o.value, label: o.label ?? o.value})), + {value: OTHER_ENUM_OPTION, label: "Other…"}, +] + +/** True when the current value is set but not one of the options (default/replay off-menu). */ +export const isOffOptionsValue = (value: string | null | undefined, options: EnumOption[]) => + value != null && !options.some((o) => o.value === value) + +/** + * Multi-select Select onChange: strip the "Other…" sentinel (it opens the draft input, it is + * not a value) and normalize empty → undefined so antd's required rule fires. + */ +export const splitOtherFromSelection = ( + next: string[], +): {values: string[] | undefined; openOther: boolean} => { + const openOther = next.includes(OTHER_ENUM_OPTION) + const values = next.filter((v) => v !== OTHER_ENUM_OPTION) + return {values: values.length ? values : undefined, openOther} +} + +/** Toggle a card: single-select replaces; multi toggles membership; empty → undefined. */ +export const toggleCardSelection = ( + selected: string[], + value: string, + multiple: boolean, +): string | string[] | undefined => { + if (!multiple) return value + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + return next.length ? next : undefined +} + +/** + * Commit an "Other…" draft: trim, drop empties and the sentinel itself, dedupe against the + * current selection. Returns the unchanged selection when there is nothing to add. + */ +export const commitCustomValue = ( + selected: string[], + draft: string | null | undefined, + multiple: boolean, +): {changed: boolean; value: string | string[] | undefined} => { + const custom = draft?.trim() + if (!custom || custom === OTHER_ENUM_OPTION) + return { + changed: false, + value: multiple ? (selected.length ? selected : undefined) : undefined, + } + if (!multiple) return {changed: true, value: custom} + if (selected.includes(custom)) return {changed: false, value: selected} + return {changed: true, value: [...selected, custom]} +} + +/** The selection's off-options entries (custom values), preserving selection order. */ +export const partitionCustomValues = (selected: string[], options: EnumOption[]): string[] => + selected.filter((v) => !options.some((o) => o.value === v)) diff --git a/web/packages/agenta-entity-ui/tests/unit/schemaFormOptions.test.ts b/web/packages/agenta-entity-ui/tests/unit/schemaFormOptions.test.ts new file mode 100644 index 0000000000..518b2b0732 --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/schemaFormOptions.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for the pure decision logic behind SchemaForm's enum controls (EnumWithOther, + * MultiEnumWithOther, ChoiceCards) — the state transitions dogfooding is most likely to hit: + * Other-mode opening for off-options defaults/replays, custom-chip commit/dedupe, single vs + * multi toggle semantics, and the load-bearing invariant that the OTHER_ENUM_OPTION sentinel + * never survives into the form value (it would otherwise leak into the accepted elicitation + * `content` the agent consumes). + */ +import {describe, expect, it} from "vitest" + +import type {FormFieldDescriptor} from "@agenta/shared/utils" + +import { + OTHER_ENUM_OPTION, + commitCustomValue, + enumOptionsOf, + isOffOptionsValue, + partitionCustomValues, + selectOptionsWithOther, + splitOtherFromSelection, + toggleCardSelection, + wantsChoiceCards, +} from "../../src/gatewayTool/components/schemaFormOptions" + +const field = (overrides: Partial): FormFieldDescriptor => ({ + name: "f", + label: "F", + type: "enum", + required: false, + freeform: false, + ...overrides, +}) + +describe("enumOptionsOf", () => { + it("merges enumValues with oneOf metadata, keeping enumValues order", () => { + const f = field({ + enumValues: ["b", "a"], + enumOptions: [{value: "a", label: "A", description: "first"}], + }) + expect(enumOptionsOf(f)).toEqual([ + {value: "b"}, + {value: "a", label: "A", description: "first"}, + ]) + }) + + it("falls back to option values when enumValues is absent", () => { + const f = field({enumOptions: [{value: "x", label: "X"}]}) + expect(enumOptionsOf(f)).toEqual([{value: "x", label: "X"}]) + }) + + it("bare enums produce bare options", () => { + expect(enumOptionsOf(field({enumValues: ["low", "high"]}))).toEqual([ + {value: "low"}, + {value: "high"}, + ]) + }) +}) + +describe("wantsChoiceCards", () => { + it("upgrades only when an option carries a description", () => { + expect(wantsChoiceCards(field({enumOptions: [{value: "a", description: "d"}]}))).toBe(true) + expect(wantsChoiceCards(field({enumOptions: [{value: "a", label: "A"}]}))).toBe(false) + expect(wantsChoiceCards(field({enumValues: ["a"]}))).toBe(false) + }) +}) + +describe("selectOptionsWithOther", () => { + it("labels fall back to the value and the Other… entry is appended last", () => { + expect(selectOptionsWithOther([{value: "a"}, {value: "b", label: "B"}])).toEqual([ + {value: "a", label: "a"}, + {value: "b", label: "B"}, + {value: OTHER_ENUM_OPTION, label: "Other…"}, + ]) + }) +}) + +describe("isOffOptionsValue (Other-mode trigger for defaults/replays)", () => { + const options = [{value: "red"}, {value: "green"}] + + it("true for a set value outside the options (a custom default or replayed draft)", () => { + expect(isOffOptionsValue("linear", options)).toBe(true) + }) + + it("false for empty values and for listed options", () => { + expect(isOffOptionsValue(undefined, options)).toBe(false) + expect(isOffOptionsValue(null, options)).toBe(false) + expect(isOffOptionsValue("red", options)).toBe(false) + }) +}) + +describe("splitOtherFromSelection (multi Select onChange)", () => { + it("strips the sentinel and signals the draft to open", () => { + expect(splitOtherFromSelection(["a", OTHER_ENUM_OPTION])).toEqual({ + values: ["a"], + openOther: true, + }) + }) + + it("normalizes empty to undefined so the antd required rule fires", () => { + expect(splitOtherFromSelection([OTHER_ENUM_OPTION])).toEqual({ + values: undefined, + openOther: true, + }) + expect(splitOtherFromSelection([])).toEqual({values: undefined, openOther: false}) + }) + + it("INVARIANT: the sentinel never survives into the value", () => { + const {values} = splitOtherFromSelection([OTHER_ENUM_OPTION, "a", OTHER_ENUM_OPTION]) + expect(values).toEqual(["a"]) + }) +}) + +describe("toggleCardSelection", () => { + it("single-select replaces the value", () => { + expect(toggleCardSelection(["a"], "b", false)).toBe("b") + expect(toggleCardSelection([], "a", false)).toBe("a") + }) + + it("multi toggles membership and empties to undefined", () => { + expect(toggleCardSelection(["a"], "b", true)).toEqual(["a", "b"]) + expect(toggleCardSelection(["a", "b"], "b", true)).toEqual(["a"]) + expect(toggleCardSelection(["a"], "a", true)).toBeUndefined() + }) +}) + +describe("commitCustomValue (Other… draft commit)", () => { + it("single: a trimmed draft becomes the value", () => { + expect(commitCustomValue([], " linear ", false)).toEqual({ + changed: true, + value: "linear", + }) + }) + + it("multi: appends and dedupes against the selection", () => { + expect(commitCustomValue(["a"], "b", true)).toEqual({changed: true, value: ["a", "b"]}) + expect(commitCustomValue(["a"], "a", true)).toEqual({changed: false, value: ["a"]}) + }) + + it("empty or whitespace drafts change nothing", () => { + expect(commitCustomValue(["a"], " ", true).changed).toBe(false) + expect(commitCustomValue([], null, false).changed).toBe(false) + expect(commitCustomValue([], undefined, true).changed).toBe(false) + }) + + it("INVARIANT: typing the sentinel itself never becomes a value", () => { + expect(commitCustomValue([], OTHER_ENUM_OPTION, false).changed).toBe(false) + expect(commitCustomValue(["a"], OTHER_ENUM_OPTION, true).changed).toBe(false) + }) +}) + +describe("partitionCustomValues", () => { + it("returns off-options entries in selection order", () => { + const options = [{value: "a"}, {value: "b"}] + expect(partitionCustomValues(["x", "a", "y"], options)).toEqual(["x", "y"]) + expect(partitionCustomValues(["a", "b"], options)).toEqual([]) + }) +}) From b8e01fb4a13551eaa087a45daa975ca96bc05fc4 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 11:45:15 +0200 Subject: [PATCH 09/13] test(frontend): one-click-accept E2E spec + dialect decision record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layer-A spec 5: the agent-templates headline — a full-dialect payload (defaults + multi-select + choice cards) where Accept with zero edits must resume carrying exactly the proposed values; also pins the card upgrade (option titles/descriptions visible, not a Select) and that the __ag_enum_other__ sentinel never leaks into the resume body. - decisions.md: record the three dialect extensions (default / multi- select array / oneOf options) with the standard-JSON-Schema-over- bespoke-hints rationale and the enums-are-suggestions rendering ruling, so the design record matches the shipped contract. --- .../agent-chat-interaction-kinds/decisions.md | 27 +++++++++ .../agent-chat/assets/elicitationStream.ts | 52 +++++++++++++++- .../playwright/acceptance/agent-chat/index.ts | 59 ++++++++++++++++++- 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/docs/design/agent-chat-interaction-kinds/decisions.md b/docs/design/agent-chat-interaction-kinds/decisions.md index 83a4b9309f..01e5c02b1a 100644 --- a/docs/design/agent-chat-interaction-kinds/decisions.md +++ b/docs/design/agent-chat-interaction-kinds/decisions.md @@ -31,6 +31,33 @@ so they travel with the code. surface with a retry cap. One card chrome; state carried by status line (`{state} · {next}` pill vocabulary, always neutral). +## Elicitation dialect extensions (2026-07-10, PR #5177) + +The M1 dialect ("top-level primitives/enums only") gained three extensions once template +questionnaires (Mahmoud's agent-templates work) hit its limits. The governing principle for +all three: **adopt the standard JSON Schema idiom over bespoke hints** — the payload author +is an LLM, and it already knows these shapes from training data. + +- **`default`** (issue #5190): a proposed value prefilling the field, so the user can accept + the whole form in one click. Primitives on scalar fields; array-of-strings on multi-select. + Date/date-time fields ignore it (a wire default is an ISO string; antd DatePicker requires + dayjs — render-side guard, not a contract rule). +- **Multi-select**: exactly one array shape is admitted — `{type: "array", items: {type: + "string", enum?/oneOf?}}`. String leaves only, nothing deeper; the answer is an array of + strings. Renders as a chip picker (checkbox choice cards when options carry descriptions). +- **Context-ful options**: `oneOf: [{const, title, description}]` on single fields and array + items. Any option description upgrades the control from a Select to selectable choice + cards; titles alone upgrade Select labels. Parse canonicalizes consts into `enum`, so + downstream consumers never branch on the authored shape. + +Rendering ruling that shipped with these: **enum options are SUGGESTIONS, not a hard +constraint** — the consumer of the answer is the agent, which handles off-menu values fine. +Every enum control therefore carries an explicit "Other…" escape hatch (free-text). This is +elicitation-only (`openEnums`/`formats` opt-in flags on the shared form builder); gateway +tool execution forms keep strict enums and `Form.List` arrays, because their schemas are +real API parameters. The `__ag_enum_other__` UI sentinel must never reach the settled +`content` — pinned by unit tests on both the control logic and the E2E resume body. + ## Model-composed UI: deliberately OUT OF SCOPE, with a re-evaluation trigger Evaluated and rejected for now (2026-07-04): letting the model freely compose dashboards / diff --git a/web/oss/tests/playwright/acceptance/agent-chat/assets/elicitationStream.ts b/web/oss/tests/playwright/acceptance/agent-chat/assets/elicitationStream.ts index 726ad381c4..4fe19aaed5 100644 --- a/web/oss/tests/playwright/acceptance/agent-chat/assets/elicitationStream.ts +++ b/web/oss/tests/playwright/acceptance/agent-chat/assets/elicitationStream.ts @@ -18,10 +18,16 @@ /** The flat elicitation payload the mocked `request_input` call carries (drives the form). */ export interface ElicitationFieldFixture { - type: "string" | "number" | "integer" | "boolean" + type: "string" | "number" | "integer" | "boolean" | "array" title?: string enum?: string[] + /** Context-ful options (oneOf+const) — descriptions upgrade the control to choice cards. */ + oneOf?: {const: string; title?: string; description?: string}[] + /** Multi-select: the one admitted array shape (string items, optional enum/oneOf). */ + items?: {type: "string"; enum?: string[]; oneOf?: {const: string; title?: string}[]} format?: string + /** Proposed value prefilling the field (one-click accept). */ + default?: string | number | boolean | string[] } export interface ElicitationPayloadFixture { @@ -47,6 +53,50 @@ export const ELICITATION_PAYLOAD: ElicitationPayloadFixture = { }, } +/** + * The full-dialect payload (defaults + multi-select + choice cards) for the one-click-accept + * spec: every field carries a proposed default, so Accept with no edits must resume with + * exactly these values. The `release_process` oneOf descriptions upgrade it to choice cards. + */ +export const RICH_ELICITATION_PAYLOAD: ElicitationPayloadFixture = { + message: "Confirm the setup — I proposed sensible defaults.", + requestedSchema: { + type: "object", + properties: { + release_process: { + type: "string", + title: "Release process", + oneOf: [ + { + const: "merge_main", + title: "Merge to main", + description: "A daily trigger checks merged PRs.", + }, + { + const: "gh_releases", + title: "GitHub releases", + description: "Runs when a release is published.", + }, + ], + default: "gh_releases", + }, + notify_on: { + type: "array", + title: "Notify on", + items: {type: "string", enum: ["success", "failure", "skipped"]}, + default: ["failure"], + }, + task_manager: { + type: "string", + title: "Task management system", + enum: ["todoist", "notion", "asana"], + default: "notion", + }, + }, + required: ["release_process"], + }, +} + /** The reserved static-catalog client-tool name the platform emits for elicitation. */ export const REQUEST_INPUT_TOOL_NAME = "__ag__request_input" diff --git a/web/oss/tests/playwright/acceptance/agent-chat/index.ts b/web/oss/tests/playwright/acceptance/agent-chat/index.ts index d7ad36c6a0..daf1b168d1 100644 --- a/web/oss/tests/playwright/acceptance/agent-chat/index.ts +++ b/web/oss/tests/playwright/acceptance/agent-chat/index.ts @@ -15,7 +15,7 @@ import {expectAuthenticatedSession} from "../utils/auth" import {createScenarios} from "../utils/scenarios" import {buildAcceptanceTags} from "../utils/tags" -import {ELICITATION_PAYLOAD} from "./assets/elicitationStream" +import {ELICITATION_PAYLOAD, RICH_ELICITATION_PAYLOAD} from "./assets/elicitationStream" import {test as baseAgentChatTest} from "./tests" const scenarios = createScenarios(baseAgentChatTest) @@ -250,6 +250,63 @@ const agentChatTests = () => { }) }, ) + + // ── Spec 5: one-click accept — defaults + multi-select + choice cards (full dialect) ───────── + // The agent-templates headline (#5190): every field ships a proposed default, so Accept with + // ZERO edits must resume carrying exactly those values. Also pins the card upgrade: oneOf + // descriptions render as choice cards (option titles + descriptions visible), not a Select. + baseAgentChatTest( + "Elicitation one-click accept: proposed defaults ride the resume unchanged", + {tag: elicitationTags}, + async ({ + page, + seedAgentChatApp, + navigateToAgentPlayground, + mockElicitationInvoke, + sendChatMessage, + }) => { + baseAgentChatTest.setTimeout(120000) + let mock!: Awaited> + + await scenarios.given("the user is authenticated", async () => { + await expectAuthenticatedSession(page) + }) + + await scenarios.and("a full-dialect elicitation run is open", async () => { + const appId = await seedAgentChatApp() + await navigateToAgentPlayground(appId) + mock = await mockElicitationInvoke(RICH_ELICITATION_PAYLOAD) + mock.setResumeText("Set up: GitHub releases · notify on failure · notion") + await sendChatMessage("set it up") + }) + + await scenarios.then("options with descriptions render as choice cards", async () => { + await expect(page.getByText(RICH_ELICITATION_PAYLOAD.message)).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByText("GitHub releases")).toBeVisible() + await expect(page.getByText("Runs when a release is published.")).toBeVisible() + await expect(page.getByText("Merge to main")).toBeVisible() + }) + + await scenarios.when("the user accepts without editing anything", async () => { + await page.getByRole("button", {name: "Accept", exact: true}).click() + }) + + await scenarios.then("the resume carries exactly the proposed defaults", async () => { + await expect(page.getByText("Provided the requested input.")).toBeVisible({ + timeout: 30000, + }) + expect(mock.calls.length).toBeGreaterThanOrEqual(2) + const resumeBody = JSON.stringify(mock.calls[1] ?? {}) + expect(resumeBody).toContain("gh_releases") + expect(resumeBody).toContain("failure") + expect(resumeBody).toContain("notion") + // The Other… UI sentinel must never leak into the accepted content. + expect(resumeBody).not.toContain("__ag_enum_other__") + }) + }, + ) } export default agentChatTests From f2ff4110737e97fe2ac750b1f3bd62a820255586 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 14:16:36 +0200 Subject: [PATCH 10/13] =?UTF-8?q?fix(frontend):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20fold=20misplaced=20array=20options,=20type-match=20?= =?UTF-8?q?constraints,=20card=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 on the dialect extensions, verified by execution before fixing: - A top-level enum/oneOf on an array field (a natural LLM author slip) did not just pass validation — canonicalization stamped a top-level enum and the descriptor builder promoted the field to a single-select, silently corrupting the multi-select. Fix folds misplaced options into items (declared items win) and strips the top level, so the predictable slip renders correctly instead of degrading; regression test pins parse→build staying {type: array, multiple: true}. - Scalar constraints now validate against the declared type: enum/oneOf require type "string"; default must match the type (integer ⇒ whole number). Stable per-type degradation reasons replace the loose "must be a primitive" check. - ChoiceCards a11y: the nested antd Radio/Checkbox kept a native focusable input inside role-carrying cards (duplicate tab stop + duplicate role per card; pointer-events-none does not remove keyboard focus), and the group had no radiogroup/group role. The card is now the single interactive element with an aria-hidden visual indicator; the container carries radiogroup/group and accepts Form.Item's injected id. - Condensed the spec-5 header comment to the one-line house rule. --- .../playwright/acceptance/agent-chat/index.ts | 6 +- .../src/gatewayTool/components/SchemaForm.tsx | 35 ++++-- .../agenta-shared/src/utils/elicitation.ts | 51 +++++++-- .../tests/unit/elicitation.test.ts | 108 ++++++++++++++++-- 4 files changed, 169 insertions(+), 31 deletions(-) diff --git a/web/oss/tests/playwright/acceptance/agent-chat/index.ts b/web/oss/tests/playwright/acceptance/agent-chat/index.ts index daf1b168d1..4f1047d266 100644 --- a/web/oss/tests/playwright/acceptance/agent-chat/index.ts +++ b/web/oss/tests/playwright/acceptance/agent-chat/index.ts @@ -251,10 +251,8 @@ const agentChatTests = () => { }, ) - // ── Spec 5: one-click accept — defaults + multi-select + choice cards (full dialect) ───────── - // The agent-templates headline (#5190): every field ships a proposed default, so Accept with - // ZERO edits must resume carrying exactly those values. Also pins the card upgrade: oneOf - // descriptions render as choice cards (option titles + descriptions visible), not a Select. + // ── Spec 5: one-click accept — full dialect (#5190) ────────────────────────────────────────── + // Every field ships a default; Accept with zero edits must resume with exactly those values. baseAgentChatTest( "Elicitation one-click accept: proposed defaults ride the resume unchanged", {tag: elicitationTags}, diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index bf1c75702c..77871bbbe0 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -10,16 +10,14 @@ import { import {buildFormFieldsFromSchema, type FormFieldDescriptor} from "@agenta/shared/utils" import {Editor} from "@agenta/ui/editor" -import {MinusCircle, Plus} from "@phosphor-icons/react" +import {Check, MinusCircle, Plus} from "@phosphor-icons/react" import { Button, - Checkbox, Collapse, DatePicker, Form, Input, InputNumber, - Radio, Switch, Select, Tag, @@ -401,6 +399,27 @@ const choiceCardCls = (selected: boolean) => : "border-colorBorderSecondary hover:border-colorPrimary" }` +/** Presentational check/dot — the CARD is the single interactive element (no nested input). */ +const CardIndicator = ({checked, multiple}: {checked: boolean; multiple?: boolean}) => ( + + {checked && + (multiple ? ( + + ) : ( + + ))} + +) + /** * Context-ful options rendered as selectable cards (radio semantics; checkbox when `multiple`) — * used when any option carries a description a bare Select would flatten. Includes the same @@ -412,12 +431,15 @@ function ChoiceCards({ options, multiple, disabled, + id, }: { value?: string | string[] onChange?: (v: string | string[] | undefined) => void options: EnumOption[] multiple?: boolean disabled?: boolean + /** Injected by Form.Item so the field label associates with the group. */ + id?: string }) { const [otherDraft, setOtherDraft] = useState(null) const selected = multiple @@ -438,10 +460,9 @@ function ChoiceCards({ if (commit.changed) onChange?.(commit.value) } const otherActive = otherDraft !== null || customValues.length > 0 - const Control = multiple ? Checkbox : Radio return ( -
+
{options.map((o) => (
- +
{o.label ?? o.value} @@ -485,7 +506,7 @@ function ChoiceCards({ }} className={choiceCardCls(otherActive)} > - +
Other… {multiple && customValues.length > 0 && ( diff --git a/web/packages/agenta-shared/src/utils/elicitation.ts b/web/packages/agenta-shared/src/utils/elicitation.ts index a4b97c4790..4bef1743ec 100644 --- a/web/packages/agenta-shared/src/utils/elicitation.ts +++ b/web/packages/agenta-shared/src/utils/elicitation.ts @@ -115,6 +115,16 @@ const isRecord = (value: unknown): value is Record => const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((v) => typeof v === "string") +/** A scalar default must match the field's declared type (integer ⇒ whole number). */ +const isValidScalarDefault = (type: string, value: unknown): boolean => + type === "string" + ? typeof value === "string" + : type === "boolean" + ? typeof value === "boolean" + : type === "integer" + ? typeof value === "number" && Number.isInteger(value) + : typeof value === "number" && Number.isFinite(value) + const isValidOneOf = (value: unknown): value is ElicitationOptionSchema[] => Array.isArray(value) && value.length > 0 && @@ -164,6 +174,12 @@ export function parseElicitationPayload(input: unknown): ElicitationParseResult return {ok: false, reason: `property "${name}" items enum must be strings`} if (items.oneOf !== undefined && !isValidOneOf(items.oneOf)) return {ok: false, reason: `property "${name}" oneOf options need a string const`} + // Top-level enum/oneOf on an array is a natural author slip — canonicalization folds + // them into items, so validate them under the same rules here. + if (prop.enum !== undefined && !isStringArray(prop.enum)) + return {ok: false, reason: `property "${name}" items enum must be strings`} + if (prop.oneOf !== undefined && !isValidOneOf(prop.oneOf)) + return {ok: false, reason: `property "${name}" oneOf options need a string const`} if (prop.default !== undefined && !isStringArray(prop.default)) return { ok: false, @@ -172,15 +188,14 @@ export function parseElicitationPayload(input: unknown): ElicitationParseResult } else { if ("items" in prop) return {ok: false, reason: `property "${name}" is nested — flat dialect only`} + if ((prop.enum !== undefined || prop.oneOf !== undefined) && type !== "string") + return {ok: false, reason: `property "${name}" enum/oneOf requires type "string"`} if (prop.enum !== undefined && !isStringArray(prop.enum)) return {ok: false, reason: `property "${name}" enum must be strings`} if (prop.oneOf !== undefined && !isValidOneOf(prop.oneOf)) return {ok: false, reason: `property "${name}" oneOf options need a string const`} - if ( - prop.default !== undefined && - !["string", "number", "boolean"].includes(typeof prop.default) - ) - return {ok: false, reason: `property "${name}" default must be a primitive`} + if (prop.default !== undefined && !isValidScalarDefault(type, prop.default)) + return {ok: false, reason: `property "${name}" default must match type "${type}"`} } const title = typeof prop.title === "string" ? prop.title : "" if (SECRET_FIELD_PATTERN.test(name) || SECRET_FIELD_PATTERN.test(title)) @@ -197,17 +212,33 @@ export function parseElicitationPayload(input: unknown): ElicitationParseResult } // Canonicalize once at the boundary so the renderer and the serializer never diverge: - // format aliases → canonical ("datetime" → "date-time", unknown dropped), and oneOf consts - // → enum (downstream consumers key on enum; oneOf stays for the option titles/descriptions). + // format aliases → canonical ("datetime" → "date-time", unknown dropped); oneOf consts → + // enum (downstream consumers key on enum; oneOf stays for the option titles/descriptions); + // and misplaced top-level enum/oneOf on an array fold into items (declared items win) — + // left at the top level they would mis-promote the field to a single-select downstream. const properties = Object.fromEntries( Object.entries(requestedSchema.properties).map(([name, prop]) => { const field = {...(prop as ElicitationFieldSchema)} const canonical = normalizeStringFormat(field.format) if (canonical) field.format = canonical else delete field.format - if (field.oneOf) field.enum = field.oneOf.map((o) => o.const) - if (field.items?.oneOf) - field.items = {...field.items, enum: field.items.oneOf.map((o) => o.const)} + if (field.type === "array" && field.items) { + field.items = { + ...field.items, + ...(field.items.enum === undefined && field.enum !== undefined + ? {enum: field.enum} + : {}), + ...(field.items.oneOf === undefined && field.oneOf !== undefined + ? {oneOf: field.oneOf} + : {}), + } + delete field.enum + delete field.oneOf + if (field.items.oneOf) + field.items = {...field.items, enum: field.items.oneOf.map((o) => o.const)} + } else if (field.oneOf) { + field.enum = field.oneOf.map((o) => o.const) + } return [name, field] }), ) as Record diff --git a/web/packages/agenta-shared/tests/unit/elicitation.test.ts b/web/packages/agenta-shared/tests/unit/elicitation.test.ts index 460037d720..43009ad4a2 100644 --- a/web/packages/agenta-shared/tests/unit/elicitation.test.ts +++ b/web/packages/agenta-shared/tests/unit/elicitation.test.ts @@ -3,6 +3,7 @@ import {join} from "node:path" import {describe, expect, it} from "vitest" +import {buildFormFieldsFromSchema} from "../../src/utils/gatewayToolSchema" import { ELICITATION_RENDER_KIND, SECRET_FIELD_PATTERN, @@ -168,22 +169,109 @@ describe("parseElicitationPayload", () => { expect(parsed.level.default).toBe("high") }) - it("rejects non-primitive defaults", () => { + it("rejects defaults that do not match the declared type", () => { + const cases: [Record, string][] = [ + [{type: "string", default: {nested: true}}, 'default must match type "string"'], + [{type: "string", default: ["a"]}, 'default must match type "string"'], + [{type: "integer", default: "3"}, 'default must match type "integer"'], + [{type: "integer", default: 3.5}, 'default must match type "integer"'], + [{type: "boolean", default: "true"}, 'default must match type "boolean"'], + [{type: "number", default: Number.NaN}, 'default must match type "number"'], + ] + for (const [prop, suffix] of cases) { + const payload = validPayload() + ;(payload.requestedSchema.properties as Record).x = prop + expect(parseElicitationPayload(payload)).toEqual({ + ok: false, + reason: `property "x" ${suffix}`, + }) + } + // Matching types stay accepted (number tolerates a float; integer requires whole). + const ok = validPayload() + ;(ok.requestedSchema.properties as Record).ratio = { + type: "number", + default: 3.5, + } + expect(parseElicitationPayload(ok).ok).toBe(true) + }) + + it("rejects enum/oneOf on non-string scalar types", () => { + const enumCase = validPayload() + ;(enumCase.requestedSchema.properties as Record).x = { + type: "integer", + enum: ["1", "2"], + } + expect(parseElicitationPayload(enumCase)).toEqual({ + ok: false, + reason: 'property "x" enum/oneOf requires type "string"', + }) + const oneOfCase = validPayload() + ;(oneOfCase.requestedSchema.properties as Record).x = { + type: "boolean", + oneOf: [{const: "yes"}], + } + expect(parseElicitationPayload(oneOfCase).ok).toBe(false) + }) + + it("folds misplaced top-level enum/oneOf on an array into items (declared items win)", () => { const payload = validPayload() - ;(payload.requestedSchema.properties as Record).name = { - type: "string", - default: {nested: true}, + const props = payload.requestedSchema.properties as Record + props.by_enum = {type: "array", items: {type: "string"}, enum: ["a", "b"]} + props.by_one_of = { + type: "array", + items: {type: "string"}, + oneOf: [{const: "slack"}, {const: "email"}], + } + props.declared_wins = { + type: "array", + items: {type: "string", enum: ["x"]}, + enum: ["ignored"], + } + const result = parseElicitationPayload(payload) + expect(result.ok).toBe(true) + if (!result.ok) return + const parsed = result.payload.requestedSchema.properties + expect(parsed.by_enum.items?.enum).toEqual(["a", "b"]) + expect(parsed.by_one_of.items?.enum).toEqual(["slack", "email"]) + expect(parsed.declared_wins.items?.enum).toEqual(["x"]) + // The top level is stripped so downstream never mis-promotes the field to single-select. + for (const name of ["by_enum", "by_one_of", "declared_wins"]) { + expect("enum" in parsed[name]).toBe(false) + expect("oneOf" in parsed[name]).toBe(false) + } + }) + + it("rejects malformed misplaced array options", () => { + const payload = validPayload() + ;(payload.requestedSchema.properties as Record).x = { + type: "array", + items: {type: "string"}, + enum: [1, 2], } expect(parseElicitationPayload(payload)).toEqual({ ok: false, - reason: 'property "name" default must be a primitive', + reason: 'property "x" items enum must be strings', }) - const arr = validPayload() - ;(arr.requestedSchema.properties as Record).name = { - type: "string", - default: ["a"], + }) + + it("REGRESSION: a misplaced-oneOf array still builds as a multi-select, not a single enum", () => { + const payload = validPayload() + ;(payload.requestedSchema.properties as Record).ch = { + type: "array", + items: {type: "string"}, + oneOf: [{const: "slack"}, {const: "email"}], } - expect(parseElicitationPayload(arr).ok).toBe(false) + const result = parseElicitationPayload(payload) + expect(result.ok).toBe(true) + if (!result.ok) return + const descriptor = buildFormFieldsFromSchema( + result.payload.requestedSchema as unknown as Record, + "", + {openEnums: true}, + ).find((f) => f.name === "ch") + expect(descriptor?.type).toBe("array") + expect(descriptor?.multiple).toBe(true) + expect(descriptor?.enumValues).toEqual(["slack", "email"]) }) it("accepts multi-select arrays (string items, optional enum, array-of-strings default)", () => { From a640dcfac5e1c4dac7ad46dbadbbf755a8dbb4a3 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 18:38:24 +0200 Subject: [PATCH 11/13] =?UTF-8?q?fix(frontend):=20dogfooding=20round=20?= =?UTF-8?q?=E2=80=94=20collapsed=20defaults=20registered,=20empty=20defaul?= =?UTF-8?q?ts=20stripped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live scenario-1 testing (all-optional prefilled form) surfaced two bugs: - All-optional fields collapse behind "Optional (N)", and antd Collapse does not render inactive panels — the Form.Items never registered, so a one-click Accept silently returned EMPTY content, dropping every proposed default. Fix: forceRender on the optional panel (defaults register while collapsed; benefits gateway forms too), and when there are no required fields render optional fields inline — the collapse de-emphasizes extras below required fields, and with none there is nothing to de-emphasize. - A model emitting default: "" (its way of saying "no proposal") passed the string type check and mounted enum fields in Other-mode with an empty input. Parse now strips empty defaults ("" and []), and isOffOptionsValue treats "" as unset (defense for replayed drafts). --- .../src/gatewayTool/components/SchemaForm.tsx | 8 +++++++- .../src/gatewayTool/components/schemaFormOptions.ts | 5 +++-- .../tests/unit/schemaFormOptions.test.ts | 4 ++++ web/packages/agenta-shared/src/utils/elicitation.ts | 7 +++++++ .../agenta-shared/tests/unit/elicitation.test.ts | 12 ++++++++++++ 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index 77871bbbe0..87cff6cdba 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -173,7 +173,9 @@ const SchemaForm = forwardRef( ))} - {flat + {/* The collapse de-emphasizes optional EXTRAS below required fields; with no + required fields there is nothing to de-emphasize, so render inline. */} + {flat || requiredFields.length === 0 ? optionalFields.map((field) => ( )) @@ -185,6 +187,10 @@ const SchemaForm = forwardRef( items={[ { key: "optional", + // Collapsed Form.Items must still register their + // initialValues (schema defaults) — without forceRender an + // untouched submit silently drops every collapsed default. + forceRender: true, label: ( Optional ({optionalFields.length}) diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormOptions.ts b/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormOptions.ts index 7f554c1559..5e11fe2491 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormOptions.ts +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormOptions.ts @@ -35,9 +35,10 @@ export const selectOptionsWithOther = (options: EnumOption[]) => [ {value: OTHER_ENUM_OPTION, label: "Other…"}, ] -/** True when the current value is set but not one of the options (default/replay off-menu). */ +/** True when the current value is set but not one of the options (default/replay off-menu). + * An empty string counts as unset — it must not mount the control in Other-mode. */ export const isOffOptionsValue = (value: string | null | undefined, options: EnumOption[]) => - value != null && !options.some((o) => o.value === value) + value != null && value !== "" && !options.some((o) => o.value === value) /** * Multi-select Select onChange: strip the "Other…" sentinel (it opens the draft input, it is diff --git a/web/packages/agenta-entity-ui/tests/unit/schemaFormOptions.test.ts b/web/packages/agenta-entity-ui/tests/unit/schemaFormOptions.test.ts index 518b2b0732..8b777c8dae 100644 --- a/web/packages/agenta-entity-ui/tests/unit/schemaFormOptions.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/schemaFormOptions.test.ts @@ -86,6 +86,10 @@ describe("isOffOptionsValue (Other-mode trigger for defaults/replays)", () => { expect(isOffOptionsValue(null, options)).toBe(false) expect(isOffOptionsValue("red", options)).toBe(false) }) + + it("REGRESSION: an empty-string value must not open Other-mode", () => { + expect(isOffOptionsValue("", options)).toBe(false) + }) }) describe("splitOtherFromSelection (multi Select onChange)", () => { diff --git a/web/packages/agenta-shared/src/utils/elicitation.ts b/web/packages/agenta-shared/src/utils/elicitation.ts index 4bef1743ec..bcd741c3ed 100644 --- a/web/packages/agenta-shared/src/utils/elicitation.ts +++ b/web/packages/agenta-shared/src/utils/elicitation.ts @@ -222,6 +222,13 @@ export function parseElicitationPayload(input: unknown): ElicitationParseResult const canonical = normalizeStringFormat(field.format) if (canonical) field.format = canonical else delete field.format + // An empty default ("" or []) means "no proposal" — models emit these when they + // cannot pick; kept, they would mount enum fields in Other-mode with an empty input. + if ( + field.default === "" || + (Array.isArray(field.default) && field.default.length === 0) + ) + delete field.default if (field.type === "array" && field.items) { field.items = { ...field.items, diff --git a/web/packages/agenta-shared/tests/unit/elicitation.test.ts b/web/packages/agenta-shared/tests/unit/elicitation.test.ts index 43009ad4a2..3d79897560 100644 --- a/web/packages/agenta-shared/tests/unit/elicitation.test.ts +++ b/web/packages/agenta-shared/tests/unit/elicitation.test.ts @@ -195,6 +195,18 @@ describe("parseElicitationPayload", () => { expect(parseElicitationPayload(ok).ok).toBe(true) }) + it("strips empty defaults ('' and []) — models emit them to mean 'no proposal'", () => { + const payload = validPayload() + const props = payload.requestedSchema.properties as Record + props.level = {type: "string", enum: ["low", "high"], default: ""} + props.tags = {type: "array", items: {type: "string"}, default: []} + const result = parseElicitationPayload(payload) + expect(result.ok).toBe(true) + if (!result.ok) return + expect("default" in result.payload.requestedSchema.properties.level).toBe(false) + expect("default" in result.payload.requestedSchema.properties.tags).toBe(false) + }) + it("rejects enum/oneOf on non-string scalar types", () => { const enumCase = validPayload() ;(enumCase.requestedSchema.properties as Record).x = { From c73c41d1ca4ddd5acf5cf9c634a021ba67769f87 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 18:42:36 +0200 Subject: [PATCH 12/13] =?UTF-8?q?fix(frontend):=20dogfooding=20polish=20?= =?UTF-8?q?=E2=80=94=20subtle=20selected=20card,=20no=20premature=20requir?= =?UTF-8?q?ed=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Selected choice card dropped the full primary-bg fill (a heavy olive slab in the dark theme); selection reads from the primary border + the filled indicator. - Picking "Other…" emitted a no-op onChange(undefined), firing the required rule the instant the option was chosen — before the user could type. Both Selects now skip the no-op change; validation still fires on Accept. --- .../src/gatewayTool/components/SchemaForm.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index 87cff6cdba..839f3335b5 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -304,7 +304,9 @@ function EnumWithOther({ onChange={(next) => { if (next === OTHER_ENUM_OPTION) { setOtherMode(true) - onChange?.(undefined) + // No no-op change: emitting undefined here would fire the required rule + // the instant the user picks Other…, before they can type. + if (value !== undefined) onChange?.(undefined) } else { setOtherMode(false) onChange?.(next) @@ -379,7 +381,12 @@ function MultiEnumWithOther({ onChange={(next: string[]) => { const {values, openOther} = splitOtherFromSelection(next) if (openOther) setOtherDraft("") - onChange?.(values) + // Opening the Other… draft is not a value change — a no-op onChange would + // fire the required rule before the user can type. + const unchanged = + (values ?? []).length === selected.length && + (values ?? []).every((v, i) => v === selected[i]) + if (!unchanged) onChange?.(values) }} options={selectOptionsWithOther(options)} /> @@ -398,11 +405,11 @@ function MultiEnumWithOther({ ) } +// Selected state: primary border + the filled indicator only — a full primary-bg fill reads +// far too heavy in the dark theme (dogfooding feedback). const choiceCardCls = (selected: boolean) => `flex cursor-pointer items-start gap-2 rounded-lg border border-solid p-3 transition-colors ${ - selected - ? "border-colorPrimary bg-[var(--ant-color-primary-bg)]" - : "border-colorBorderSecondary hover:border-colorPrimary" + selected ? "border-colorPrimary" : "border-colorBorderSecondary hover:border-colorPrimary" }` /** Presentational check/dot — the CARD is the single interactive element (no nested input). */ From 3f6bbf8a8cff4b23cc7c28b83e354879db006ad1 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 20:18:27 +0200 Subject: [PATCH 13/13] feat(frontend): elicitation form drafts survive a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed field values lived only in antd Form state, so reloading while a form was pending lost everything the user had entered (the message parts persist; the draft never did). The widget now persists the draft to localStorage keyed by the toolCallId on every change, restores it on mount over the schema defaults, and clears it on any settle (accept/decline/dismiss). - SchemaForm gains an onValuesChange passthrough (raw values on purpose: cleanFormValues recurses into and destroys dayjs objects and JSON.parses typed strings — wrong for a draft snapshot). - Date fields round-trip via partitionElicitationDraft (@agenta/shared, tested): persisted ISO strings are revived to dayjs on restore, since DatePicker rejects strings. --- .../clientTools/ElicitationWidget.tsx | 59 ++++++++++++++++++- .../src/gatewayTool/components/SchemaForm.tsx | 7 ++- .../agenta-shared/src/utils/elicitation.ts | 20 +++++++ .../tests/unit/elicitation.test.ts | 32 ++++++++++ 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/ElicitationWidget.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/ElicitationWidget.tsx index 778e1bd809..b268c23eaf 100644 --- a/web/oss/src/components/AgentChatSlice/components/clientTools/ElicitationWidget.tsx +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/ElicitationWidget.tsx @@ -17,10 +17,12 @@ import { buildDegradationErrorText, deriveElicitationPartState, parseElicitationPayload, + partitionElicitationDraft, serializeElicitationContent, } from "@agenta/shared/utils" import {CheckCircle, Prohibit, Question, Warning, XCircle} from "@phosphor-icons/react" import {Button, Form, Typography} from "antd" +import dayjs from "dayjs" import type {ClientToolHandlerProps} from "./types" @@ -29,6 +31,9 @@ const {Text} = Typography /** ElicitationResult → the settle channel's Record shape (interfaces carry no index signature). */ const toOutput = (result: ElicitationResult) => ({...result}) as Record +/** In-progress field values survive a reload (localStorage draft keyed by the toolCallId). */ +const draftKeyFor = (toolCallId: string) => `agenta:elicitation-draft:${toolCallId}` + /** Settled/parked single-line chip — one chrome for every terminal state (design: settled chip). */ const Chip = ({ icon, @@ -64,6 +69,47 @@ const ElicitationWidget = ({meta, settle, degradedEarlierInTurn}: ClientToolHand settle({errorText: buildDegradationErrorText(parsed.reason)}) }, [parsed, parked, meta.settled, settle]) + // Draft persistence: typed values live only in antd Form state, so a reload would lose them. + const draftKey = draftKeyFor(meta.toolCallId) + const clearDraft = () => { + try { + localStorage.removeItem(draftKey) + } catch { + // storage unavailable — drafts are best-effort + } + } + const persistDraft = (values: Record) => { + try { + localStorage.setItem(draftKey, JSON.stringify(values)) + } catch { + // storage unavailable — drafts are best-effort + } + } + const settleAndClear: typeof settle = (args: Parameters[0]) => { + clearDraft() + settle(args as {output: Record}) + } + const restoredRef = useRef(false) + useEffect(() => { + if (restoredRef.current || !parsed.ok || parked || meta.settled) return + restoredRef.current = true + try { + const raw = localStorage.getItem(draftKey) + if (!raw) return + const {plain, dates} = partitionElicitationDraft( + parsed.payload, + JSON.parse(raw) as Record, + ) + // DatePicker rejects strings — revive persisted ISO strings to dayjs. + form.setFieldsValue({ + ...plain, + ...Object.fromEntries(Object.entries(dates).map(([k, v]) => [k, dayjs(v)])), + }) + } catch { + // unreadable draft — fall back to schema defaults + } + }, [parsed, parked, meta.settled, draftKey, form]) + const partState = deriveElicitationPartState({ state: meta.state, output: meta.output, @@ -142,7 +188,9 @@ const ElicitationWidget = ({meta, settle, degradedEarlierInTurn}: ClientToolHand try { const values = await form.validateFields() const content = serializeElicitationContent(parsed.payload, values) - settle({output: toOutput(buildAcceptResult(content, "Provided the requested input."))}) + settleAndClear({ + output: toOutput(buildAcceptResult(content, "Provided the requested input.")), + }) } catch { // antd surfaces inline field errors; Accept stays enabled for retry. } finally { @@ -172,6 +220,7 @@ const ElicitationWidget = ({meta, settle, degradedEarlierInTurn}: ClientToolHand form={form} formats openEnums + onValuesChange={persistDraft} />
@@ -181,7 +230,9 @@ const ElicitationWidget = ({meta, settle, degradedEarlierInTurn}: ClientToolHand