From 292befbeab199431deb9ae159005abb943f2c606 Mon Sep 17 00:00:00 2001 From: oripitch Date: Wed, 30 Sep 2026 19:19:35 +0200 Subject: [PATCH] feat(web): question cards for agent elicitations --- .changeset/acp-elicitation-loop.md | 5 + .../rooms/typing-indicator.stories.tsx | 8 + .../rooms/typing-indicator.test.tsx | 15 +- .../src/components/rooms/typing-indicator.tsx | 14 +- .../settings/notification-section.tsx | 1 + .../src/components/structures/room-view.tsx | 4 +- .../thread-view.question.stories.tsx | 34 ++++ .../src/components/timeline/event-tile.tsx | 4 + .../timeline/question-card-view.stories.tsx | 54 ++++++ .../timeline/question-card-view.test.tsx | 89 ++++++++++ .../timeline/question-card-view.tsx | 159 ++++++++++++++++++ .../question-card.integration.test.tsx | 148 ++++++++++++++++ .../src/components/timeline/question-card.tsx | 40 +++++ packages/web/src/events/elicitation.test.ts | 77 +++++++++ packages/web/src/events/elicitation.ts | 130 ++++++++++++++ .../web/src/hooks/use-awaiting-input.test.tsx | 39 +++++ packages/web/src/hooks/use-elicitation.ts | 70 ++++++++ packages/web/src/hooks/use-timeline.ts | 54 ++++++ packages/web/src/lib/elicitation-form.test.ts | 101 +++++++++++ packages/web/src/lib/elicitation-form.ts | 149 ++++++++++++++++ .../src/lib/matrix/notification-prefs.test.ts | 5 +- .../web/src/lib/matrix/notification-prefs.ts | 1 + .../web/src/lib/matrix/notifications.test.ts | 6 + packages/web/src/lib/matrix/notifications.ts | 4 + 24 files changed, 1203 insertions(+), 8 deletions(-) create mode 100644 .changeset/acp-elicitation-loop.md create mode 100644 packages/web/src/components/rooms/typing-indicator.stories.tsx create mode 100644 packages/web/src/components/structures/thread-view.question.stories.tsx create mode 100644 packages/web/src/components/timeline/question-card-view.stories.tsx create mode 100644 packages/web/src/components/timeline/question-card-view.test.tsx create mode 100644 packages/web/src/components/timeline/question-card-view.tsx create mode 100644 packages/web/src/components/timeline/question-card.integration.test.tsx create mode 100644 packages/web/src/components/timeline/question-card.tsx create mode 100644 packages/web/src/events/elicitation.test.ts create mode 100644 packages/web/src/events/elicitation.ts create mode 100644 packages/web/src/hooks/use-awaiting-input.test.tsx create mode 100644 packages/web/src/hooks/use-elicitation.ts create mode 100644 packages/web/src/lib/elicitation-form.test.ts create mode 100644 packages/web/src/lib/elicitation-form.ts diff --git a/.changeset/acp-elicitation-loop.md b/.changeset/acp-elicitation-loop.md new file mode 100644 index 0000000..38b6ab6 --- /dev/null +++ b/.changeset/acp-elicitation-loop.md @@ -0,0 +1,5 @@ +--- +"@zooid/web": minor +--- + +Display and answer agent elicitation forms, with confirmed resolution, field feedback, threaded questions, notifications, and awaiting-input status. diff --git a/packages/web/src/components/rooms/typing-indicator.stories.tsx b/packages/web/src/components/rooms/typing-indicator.stories.tsx new file mode 100644 index 0000000..67ab02d --- /dev/null +++ b/packages/web/src/components/rooms/typing-indicator.stories.tsx @@ -0,0 +1,8 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TypingIndicator } from "./typing-indicator"; +const meta = { title: "Rooms/TypingIndicator", component: TypingIndicator, args: { typingUserIds: [], awaitingUserIds: ["@architect.acme:h.example"] } } satisfies Meta; +export default meta; +type Story = StoryObj; +export const Awaiting: Story = {}; +export const AwaitingAndTyping: Story = { args: { typingUserIds: ["@bob:h.example", "@architect.acme:h.example"] } }; +export const TwoAwaiting: Story = { args: { awaitingUserIds: ["@architect.acme:h.example", "@claude:h.example"] } }; diff --git a/packages/web/src/components/rooms/typing-indicator.test.tsx b/packages/web/src/components/rooms/typing-indicator.test.tsx index 0528d2c..cdc5393 100644 --- a/packages/web/src/components/rooms/typing-indicator.test.tsx +++ b/packages/web/src/components/rooms/typing-indicator.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { TypingIndicator } from "./typing-indicator"; @@ -43,3 +43,16 @@ describe("", () => { expect(container.textContent).toMatch(/typing/i); }); }); + +describe("TypingIndicator — awaiting input", () => { + it("shows an agent awaiting input instead of typing", () => { + render(); + expect(screen.getByText(/is awaiting your input/i)).toBeInTheDocument(); + expect(screen.queryByText(/is typing/i)).not.toBeInTheDocument(); + }); + it("shows both typing and awaiting users", () => { + render(); + expect(screen.getByText(/is typing/i)).toBeInTheDocument(); + expect(screen.getByText(/is awaiting your input/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/rooms/typing-indicator.tsx b/packages/web/src/components/rooms/typing-indicator.tsx index cc9f261..04d1d61 100644 --- a/packages/web/src/components/rooms/typing-indicator.tsx +++ b/packages/web/src/components/rooms/typing-indicator.tsx @@ -3,6 +3,7 @@ import { useUserName } from "../../hooks/use-user-name"; interface Props { typingUserIds: string[]; + awaitingUserIds?: string[]; roomId?: string; } @@ -12,8 +13,9 @@ function TypingName({ userId, roomId }: { userId: string; roomId?: string }) { ); } -export function TypingIndicator({ typingUserIds, roomId }: Props) { - if (typingUserIds.length === 0) return
; +export function TypingIndicator({ typingUserIds: allTyping, awaitingUserIds = [], roomId }: Props) { + const typingUserIds = allTyping.filter((id) => !awaitingUserIds.includes(id)); + if (typingUserIds.length === 0 && awaitingUserIds.length === 0) return
; const MAX_NAMES = 2; const named = typingUserIds.slice(0, MAX_NAMES); @@ -36,8 +38,12 @@ export function TypingIndicator({ typingUserIds, roomId }: Props) { return (
- {parts} - {suffix} + {awaitingUserIds.length > 0 && + {awaitingUserIds.map((id, index) => {index > 0 ? ", " : ""})} + {awaitingUserIds.length === 1 ? " is awaiting your input" : " are awaiting your input"} + } + {awaitingUserIds.length > 0 && typingUserIds.length > 0 && " · "} + {typingUserIds.length > 0 && {parts}{suffix}}
); } diff --git a/packages/web/src/components/settings/notification-section.tsx b/packages/web/src/components/settings/notification-section.tsx index f213785..d36debd 100644 --- a/packages/web/src/components/settings/notification-section.tsx +++ b/packages/web/src/components/settings/notification-section.tsx @@ -14,6 +14,7 @@ import { setSoundEnabledLocally, soundEnabledLocally } from "@/lib/notification- import type { GlobalNotifMode } from "@/lib/matrix/notification-prefs"; const AGENT_RULE_LABELS: Record = { + "dev.zooid.elicitation_request": "Questions from agents", "dev.zooid.approval_request": "Approval requests", "dev.zooid.turn.end": "Agent finished a turn", "dev.zooid.error": "Agent errors", diff --git a/packages/web/src/components/structures/room-view.tsx b/packages/web/src/components/structures/room-view.tsx index 68cf85f..a92cf14 100644 --- a/packages/web/src/components/structures/room-view.tsx +++ b/packages/web/src/components/structures/room-view.tsx @@ -1,3 +1,4 @@ +import { useAwaitingInput } from "../../hooks/use-timeline"; import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; import { Composer } from "../rooms/composer"; @@ -17,6 +18,7 @@ export function RoomView() { const threadRootEventId = searchParams.get("thread"); const highlightEventId = searchParams.get("event") ?? undefined; const known = useRoomKnown(roomId ?? ""); + const awaitingUserIds = useAwaitingInput(roomId ?? ""); const typingUserIds = useTyping(roomId ?? ""); useMarkRead(roomId ?? ""); @@ -66,7 +68,7 @@ export function RoomView() { /> )}
- + {showPlan && (
unknown }).getRoom = (id) => id === roomId ? room : null; + // Stories must stay offline even when TimelinePanel asks for pagination. + (client as unknown as { paginateEventTimeline: unknown }).paginateEventTimeline = async () => false; + const add = (id: string, type: string, sender: string, content: Record) => pushTimelineEvent(room, mkMatrixEvent({ roomId, eventId: id, sender, type, content })); + add(root, "m.room.message", me, { msgtype: "m.text", body: "Prepare a deployment. Ask me which environment first." }); + add("$question-prose", "m.room.message", agent, { msgtype: "m.notice", body: "I have the deployment ready. One choice before I continue.", ...thread }); + add("$question-tool", "dev.zooid.tool_call", agent, { session_id: "s1", tool_call_id: "tc1", title: "AskUserQuestion", kind: "other", status: "in_progress", ...thread }); + add("$question-request", "dev.zooid.elicitation_request", agent, { version: 1, request_id: "question-1", session_id: "s1", message: "Which environment should I deploy to?", requested_schema: askSchema, ...thread }); + if (answered) { + add("$question-answer", "dev.zooid.elicitation_response", me, { request_id: "question-1", request_event_id: "$question-request", action: "accept", content: { question_0: "staging" }, ...thread }); + add("$question-resolved", "dev.zooid.elicitation_resolved", agent, { version: 1, request_id: "question-1", request_event_id: "$question-request", response_event_id: "$question-answer", status: "accepted", responded_by: me, content: { question_0: "staging" }, ...thread }); + add("$question-continued", "m.room.message", agent, { msgtype: "m.notice", body: "Thanks. I’m preparing the staging deployment now.", ...thread }); + } + MatrixClientPeg.injectClientForTest(client); +} +const meta = { title: "Structures/ThreadViewQuestion", component: ThreadView, parameters: { layout: "fullscreen" }, args: { roomId, rootEventId: root, onBack: () => {} } } satisfies Meta; +export default meta; +type Story = StoryObj; +export const OpenQuestion: Story = { render: (args) => { seed(false); return ; } }; +export const AnsweredThenContinued: Story = { render: (args) => { seed(true); return ; } }; diff --git a/packages/web/src/components/timeline/event-tile.tsx b/packages/web/src/components/timeline/event-tile.tsx index 4282b18..6487755 100644 --- a/packages/web/src/components/timeline/event-tile.tsx +++ b/packages/web/src/components/timeline/event-tile.tsx @@ -1,3 +1,5 @@ +import { ElicitationEventType } from "../../events/elicitation"; +import { QuestionCard } from "./question-card"; import type { MatrixEvent } from "matrix-js-sdk"; import { ApprovalEventType } from "../../events/approval"; import { decodeZooidEvent, isZooidLifecycle } from "../../events/zooid-events"; @@ -39,6 +41,8 @@ export function EventTile({ /> ); } + if (event.getType() === ElicitationEventType.Request) return ; + if ([ElicitationEventType.Response, ElicitationEventType.Resolved, ElicitationEventType.Rejected].some((type) => type === event.getType())) return null; if (event.getType() === ApprovalEventType.Request) return ; // Approval *responses* are not rendered as their own tile — they only matter // as input to resolution. Skip silently. diff --git a/packages/web/src/components/timeline/question-card-view.stories.tsx b/packages/web/src/components/timeline/question-card-view.stories.tsx new file mode 100644 index 0000000..b1fe5de --- /dev/null +++ b/packages/web/src/components/timeline/question-card-view.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { buildFormModel } from "../../lib/elicitation-form"; +import { QuestionCardView } from "./question-card-view"; + +export const askSchema = { + type: "object", + properties: { + question_0: { type: "string", title: "Environment", description: "Where should I deploy?", oneOf: [ + { const: "staging", title: "Staging (Recommended)", description: "Try the change before production." }, + { const: "production", title: "Production", description: "Make the change available to everyone." }, + { const: "local", title: "Local", description: "Run on your computer." }, + ] }, + question_0_custom: { type: "string", title: "Other", description: "Add a custom answer or context.", _meta: { _askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true } } }, + }, +}; +export const typedSchema = { + type: "object", + properties: { + name: { type: "string", title: "Project name", minLength: 2 }, + email: { type: "string", title: "Contact email", format: "email" }, + count: { type: "integer", title: "Instances", minimum: 1, maximum: 5 }, + ratio: { type: "number", title: "Traffic share", minimum: 0, maximum: 1 }, + enabled: { type: "boolean", title: "Enable logging", default: true }, + tags: { type: "array", title: "Checks", items: { type: "string", enum: ["lint", "test", "build"] }, maxItems: 2 }, + }, + required: ["name", "count"], +}; +const meta = { + title: "Timeline/QuestionCardView", + component: QuestionCardView, + args: { agentName: "architect", message: "Which environment should I deploy to?", model: buildFormModel(askSchema), state: "open", onSubmit: (content) => { window.dispatchEvent(new CustomEvent("question-submit", { detail: content })); } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const SingleQuestion: Story = {}; +export const MultipleQuestions: Story = { args: { model: buildFormModel({ ...askSchema, properties: { + ...askSchema.properties, + question_1: { type: "array", title: "Checks", description: "Which checks should run?", items: { anyOf: [{ const: "lint", title: "Lint" }, { const: "test", title: "Test" }] } }, + question_1_custom: { type: "string", title: "Other checks", _meta: { _askUserQuestionCustomAnswer: { questionId: "question_1", isCustomAnswer: true } } }, +} }) } }; +export const TypedFields: Story = { args: { message: "Configure the deployment", model: buildFormModel(typedSchema) } }; +export const ServerErrors: Story = { args: { model: buildFormModel(typedSchema), fieldErrors: { name: "must be at least 2 characters", count: "must be ≤ 5" } } }; +export const Sending: Story = { args: { state: "sending" } }; +export const AwaitingConfirmation: Story = { args: { state: "awaiting_confirmation" } }; +export const Stale: Story = { args: { stale: true } }; +export const SendError: Story = { args: { error: "M_FORBIDDEN: Your response could not be sent. Try again." } }; +export const Unsupported: Story = { args: { model: buildFormModel({ type: "object", properties: { note: { type: "string", title: "Note" }, nested: { type: "object" } } }) } }; +export const Answered: Story = { args: { state: "resolved", model: buildFormModel({ ...askSchema, properties: { ...askSchema.properties, question_0: { ...askSchema.properties.question_0, default: "staging" } } }), resolution: { status: "accepted", respondedBy: "Ori" } } }; +export const Skipped: Story = { args: { state: "resolved", resolution: { status: "declined", respondedBy: "Ori" } } }; +export const Cancelled: Story = { args: { state: "resolved", resolution: { status: "cancelled" } } }; +export const Interrupted: Story = { args: { state: "resolved", resolution: { status: "interrupted" } } }; +export const NoPermission: Story = { args: { canAnswer: false } }; +export const LongContent: Story = { args: { message: "Please consider the deployment environment and the implications of this change. ".repeat(6), model: buildFormModel({ type: "object", properties: { env: { type: "string", title: "Environment", enum: ["production-with-a-very-long-environment-name", "/workspace/" + "very-long-unbreakable-path".repeat(12)] } } }) } }; +export const UntrustedMarkup: Story = { args: { message: ' **Which environment?**', model: buildFormModel({ type: "object", properties: { env: { type: "string", oneOf: [{ const: "prod", title: " **Production**" }] } } }) } }; diff --git a/packages/web/src/components/timeline/question-card-view.test.tsx b/packages/web/src/components/timeline/question-card-view.test.tsx new file mode 100644 index 0000000..5b1844c --- /dev/null +++ b/packages/web/src/components/timeline/question-card-view.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { QuestionCardView } from "./question-card-view"; +import { buildFormModel } from "../../lib/elicitation-form"; + +const model = buildFormModel({ + type: "object", + properties: { + question_0: { type: "string", title: "Env", oneOf: [{ const: "staging", title: "Staging" }, { const: "prod", title: "Production" }] }, + question_0_custom: { type: "string", title: "Other", _meta: { _askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true } } }, + }, +}); + +const base = { agentName: "architect", message: "Which environment?", model, state: "open" as const }; + +describe("", () => { + it("renders the agent, question, options, custom field and three actions", () => { + render(); + expect(screen.getByText("architect")).toBeInTheDocument(); + expect(screen.getByText("Which environment?")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Production" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /other/i })).toBeInTheDocument(); + for (const name of ["Submit", "Skip", "Cancel"]) expect(screen.getByRole("button", { name })).toBeEnabled(); + }); + + it("submits typed content", async () => { + const onSubmit = vi.fn(); + render(); + await userEvent.click(screen.getByRole("radio", { name: "Production" })); + await userEvent.type(screen.getByRole("textbox", { name: /other/i }), "after 5pm"); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + expect(onSubmit).toHaveBeenCalledWith({ question_0: "prod", question_0_custom: "after 5pm" }); + }); + + it("flags an empty required field and does not submit", async () => { + const required = buildFormModel({ type: "object", properties: { env: { type: "string" } }, required: ["env"] }); + const onSubmit = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText("required")).toBeInTheDocument(); + }); + + it("shows server field errors passed in", () => { + render(); + expect(screen.getByText("must be one of: staging, prod")).toBeInTheDocument(); + }); + + it("disables actions while sending", () => { + render(); + for (const name of ["Submit", "Skip", "Cancel"]) expect(screen.getByRole("button", { name })).toBeDisabled(); + }); + + it("renders read-only with the responder once resolved", () => { + render(); + expect(screen.getByText(/answered by/i)).toHaveTextContent("Answered by Beno"); + expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Production" })).toBeDisabled(); + }); + + it.each([ + ["declined", /skipped by/i], + ["cancelled", /cancelled/i], + ["interrupted", /no longer answerable/i], + ] as const)("renders %s as read-only", (status, text) => { + render(); + expect(screen.getByText(text)).toBeInTheDocument(); + }); + + it("shows stale feedback", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent(/no longer waiting/i); + }); + + it("renders unsupported fields visibly and disables Submit", () => { + const bad = buildFormModel({ type: "object", properties: { o: { type: "object" } } }); + render(); + expect(screen.getByRole("alert")).toHaveTextContent(/can't be shown/i); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Skip" })).toBeEnabled(); + }); + + it("renders untrusted text as text, never HTML", () => { + render('} onSubmit={vi.fn()} onSkip={vi.fn()} onCancel={vi.fn()} />); + expect(screen.getByText('')).toBeInTheDocument(); + expect(document.querySelector("img")).toBeNull(); + }); +}); diff --git a/packages/web/src/components/timeline/question-card-view.tsx b/packages/web/src/components/timeline/question-card-view.tsx new file mode 100644 index 0000000..72a2c72 --- /dev/null +++ b/packages/web/src/components/timeline/question-card-view.tsx @@ -0,0 +1,159 @@ +import { useId, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import type { FormField, FormModel, FormValues } from "@/lib/elicitation-form"; +import { toSubmission } from "@/lib/elicitation-form"; +import type { ElicitationStatus } from "@/events/elicitation"; + +export type QuestionCardState = "open" | "sending" | "awaiting_confirmation" | "resolved"; + +export interface QuestionCardViewProps { + agentName: string; + message: string; + model: FormModel; + state: QuestionCardState; + resolution?: { status: ElicitationStatus; respondedBy?: string }; + /** Daemon validation errors for this user's last response. */ + fieldErrors?: Record; + answerValues?: FormValues; + /** Send failure. */ + error?: string; + /** The daemon is no longer waiting on this question. */ + stale?: boolean; + canAnswer?: boolean; + onSubmit?: (content: Record) => void; + onSkip?: () => void; + onCancel?: () => void; +} + +const RESOLVED_TEXT: Record string> = { + accepted: (by) => `Answered by ${by ?? "someone"}`, + declined: (by) => `Skipped by ${by ?? "someone"}`, + cancelled: () => "Cancelled", + interrupted: () => "No longer answerable — the agent restarted", +}; + +export function QuestionCardView({ + agentName, message, model, state, resolution, fieldErrors, answerValues, error, stale, + canAnswer = true, onSubmit, onSkip, onCancel, +}: QuestionCardViewProps) { + const [values, setValues] = useState(() => ({ ...model.initialValues })); + const [localErrors, setLocalErrors] = useState>({}); + const readOnly = state !== "open" || !canAnswer || Boolean(stale); + const errors = { ...(fieldErrors ?? {}), ...localErrors }; + const set = (key: string, v: unknown) => setValues((prev) => ({ ...prev, [key]: v })); + + function submit() { + const r = toSubmission(model, values); + if (!r.ok) return setLocalErrors(r.errors); + setLocalErrors({}); + onSubmit?.(r.content); + } + + return ( + + + + {agentName} is asking + + {message} + + + {model.fields.map((f) => ( + + ))} + {model.unsupported.length > 0 && ( +
+ Some fields in this question can't be shown here: {model.unsupported.join(", ")}. Skip it or answer in another client. +
+ )} + {stale && ( +
+ The agent is no longer waiting for this answer. +
+ )} + {error && ( +
{error}
+ )} +
+ {resolution ? ( + +

{RESOLVED_TEXT[resolution.status](resolution.respondedBy)}

+
+ ) : !canAnswer ? ( + +

You have insufficient permission to answer this question.

+
+ ) : ( + + + + + {state === "awaiting_confirmation" && ( + Waiting for confirmation… + )} + + )} +
+ ); +} + +function QuestionField({ field, values, errors, disabled, onChange }: { + field: FormField; + values: FormValues; + errors: Record; + disabled: boolean; + onChange: (key: string, value: unknown) => void; +}) { + const id = useId(); + const label = `${field.label}${field.required ? " *" : ""}`; + const helper = <> + {field.description &&

{field.description}

} + {errors[field.key] && } + ; + const a11y = { + "aria-required": field.required, + "aria-invalid": Boolean(errors[field.key]), + "aria-describedby": [field.description ? `${id}-description` : "", errors[field.key] ? `${id}-error` : ""].filter(Boolean).join(" ") || undefined, + }; + if (field.kind === "select" || field.kind === "multiselect") { + const multi = field.kind === "multiselect"; + const selected = Array.isArray(values[field.key]) ? values[field.key] as string[] : []; + return
+ {label} + {helper} + {field.options?.map((option, index) =>
+ + {option.description &&

{option.description}

} +
)} + {field.customKey &&
+ + onChange(field.customKey!, event.target.value)} /> + {field.customDescription &&

{field.customDescription}

} + {errors[field.customKey] &&

{errors[field.customKey]}

} +
} +
; + } + return
+ {field.kind === "boolean" ? : <> + + onChange(field.key, event.target.value)} /> + } + {helper} +
; +} diff --git a/packages/web/src/components/timeline/question-card.integration.test.tsx b/packages/web/src/components/timeline/question-card.integration.test.tsx new file mode 100644 index 0000000..2219620 --- /dev/null +++ b/packages/web/src/components/timeline/question-card.integration.test.tsx @@ -0,0 +1,148 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { EventType } from "matrix-js-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + injectStateEvent, + makeFakeClient, + makeRoom, + mkMatrixEvent, + pushTimelineEvent, +} from "../../../test/factories"; +import { MatrixClientPeg } from "../../client/peg"; +import { ElicitationEventType } from "../../events/elicitation"; +import { QuestionCard } from "./question-card"; + +const me = "@me:h.example"; +const agent = "@architect.acme:h.example"; +const roomId = "!r:h.example"; +const thread = { "m.relates_to": { rel_type: "m.thread", event_id: "$root" } }; +const schema = { + type: "object", + properties: { env: { type: "string", title: "Env", oneOf: [{ const: "staging", title: "Staging" }, { const: "prod", title: "Production" }] } }, + required: ["env"], +}; + +function requestEvent() { + return mkMatrixEvent({ + roomId, sender: agent, type: ElicitationEventType.Request, eventId: "$ereq", + content: { version: 1, request_id: "e1", session_id: "s1", tool_call_id: "tc1", message: "Which env?", requested_schema: schema, ...thread }, + }); +} + +function setup(opts: { canAnswer?: boolean; sendEvent?: ReturnType } = {}) { + const client = makeFakeClient({ userId: me }); + const room = makeRoom(roomId, { client, myUserId: me, powerLevels: { [me]: 50 } }); + if (opts.canAnswer === false) { + injectStateEvent(room, mkMatrixEvent({ + roomId, sender: "@admin:h.example", type: EventType.RoomPowerLevels, stateKey: "", + content: { users: { [me]: 0 }, users_default: 0, events_default: 0, state_default: 50, events: { [ElicitationEventType.Response]: 50 } }, + })); + } + (client as unknown as { getRoom: () => unknown }).getRoom = () => room; + const sendEvent = opts.sendEvent ?? vi.fn().mockResolvedValue({ event_id: "$mine" }); + (client as unknown as { sendEvent: unknown }).sendEvent = sendEvent; + MatrixClientPeg.injectClientForTest(client); + const req = requestEvent(); + pushTimelineEvent(room, req); + return { client, room, req, sendEvent }; +} + +afterEach(() => MatrixClientPeg.reset()); + +describe(" (Matrix-wired)", () => { + it("submits an accept response that references the request and its thread", async () => { + const { req, sendEvent } = setup(); + render(); + await userEvent.click(screen.getByRole("radio", { name: "Production" })); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + expect(sendEvent).toHaveBeenCalledWith(roomId, ElicitationEventType.Response, { + version: 1, request_id: "e1", request_event_id: "$ereq", action: "accept", content: { env: "prod" }, ...thread, + }); + // Not shown as accepted until the daemon confirms. + expect(await screen.findByText(/waiting for confirmation/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + }); + + it.each([["Skip", "decline"], ["Cancel", "cancel"]] as const)("%s sends action %s without content", async (label, action) => { + const { req, sendEvent } = setup(); + render(); + await userEvent.click(screen.getByRole("button", { name: label })); + expect(sendEvent).toHaveBeenCalledWith(roomId, ElicitationEventType.Response, { + version: 1, request_id: "e1", request_event_id: "$ereq", action, ...thread, + }); + }); + + it("goes read-only when the requesting agent publishes resolved", async () => { + const { room, req } = setup(); + render(); + act(() => pushTimelineEvent(room, mkMatrixEvent({ + roomId, sender: agent, type: ElicitationEventType.Resolved, + content: { version: 1, request_id: "e1", request_event_id: "$ereq", status: "accepted", responded_by: "@bob:h.example", ...thread }, + }))); + expect(await screen.findByText(/answered by/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument(); + }); + + it("ignores a resolved event forged by someone other than the agent", async () => { + const { room, req } = setup(); + render(); + act(() => pushTimelineEvent(room, mkMatrixEvent({ + roomId, sender: "@mallory:h.example", type: ElicitationEventType.Resolved, + content: { version: 1, request_id: "e1", status: "accepted", responded_by: "@mallory:h.example", ...thread }, + }))); + expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled(); + }); + + it("shows the daemon's field errors for my rejected response and re-enables the form", async () => { + const { room, req } = setup(); + render(); + await userEvent.click(screen.getByRole("radio", { name: "Production" })); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + act(() => pushTimelineEvent(room, mkMatrixEvent({ + roomId, sender: agent, type: ElicitationEventType.Rejected, + content: { version: 1, request_id: "e1", request_event_id: "$ereq", response_event_id: "$mine", reason: "invalid", errors: { env: "must be one of: staging, prod" }, ...thread }, + }))); + expect(await screen.findByText("must be one of: staging, prod")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled()); + }); + + it("shows stale feedback when the daemon is no longer waiting", async () => { + const { room, req } = setup(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Skip" })); + act(() => pushTimelineEvent(room, mkMatrixEvent({ + roomId, sender: agent, type: ElicitationEventType.Rejected, + content: { version: 1, request_id: "e1", request_event_id: "$ereq", response_event_id: "$mine", reason: "stale", ...thread }, + }))); + expect(await screen.findByRole("alert")).toHaveTextContent(/no longer waiting/i); + }); + + it("surfaces a send failure and re-enables the form", async () => { + const { req } = setup({ sendEvent: vi.fn().mockRejectedValue(new Error("M_FORBIDDEN")) }); + render(); + await userEvent.click(screen.getByRole("button", { name: "Skip" })); + expect(await screen.findByRole("alert")).toHaveTextContent("M_FORBIDDEN"); + expect(screen.getByRole("button", { name: "Skip" })).toBeEnabled(); + }); + + it("hides the controls when the user may not send responses", () => { + const { req } = setup({ canAnswer: false }); + render(); + expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument(); + expect(screen.getByText(/insufficient permission/i)).toBeInTheDocument(); + }); +}); + +describe('confirmed answers after reload', () => { + it('displays the accepted values for all viewers', () => { + const { room, req } = setup(); + pushTimelineEvent(room, mkMatrixEvent({ + roomId, sender: agent, type: ElicitationEventType.Resolved, + content: { version: 1, request_id: "e1", request_event_id: "$ereq", status: "accepted", responded_by: "@bob:h.example", content: { env: "prod" }, ...thread }, + })); + render(); + expect(screen.getByRole("radio", { name: "Production" })).toBeChecked(); + expect(screen.getByRole("radio", { name: "Production" })).toBeDisabled(); + }); +}); diff --git a/packages/web/src/components/timeline/question-card.tsx b/packages/web/src/components/timeline/question-card.tsx new file mode 100644 index 0000000..2f22ae9 --- /dev/null +++ b/packages/web/src/components/timeline/question-card.tsx @@ -0,0 +1,40 @@ +import type { MatrixEvent } from "matrix-js-sdk"; +import { useMemo } from "react"; +import { ElicitationEventType } from "../../events/elicitation"; +import { buildFormModel } from "../../lib/elicitation-form"; +import { useElicitation } from "../../hooks/use-elicitation"; +import { useMyPowerLevel } from "../../hooks/use-my-power-level"; +import { useUserName } from "../../hooks/use-user-name"; +import { QuestionCardView } from "./question-card-view"; + +export function QuestionCard({ event }: { event: MatrixEvent }) { + const roomId = event.getRoomId() ?? ""; + const { decoded, state, resolution, rejection, error, send } = useElicitation(event); + const power = useMyPowerLevel(roomId); + const agentName = useUserName(decoded?.sender ?? "", roomId); + const responder = useUserName(resolution?.respondedBy ?? "", roomId); + const model = useMemo( + () => (decoded ? buildFormModel(decoded.requestedSchema) : null), + // The schema of a published request never changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + [decoded?.requestId], + ); + if (!decoded || !model) return null; + return ( + void send("accept", content)} + onSkip={() => void send("decline")} + onCancel={() => void send("cancel")} + /> + ); +} diff --git a/packages/web/src/events/elicitation.test.ts b/packages/web/src/events/elicitation.test.ts new file mode 100644 index 0000000..d331ecb --- /dev/null +++ b/packages/web/src/events/elicitation.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { mkMatrixEvent } from "../../test/factories"; +import { + ElicitationEventType, + decodeElicitationRequest, + decodeElicitationResolved, + findElicitationResolution, + findElicitationRejection, + openElicitationSenders, +} from "./elicitation"; + +const room = "!r:h.example"; +const agent = "@architect.acme:h.example"; +const schema = { type: "object", properties: { env: { type: "string", enum: ["a", "b"] } } }; + +function req(id = "e1", eventId = "$ereq") { + return mkMatrixEvent({ + roomId: room, sender: agent, type: ElicitationEventType.Request, eventId, + content: { + version: 1, request_id: id, session_id: "s1", tool_call_id: "tc1", + message: "Which env?", requested_schema: schema, + "m.relates_to": { rel_type: "m.thread", event_id: "$root" }, + }, + }); +} +function resolved(id = "e1", status = "accepted", sender = agent) { + return mkMatrixEvent({ + roomId: room, sender, type: ElicitationEventType.Resolved, + content: { version: 1, request_id: id, request_event_id: "$ereq", status, responded_by: "@me:h.example" }, + }); +} + +describe("decodeElicitationRequest", () => { + it("decodes a request", () => { + expect(decodeElicitationRequest(req())).toEqual({ + requestId: "e1", requestEventId: "$ereq", sessionId: "s1", toolCallId: "tc1", + message: "Which env?", requestedSchema: schema, sender: agent, threadRoot: "$root", + }); + }); + it("rejects wrong type or missing fields", () => { + const bad = mkMatrixEvent({ roomId: room, sender: agent, type: ElicitationEventType.Request, content: { request_id: "e1" } }); + expect(decodeElicitationRequest(bad)).toBeNull(); + }); +}); + +describe("resolution lookup", () => { + it("only trusts resolved events sent by the requesting agent", () => { + const events = [req(), resolved("e1", "accepted", "@mallory:h.example")]; + expect(findElicitationResolution(events, "e1", agent)).toBeNull(); + const ok = [req(), resolved("e1", "accepted")]; + expect(findElicitationResolution(ok, "e1", agent)).toEqual({ + requestId: "e1", status: "accepted", respondedBy: "@me:h.example", reason: undefined, + }); + }); + it("decodes resolved statuses and rejects unknown ones", () => { + expect(decodeElicitationResolved(resolved("e1", "interrupted"))?.status).toBe("interrupted"); + expect(decodeElicitationResolved(resolved("e1", "bogus"))).toBeNull(); + }); + it("finds the rejection for a given response event", () => { + const rej = mkMatrixEvent({ + roomId: room, sender: agent, type: ElicitationEventType.Rejected, + content: { request_id: "e1", response_event_id: "$mine", reason: "invalid", errors: { env: "required" } }, + }); + expect(findElicitationRejection([rej], "e1", "$mine", agent)).toEqual({ + reason: "invalid", errors: { env: "required" }, + }); + expect(findElicitationRejection([rej], "e1", "$other", agent)).toBeNull(); + }); +}); + +describe("openElicitationSenders", () => { + it("lists agents with an unresolved request, once each", () => { + const events = [req("e1", "$a"), req("e2", "$b"), resolved("e1")]; + expect(openElicitationSenders(events)).toEqual([agent]); + expect(openElicitationSenders([...events, resolved("e2")])).toEqual([]); + }); +}); diff --git a/packages/web/src/events/elicitation.ts b/packages/web/src/events/elicitation.ts new file mode 100644 index 0000000..8b44688 --- /dev/null +++ b/packages/web/src/events/elicitation.ts @@ -0,0 +1,130 @@ +import type { MatrixEvent } from "matrix-js-sdk"; + +export const ElicitationEventType = { + Request: "dev.zooid.elicitation_request", + Response: "dev.zooid.elicitation_response", + Resolved: "dev.zooid.elicitation_resolved", + Rejected: "dev.zooid.elicitation_rejected", +} as const; + +export type ElicitationStatus = "accepted" | "declined" | "cancelled" | "interrupted"; +const STATUSES = new Set(["accepted", "declined", "cancelled", "interrupted"]); + +export interface ElicitationRequestView { + requestId: string; + requestEventId: string; + sessionId: string; + toolCallId?: string; + message: string; + /** The ACP schema, untouched. Untrusted display data. */ + requestedSchema: Record; + sender: string; + threadRoot?: string; +} + +export interface ElicitationResolutionView { + requestId: string; + status: ElicitationStatus; + respondedBy?: string; + reason?: string; + content?: Record; +} + +export interface ElicitationRejectionView { + reason: "invalid" | "stale"; + errors?: Record; +} + +const isObj = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +function threadRootOf(ev: MatrixEvent): string | undefined { + const rel = ev.getRelation(); + return rel?.rel_type === "m.thread" && rel.event_id ? rel.event_id : undefined; +} + +export function decodeElicitationRequest(ev: MatrixEvent): ElicitationRequestView | null { + if (ev.getType() !== ElicitationEventType.Request) return null; + const c = ev.getContent() as Record; + const id = ev.getId(); + const sender = ev.getSender(); + if (!id || !sender) return null; + if (typeof c.request_id !== "string" || typeof c.session_id !== "string") return null; + if (typeof c.message !== "string" || !isObj(c.requested_schema)) return null; + const threadRoot = threadRootOf(ev); + return { + requestId: c.request_id, + requestEventId: id, + sessionId: c.session_id, + ...(typeof c.tool_call_id === "string" ? { toolCallId: c.tool_call_id } : {}), + message: c.message, + requestedSchema: c.requested_schema, + sender, + ...(threadRoot ? { threadRoot } : {}), + }; +} + +export function decodeElicitationResolved(ev: MatrixEvent): ElicitationResolutionView | null { + if (ev.getType() !== ElicitationEventType.Resolved) return null; + const c = ev.getContent() as Record; + if (typeof c.request_id !== "string" || typeof c.status !== "string" || !STATUSES.has(c.status)) return null; + return { + requestId: c.request_id, + status: c.status as ElicitationStatus, + respondedBy: typeof c.responded_by === "string" ? c.responded_by : undefined, + reason: typeof c.reason === "string" ? c.reason : undefined, + ...(isObj(c.content) ? { content: c.content } : {}), + }; +} + +/** Only the requesting agent can close its own question. */ +export function findElicitationResolution( + events: MatrixEvent[], + requestId: string, + agentSender: string, + request?: ElicitationRequestView, +): ElicitationResolutionView | null { + for (const ev of events) { + if (ev.getSender() !== agentSender) continue; + if (request && (ev.getContent().request_event_id !== request.requestEventId || threadRootOf(ev) !== request.threadRoot)) continue; + const r = decodeElicitationResolved(ev); + if (r && r.requestId === requestId) return r; + } + return null; +} + +export function findElicitationRejection( + events: MatrixEvent[], + requestId: string, + responseEventId: string, + agentSender: string, + request?: ElicitationRequestView, +): ElicitationRejectionView | null { + for (const ev of events) { + if (ev.getType() !== ElicitationEventType.Rejected || ev.getSender() !== agentSender) continue; + const c = ev.getContent() as Record; + if (c.request_id !== requestId || c.response_event_id !== responseEventId) continue; + if (request && (c.request_event_id !== request.requestEventId || threadRootOf(ev) !== request.threadRoot)) continue; + if (c.reason !== "invalid" && c.reason !== "stale") continue; + const errors = isObj(c.errors) + ? Object.fromEntries(Object.entries(c.errors).filter(([, v]) => typeof v === "string")) as Record + : undefined; + return { reason: c.reason, ...(errors ? { errors } : {}) }; + } + return null; +} + +/** Agents that asked a question nobody has resolved yet, in first-asked order. */ +export function openElicitationSenders(events: MatrixEvent[]): string[] { + const open = new Map(); // requestId → sender + for (const ev of events) { + const req = decodeElicitationRequest(ev); + if (req) { + open.set(req.requestId, req.sender); + continue; + } + const res = decodeElicitationResolved(ev); + if (res && open.get(res.requestId) === ev.getSender()) open.delete(res.requestId); + } + return [...new Set(open.values())]; +} diff --git a/packages/web/src/hooks/use-awaiting-input.test.tsx b/packages/web/src/hooks/use-awaiting-input.test.tsx new file mode 100644 index 0000000..a9521cd --- /dev/null +++ b/packages/web/src/hooks/use-awaiting-input.test.tsx @@ -0,0 +1,39 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { makeFakeClient, makeRoom, mkMatrixEvent, pushTimelineEvent } from "../../test/factories"; +import { MatrixClientPeg } from "../client/peg"; +import { ElicitationEventType } from "../events/elicitation"; +import { useAwaitingInput } from "./use-timeline"; + +const me = "@me:h.example"; +const agent = "@architect.acme:h.example"; +const roomId = "!r:h.example"; +const thread = { "m.relates_to": { rel_type: "m.thread", event_id: "$root" } }; + +afterEach(() => MatrixClientPeg.reset()); + +describe("useAwaitingInput", () => { + it("tracks agents with open questions, including threaded ones", () => { + const client = makeFakeClient({ userId: me }); + const room = makeRoom(roomId, { client, myUserId: me }); + (client as unknown as { getRoom: () => unknown }).getRoom = () => room; + MatrixClientPeg.injectClientForTest(client); + const { result } = renderHook(() => useAwaitingInput(roomId)); + expect(result.current).toEqual([]); + act(() => pushTimelineEvent(room, mkMatrixEvent({ + roomId, sender: agent, type: ElicitationEventType.Request, eventId: "$ereq", + content: { version: 1, request_id: "e1", session_id: "s1", message: "q", requested_schema: { type: "object", properties: {} }, ...thread }, + }))); + expect(result.current).toEqual([agent]); + const first = result.current; + act(() => pushTimelineEvent(room, mkMatrixEvent({ + roomId, sender: me, type: "m.room.message", content: { msgtype: "m.text", body: "hi" }, + }))); + expect(result.current).toBe(first); // stable reference while unchanged + act(() => pushTimelineEvent(room, mkMatrixEvent({ + roomId, sender: agent, type: ElicitationEventType.Resolved, + content: { version: 1, request_id: "e1", status: "accepted", ...thread }, + }))); + expect(result.current).toEqual([]); + }); +}); diff --git a/packages/web/src/hooks/use-elicitation.ts b/packages/web/src/hooks/use-elicitation.ts new file mode 100644 index 0000000..0f4a75f --- /dev/null +++ b/packages/web/src/hooks/use-elicitation.ts @@ -0,0 +1,70 @@ +import type { MatrixEvent } from "matrix-js-sdk"; +import { useCallback, useRef, useState } from "react"; +import { MatrixClientPeg } from "../client/peg"; +import { + ElicitationEventType, + decodeElicitationRequest, + findElicitationRejection, + findElicitationResolution, +} from "../events/elicitation"; +import { useElicitationTrail } from "./use-timeline"; +import type { QuestionCardState } from "../components/timeline/question-card-view"; + +type Action = "accept" | "decline" | "cancel"; + +export function useElicitation(requestEvent: MatrixEvent) { + const decoded = decodeElicitationRequest(requestEvent); + const roomId = requestEvent.getRoomId() ?? ""; + const trail = useElicitationTrail(roomId, decoded?.requestId ?? ""); + const resolution = decoded ? findElicitationResolution(trail, decoded.requestId, decoded.sender, decoded) : null; + const [mine, setMine] = useState(null); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const inFlight = useRef(false); + const rejection = + decoded && mine ? findElicitationRejection(trail, decoded.requestId, mine, decoded.sender, decoded) : null; + + const send = useCallback( + async (action: Action, content?: Record) => { + if (!decoded || resolution || inFlight.current) return; + inFlight.current = true; + setSending(true); + setError(null); + try { + const body: Record = { + version: 1, + request_id: decoded.requestId, + request_event_id: decoded.requestEventId, + action, + ...(action === "accept" ? { content: content ?? {} } : {}), + ...(decoded.threadRoot + ? { "m.relates_to": { rel_type: "m.thread", event_id: decoded.threadRoot } } + : {}), + }; + const { event_id } = await (MatrixClientPeg.get().sendEvent as ( + roomId: string, + type: string, + content: Record, + ) => Promise<{ event_id: string }>)(roomId, ElicitationEventType.Response, body); + setMine(event_id); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSending(false); + inFlight.current = false; + } + }, + [decoded, resolution, roomId], + ); + + // Never show a submission as accepted before the daemon confirms it. + const state: QuestionCardState = resolution + ? "resolved" + : sending + ? "sending" + : mine && !rejection + ? "awaiting_confirmation" + : "open"; + + return { decoded, state, resolution, rejection, error, send }; +} diff --git a/packages/web/src/hooks/use-timeline.ts b/packages/web/src/hooks/use-timeline.ts index 8ee2d18..fbe8c39 100644 --- a/packages/web/src/hooks/use-timeline.ts +++ b/packages/web/src/hooks/use-timeline.ts @@ -9,6 +9,7 @@ import { RoomEvent, } from "matrix-js-sdk"; import { useSyncExternalStore } from "react"; +import { ElicitationEventType, openElicitationSenders } from "../events/elicitation"; import { MatrixClientPeg } from "../client/peg"; import { extractToolCallContent, type DiffBlock } from "../events/zooid-events"; @@ -554,3 +555,56 @@ export function useToolCallApproval(roomId: string, toolCallId: string): ToolCal () => TOOL_CALL_APPROVAL_EMPTY, ); } + + +const NO_EVENTS: MatrixEvent[] = []; +const NO_USERS: string[] = []; +const trailCache = new Map(); +const awaitingCache = new Map(); + +function snapshotElicitationTrail(roomId: string, requestId: string): MatrixEvent[] { + const room = MatrixClientPeg.safeGet()?.getRoom(roomId); + if (!room || !requestId) return NO_EVENTS; + const hits = allRoomEvents(room).filter( + (ev) => + (ev.getType() === ElicitationEventType.Resolved || ev.getType() === ElicitationEventType.Rejected) && + (ev.getContent() as { request_id?: unknown }).request_id === requestId, + ); + const key = `${hits.length}:${hits.map((e) => e.getId()).join(",")}`; + const cacheKey = `${roomId}|${requestId}`; + const prev = trailCache.get(cacheKey); + if (prev && prev.room === room && prev.key === key) return prev.value; + const value = hits.length > 0 ? hits : NO_EVENTS; + trailCache.set(cacheKey, { room, key, value }); + return value; +} + +/** Resolved/rejected events for one question, thread-related ones included. */ +export function useElicitationTrail(roomId: string, requestId: string): MatrixEvent[] { + return useSyncExternalStore( + makeSubscribe(roomId), + () => snapshotElicitationTrail(roomId, requestId), + () => NO_EVENTS, + ); +} + +function snapshotAwaiting(roomId: string): string[] { + const room = MatrixClientPeg.safeGet()?.getRoom(roomId); + if (!room) return NO_USERS; + const senders = openElicitationSenders( + allRoomEvents(room).filter( + (ev) => ev.getType() === ElicitationEventType.Request || ev.getType() === ElicitationEventType.Resolved, + ), + ); + const key = senders.join(","); + const prev = awaitingCache.get(roomId); + if (prev && prev.key === key) return prev.value; + const value = senders.length > 0 ? senders : NO_USERS; + awaitingCache.set(roomId, { key, value }); + return value; +} + +/** Agents in this room waiting on a human answer. */ +export function useAwaitingInput(roomId: string): string[] { + return useSyncExternalStore(makeSubscribe(roomId), () => snapshotAwaiting(roomId), () => NO_USERS); +} diff --git a/packages/web/src/lib/elicitation-form.test.ts b/packages/web/src/lib/elicitation-form.test.ts new file mode 100644 index 0000000..de5837a --- /dev/null +++ b/packages/web/src/lib/elicitation-form.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { buildFormModel, toSubmission } from "./elicitation-form"; + +const askSchema = { + type: "object", + properties: { + question_0: { + type: "string", title: "Env", description: "Which environment?", + oneOf: [{ const: "staging", title: "Staging" }, { const: "prod", title: "Production", description: "Live" }], + }, + question_0_custom: { + type: "string", title: "Other", + _meta: { _askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true } }, + }, + question_1: { + type: "array", title: "Checks", + items: { anyOf: [{ const: "lint", title: "Lint" }, { const: "test", title: "Test" }] }, + }, + question_1_custom: { + type: "string", title: "Other", + _meta: { _askUserQuestionCustomAnswer: { questionId: "question_1", isCustomAnswer: true } }, + }, + }, +}; + +const typed = { + type: "object", + properties: { + name: { type: "string", minLength: 2, maxLength: 5, default: "abc" }, + count: { type: "integer", minimum: 1, maximum: 3 }, + ok: { type: "boolean", default: true }, + tags: { type: "array", items: { type: "string", enum: ["a", "b", "c"] }, maxItems: 2 }, + }, + required: ["name", "ok"], +}; + +describe("buildFormModel", () => { + it("builds select + multiselect fields and groups custom answers under their question", () => { + const m = buildFormModel(askSchema); + expect(m.unsupported).toEqual([]); + expect(m.fields.map((f) => [f.key, f.kind, f.customKey])).toEqual([ + ["question_0", "select", "question_0_custom"], + ["question_1", "multiselect", "question_1_custom"], + ]); + expect(m.fields[0]!.options).toEqual([ + { value: "staging", label: "Staging", description: undefined }, + { value: "prod", label: "Production", description: "Live" }, + ]); + }); + + it("keeps an unrecognized custom field as a plain text field rather than dropping it", () => { + const m = buildFormModel({ + type: "object", + properties: { note: { type: "string", _meta: { _askUserQuestionCustomAnswer: { questionId: "ghost" } } } }, + }); + expect(m.fields.map((f) => [f.key, f.kind])).toEqual([["note", "text"]]); + }); + + it("maps primitive types and required flags, with defaults as initial values", () => { + const m = buildFormModel(typed); + expect(m.fields.map((f) => [f.key, f.kind, f.required])).toEqual([ + ["name", "text", true], ["count", "integer", false], ["ok", "boolean", true], ["tags", "multiselect", false], + ]); + expect(m.initialValues).toEqual({ name: "abc", ok: true }); + }); + + it("reports unsupported property types instead of coercing them", () => { + const m = buildFormModel({ type: "object", properties: { o: { type: "object" } } }); + expect(m.unsupported).toEqual(["o"]); + }); +}); + +describe("toSubmission (serializes the form; the daemon validates)", () => { + it("drops empty optional values and passes a valid answer", () => { + const m = buildFormModel(askSchema); + expect(toSubmission(m, { question_0: "prod", question_0_custom: "", question_1: [] })).toEqual({ + ok: true, content: { question_0: "prod" }, + }); + }); + it("flags empty required fields", () => { + const m = buildFormModel(typed); + expect(toSubmission(m, { name: "", ok: undefined })).toEqual({ + ok: false, errors: { name: "required", ok: "required" }, + }); + }); + it("leaves schema rules to the daemon: out-of-bounds values pass through", () => { + const m = buildFormModel(typed); + expect(toSubmission(m, { name: "x", count: "9", ok: true, tags: ["a", "b", "c"] })).toEqual({ + ok: true, content: { name: "x", count: 9, ok: true, tags: ["a", "b", "c"] }, + }); + }); + it("parses integer/number inputs from strings and rejects non-numbers", () => { + const m = buildFormModel(typed); + expect(toSubmission(m, { name: "ab", ok: false, count: "2" })).toEqual({ + ok: true, content: { name: "ab", ok: false, count: 2 }, + }); + expect(toSubmission(m, { name: "ab", ok: false, count: "two" })).toEqual({ + ok: false, errors: { count: "must be an integer" }, + }); + }); +}); diff --git a/packages/web/src/lib/elicitation-form.ts b/packages/web/src/lib/elicitation-form.ts new file mode 100644 index 0000000..f0a9f46 --- /dev/null +++ b/packages/web/src/lib/elicitation-form.ts @@ -0,0 +1,149 @@ +// Builds the card from the request schema and serializes the answer. It doesn't +// validate schema rules: the daemon does (@zooid/core elicitation-schema.ts) and +// returns per-field errors in elicitation_rejected. + +export type FieldKind = "text" | "number" | "integer" | "boolean" | "select" | "multiselect"; + +export interface FieldOption { + value: string; + label: string; + description?: string; +} + +export interface FormField { + key: string; + kind: FieldKind; + label: string; + description?: string; + required: boolean; + options?: FieldOption[]; + /** Grouped free-text answer (Claude's per-question "Other"). */ + customKey?: string; + customLabel?: string; + customDescription?: string; +} + +export interface FormModel { + schema: Record; + fields: FormField[]; + /** Property keys this client cannot render. */ + unsupported: string[]; + initialValues: Record; +} + +export type FormValues = Record; +export type Submission = + | { ok: true; content: Record } + | { ok: false; errors: Record }; + +const CUSTOM_META = "_askUserQuestionCustomAnswer"; +type Prop = Record; +const isObj = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); +const str = (v: unknown) => (typeof v === "string" ? v : undefined); + +function optionsOf(p: Prop): FieldOption[] | undefined { + if (Array.isArray(p.oneOf)) { + return p.oneOf.flatMap((o) => + isObj(o) && typeof o.const === "string" + ? [{ value: o.const, label: str(o.title) ?? o.const, description: str(o.description) }] + : [], + ); + } + if (Array.isArray(p.enum)) { + return p.enum.flatMap((v) => (typeof v === "string" ? [{ value: v, label: v }] : [])); + } + return undefined; +} + +function itemOptionsOf(p: Prop): FieldOption[] | undefined { + const items = p.items; + if (!isObj(items)) return undefined; + if (Array.isArray(items.anyOf)) return optionsOf({ oneOf: items.anyOf }); + if (items.type === "string" && Array.isArray(items.enum)) return optionsOf({ enum: items.enum }); + return undefined; +} + +function customTarget(p: Prop): string | undefined { + const meta = isObj(p._meta) ? p._meta[CUSTOM_META] : undefined; + return isObj(meta) && meta.isCustomAnswer === true ? str(meta.questionId) : undefined; +} + +export function buildFormModel(schema: Record): FormModel { + const props = (isObj(schema.properties) ? schema.properties : {}) as Record; + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + const fields: FormField[] = []; + const unsupported: string[] = []; + const initialValues: Record = Object.create(null); + const choiceKeys = new Set( + Object.entries(props) + .filter(([, p]) => (p.type === "string" && optionsOf(p)) || (p.type === "array" && itemOptionsOf(p))) + .map(([k]) => k), + ); + + for (const [key, p] of Object.entries(props)) { + if (p.default !== undefined && p.default !== null) initialValues[key] = p.default; + const target = customTarget(p); + if (target && choiceKeys.has(target)) continue; // grouped below + let kind: FieldKind | undefined; + let options: FieldOption[] | undefined; + if (p.type === "string") { + options = optionsOf(p); + kind = options ? "select" : "text"; + } else if (p.type === "number" || p.type === "integer" || p.type === "boolean") { + kind = p.type; + } else if (p.type === "array") { + options = itemOptionsOf(p); + if (options) kind = "multiselect"; + } + if (!kind) { + unsupported.push(key); + continue; + } + const field: FormField = { + key, + kind, + label: str(p.title) ?? key, + description: str(p.description), + required: required.has(key), + ...(options ? { options } : {}), + }; + if (kind === "select" || kind === "multiselect") { + const custom = Object.entries(props).find(([, cp]) => customTarget(cp) === key); + if (custom) { + field.customKey = custom[0]; + field.customLabel = str(custom[1].title) ?? "Other"; + field.customDescription = str(custom[1].description); + } + } + fields.push(field); + // Defaults are editable suggestions: pre-filled, never auto-submitted. + if (p.default !== undefined && p.default !== null) initialValues[key] = p.default; + } + return { schema, fields, unsupported, initialValues }; +} + +export function toSubmission(model: FormModel, values: FormValues): Submission { + const props = (isObj(model.schema.properties) ? model.schema.properties : {}) as Record; + const required = new Set(Array.isArray(model.schema.required) ? model.schema.required : []); + const errors: Record = Object.create(null); + const content: Record = Object.create(null); + for (const [key, p] of Object.entries(props)) { + let v = values[key]; + if ((p.type === "number" || p.type === "integer") && typeof v === "string" && v.trim() !== "") { + v = Number(v); + if (!Number.isFinite(v)) { + errors[key] = p.type === "integer" ? "must be an integer" : "must be a number"; + continue; + } + } + if ((p.type === "number" || p.type === "integer") && typeof v === "string" && v.trim() === "") v = undefined; + const empty = v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0); + if (empty) { + if (required.has(key)) errors[key] = "required"; + continue; + } + content[key] = v as string | number | boolean | string[]; + } + return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, content }; +} diff --git a/packages/web/src/lib/matrix/notification-prefs.test.ts b/packages/web/src/lib/matrix/notification-prefs.test.ts index b85a7b3..f11948c 100644 --- a/packages/web/src/lib/matrix/notification-prefs.test.ts +++ b/packages/web/src/lib/matrix/notification-prefs.test.ts @@ -294,12 +294,12 @@ describe("agent push rules", () => { return { client: client as unknown as MatrixClient, addPushRule }; } - it("installs all three rules as plain overrides — no before/after, which Matrix forbids against a server-default rule", async () => { + it("installs all four rules as plain overrides — no before/after, which Matrix forbids against a server-default rule", async () => { const { client, addPushRule } = clientWithPushRules(); await ensureAgentPushRules(client); const ids = addPushRule.mock.calls.map((c) => c[2]); - expect(ids).toEqual(["dev.zooid.approval_request", "dev.zooid.turn.end", "dev.zooid.error"]); + expect(ids).toEqual(["dev.zooid.approval_request", "dev.zooid.turn.end", "dev.zooid.error", "dev.zooid.elicitation_request"]); for (const call of addPushRule.mock.calls) { expect(call[0]).toBe("global"); expect(call[1]).toBe(PushRuleKind.Override); @@ -330,6 +330,7 @@ describe("agent push rules", () => { { set_tweak: "highlight", value: true }, ]); expect(actionsFor("dev.zooid.error")).toEqual(["notify"]); + expect(actionsFor("dev.zooid.elicitation_request")).toEqual(["notify", { set_tweak: "highlight", value: true }]); }); it("is idempotent — re-running does not duplicate", async () => { diff --git a/packages/web/src/lib/matrix/notification-prefs.ts b/packages/web/src/lib/matrix/notification-prefs.ts index b222167..d2c4c50 100644 --- a/packages/web/src/lib/matrix/notification-prefs.ts +++ b/packages/web/src/lib/matrix/notification-prefs.ts @@ -170,6 +170,7 @@ export const AGENT_RULES = [ actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }], }, { id: "dev.zooid.error", actions: [PushRuleActionName.Notify] }, + { id: "dev.zooid.elicitation_request", actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }] }, ] as const; export const AGENT_RULE_IDS = AGENT_RULES.map((r) => r.id); diff --git a/packages/web/src/lib/matrix/notifications.test.ts b/packages/web/src/lib/matrix/notifications.test.ts index d46c19d..fed3260 100644 --- a/packages/web/src/lib/matrix/notifications.test.ts +++ b/packages/web/src/lib/matrix/notifications.test.ts @@ -78,6 +78,12 @@ describe("evaluateNotification — agent event types", () => { return { client, room }; } + it("names the agent and shows its elicitation question", () => { + const { client, room } = setupAgent(); + const ev = makeMatrixEvent({ eventId: "$q1", roomId, type: "dev.zooid.elicitation_request", sender: "@claude:example.org", content: { request_id: "e1", message: "Which environment?" } }); + expect(evaluateNotification(client, room, ev)).toEqual({ roomId, eventId: "$q1", title: "general", body: "claude asks: Which environment?" }); + }); + it("renders an approval request without a message preview", () => { const { client, room } = setupAgent(); const ev = makeMatrixEvent({ diff --git a/packages/web/src/lib/matrix/notifications.ts b/packages/web/src/lib/matrix/notifications.ts index 56b3cab..aa68589 100644 --- a/packages/web/src/lib/matrix/notifications.ts +++ b/packages/web/src/lib/matrix/notifications.ts @@ -7,6 +7,7 @@ const MAX_BODY = 140; // deliberately excluded — never listed here, never notified. const AGENT_EVENT_TYPES = new Set([ "dev.zooid.approval_request", + "dev.zooid.elicitation_request", "dev.zooid.turn.end", "dev.zooid.error", ]); @@ -50,6 +51,9 @@ export function evaluateNotification( // No prose to preview — tool_title is the closest thing to a summary. const title = typeof content.tool_title === "string" ? (content.tool_title as string) : "a tool call"; body = `${agentNameFor(room, senderId)} needs approval: ${title}`; + } else if (type === "dev.zooid.elicitation_request") { + const question = typeof content.message === "string" ? content.message : "a question"; + body = `${agentNameFor(room, senderId)} asks: ${question}`; } else { // dev.zooid.turn.end / dev.zooid.error already carry a human-readable // body from the daemon (toTurnEndBody / toErrorBody).