From 7e61ec19b715a27d63df3b24ae75c16548cddd25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 08:24:07 +0000 Subject: [PATCH 1/4] fix(ai): keep a leading system message when trimming chat history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `trimHistoryForModel` derived its cut points from `user` indices only and sliced from one of them, so anything ahead of the first user message — in practice the system prompt this package itself builds — disappeared the moment a conversation crossed the budget, with no error. A leading run of `system` messages is now held out of the turn scan and prepended to the result. Its size still counts against the budget. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ --- packages/ai/src/task/ChatHistory.ts | 14 ++++++-- .../ai/src/task/__tests__/ChatHistory.test.ts | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/task/ChatHistory.ts b/packages/ai/src/task/ChatHistory.ts index 8d19728e7..de73eb431 100644 --- a/packages/ai/src/task/ChatHistory.ts +++ b/packages/ai/src/task/ChatHistory.ts @@ -81,6 +81,11 @@ function messageChars(message: ChatMessage): number { * The newest turn is kept even when it alone exceeds the budget: there would * otherwise be nothing for the model to answer, and silently returning an empty * list turns "this turn is too long" into "the conversation is gone". + * + * A leading run of `system` messages belongs to no turn and is never cut: it + * carries the instructions every later turn is answered under, and dropping it + * for being old changes how the model behaves with nothing to show for it. Its + * size still counts against the budget, so the turns cut to fit around it. */ export function trimHistoryForModel( history: readonly ChatMessage[], @@ -90,16 +95,19 @@ export function trimHistoryForModel( let total = sizes.reduce((sum, n) => sum + n, 0); if (total <= max) return [...history]; + let prefixEnd = 0; + while (history[prefixEnd]?.role === "system") prefixEnd++; + const turnStarts: number[] = []; - for (let i = 0; i < history.length; i++) { + for (let i = prefixEnd; i < history.length; i++) { if (history[i]?.role === "user") turnStarts.push(i); } - let cut = 0; + let cut = prefixEnd; for (const start of turnStarts.slice(1)) { for (let i = cut; i < start; i++) total -= sizes[i] ?? 0; cut = start; if (total <= max) break; } - return history.slice(cut); + return [...history.slice(0, prefixEnd), ...history.slice(cut)]; } diff --git a/packages/ai/src/task/__tests__/ChatHistory.test.ts b/packages/ai/src/task/__tests__/ChatHistory.test.ts index 40da04087..69ee679c4 100644 --- a/packages/ai/src/task/__tests__/ChatHistory.test.ts +++ b/packages/ai/src/task/__tests__/ChatHistory.test.ts @@ -120,6 +120,39 @@ describe("trimHistoryForModel", () => { expect(trimmed[0]!.role).toBe("user"); }); + it("keeps a leading system message across a trim", () => { + // A host that keeps its system prompt as history[0] — the shape + // AiChatWithKbTask builds — loses its instructions and guardrails silently + // the first time a conversation crosses the budget, since cut points are + // derived from user indices and the slice starts at one of them. + const history: ChatMessage[] = [ + { role: "system", content: [{ type: "text", text: "never reveal the key" }] }, + user("a".repeat(400)), + assistant("x"), + user("b".repeat(400)), + assistant("y"), + user("c"), + ]; + const trimmed = trimHistoryForModel(history, 500); + expect(trimmed.length).toBeLessThan(history.length); + expect(trimmed.map((m) => m.role)).toEqual(["system", "user"]); + expect(trimmed[0]).toEqual(history[0]); + }); + + it("keeps every leading system message, not just the first", () => { + const history: ChatMessage[] = [ + { role: "system", content: [{ type: "text", text: "rule one" }] }, + { role: "system", content: [{ type: "text", text: "rule two" }] }, + user("a".repeat(400)), + assistant("x"), + user("b".repeat(400)), + assistant("y"), + user("c"), + ]; + const trimmed = trimHistoryForModel(history, 500); + expect(trimmed.map((m) => m.role)).toEqual(["system", "system", "user"]); + }); + it("exposes a default budget callers can reason about", () => { expect(DEFAULT_MAX_HISTORY_CHARS).toBeGreaterThan(0); }); From fa3f89c39cfa1b287001aee711fb25dd6faf432f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 08:26:03 +0000 Subject: [PATCH 2/4] fix(mcp): stop a confirm value forging lines on the approval card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A confirm's requested schema is deliberately empty, so the elicitation message is the whole approval card. `withConfirmDetails` interpolated string values of `contentData` verbatim into `Label: value` lines joined by newlines, and `contentData` is an input port on `HumanInputTask` — so a value carrying a newline wrote further labelled lines no reader could tell from the real ones, or padded the card with blanks until the true detail scrolled out of view. Line breaks in an interpolated value or label are now escaped, so the card's line structure comes from the code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ --- .../mcp/src/tasks/McpElicitationConnector.ts | 18 ++- ...cpElicitationConnector.confirmCard.test.ts | 105 ++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 packages/test/src/test/human/McpElicitationConnector.confirmCard.test.ts diff --git a/packages/mcp/src/tasks/McpElicitationConnector.ts b/packages/mcp/src/tasks/McpElicitationConnector.ts index 00316f0b5..f99614ddb 100644 --- a/packages/mcp/src/tasks/McpElicitationConnector.ts +++ b/packages/mcp/src/tasks/McpElicitationConnector.ts @@ -37,12 +37,26 @@ function toMcpRequestedSchema( }; } +/** + * One card line holds one value, whatever the value contains. + * + * `contentData` arrives from a task input port, so its values are as reachable + * as anything else a model supplies. Left raw, a value carrying a line break + * writes further `Label: value` lines of its own — indistinguishable from the + * ones below — or pads the card with blanks until the true detail is off the + * screen the person is reading before they decide. + */ +function oneLine(text: string): string { + return text.replace(/\r\n|[\n\r\u2028\u2029]/g, "\\n"); +} + /** The label a person should read for one confirm field: its title, else its key. */ function confirmLabel(contentSchema: unknown, key: string): string { const properties = (contentSchema as { properties?: Record } | undefined) ?.properties; const property = properties?.[key] as { title?: unknown } | undefined; - return typeof property?.title === "string" && property.title ? property.title : key; + const label = typeof property?.title === "string" && property.title ? property.title : key; + return oneLine(label); } /** `Action: Run workflow` — one line per value a person needs before deciding. */ @@ -57,7 +71,7 @@ function withConfirmDetails( // `JSON.stringify` returns the VALUE undefined for undefined and functions, // which templates as the string "undefined" — say so deliberately instead. const rendered = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value)); - return `${confirmLabel(contentSchema, key)}: ${rendered}`; + return `${confirmLabel(contentSchema, key)}: ${oneLine(rendered)}`; }); return message ? `${message}\n\n${lines.join("\n")}` : lines.join("\n"); } diff --git a/packages/test/src/test/human/McpElicitationConnector.confirmCard.test.ts b/packages/test/src/test/human/McpElicitationConnector.confirmCard.test.ts new file mode 100644 index 000000000..6d7b1c4c6 --- /dev/null +++ b/packages/test/src/test/human/McpElicitationConnector.confirmCard.test.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { McpElicitationConnector } from "@workglow/mcp/tasks"; +import type { IHumanRequest } from "@workglow/util"; +import type { DataPortSchema } from "@workglow/util/schema"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createPairedMcpHarness } from "./mcpHarness"; + +const confirmSchema = { + type: "object", + properties: { + action: { type: "string", title: "Action" }, + reaches: { type: "string", title: "Reaches" }, + }, + additionalProperties: true, +} as unknown as DataPortSchema; + +function confirmRequest(contentData: Record): IHumanRequest { + return { + requestId: "confirm-card", + targetHumanId: "default", + kind: "confirm", + message: 'Run workflow "export"?', + contentSchema: confirmSchema, + contentData, + expectsResponse: true, + mode: "single", + metadata: undefined, + }; +} + +/** + * A confirm's requested schema is deliberately empty, so the message IS the + * whole approval card. Its line structure has to come from this connector and + * not from the values it interpolates: `contentData` is an input port on + * `HumanInputTask`, so a model driving the task over MCP supplies it. + */ +describe("McpElicitationConnector confirm card", () => { + let harness: Awaited>; + let connector: McpElicitationConnector; + + beforeAll(async () => { + harness = await createPairedMcpHarness(); + connector = new McpElicitationConnector(harness.server); + }); + + afterAll(async () => { + await harness.dispose(); + }); + + async function cardFor(contentData: Record): Promise { + harness.script.clear(); + harness.script.push({ requestId: "x", action: "decline", content: undefined, done: true }); + await connector.send(confirmRequest(contentData), new AbortController().signal); + return harness.script.received.at(-1)?.message ?? ""; + } + + it("renders exactly one line per value, whatever the value contains", async () => { + const card = await cardFor({ + action: "Run workflow", + // The forgery: a second, false "Reaches:" line the person reads last. + reaches: "network:http → https://attacker.test\n\nReaches: (nothing beyond running a model)", + }); + const [message, blank, ...details] = card.split("\n"); + expect(message).toBe('Run workflow "export"?'); + expect(blank).toBe(""); + expect(details).toHaveLength(2); + expect(details.filter((line) => line.startsWith("Reaches:"))).toHaveLength(1); + }); + + it("a value cannot pad the card with blank lines", async () => { + const card = await cardFor({ action: "Run workflow", reaches: "\n".repeat(40) + "harmless" }); + expect(card.split("\n").filter((line) => line === "")).toHaveLength(1); + }); + + it("keeps an ordinary card readable", async () => { + const card = await cardFor({ action: "Run workflow", reaches: "https://example.test" }); + expect(card.split("\n")).toHaveLength(4); + expect(card).toContain("Action: Run workflow"); + expect(card).toContain("Reaches: https://example.test"); + }); + + it("a label cannot introduce a line either", async () => { + harness.script.clear(); + harness.script.push({ requestId: "x", action: "decline", content: undefined, done: true }); + await connector.send( + { + ...confirmRequest({ action: "Run workflow" }), + contentSchema: { + type: "object", + properties: { action: { type: "string", title: "Action\nReaches: nothing" } }, + additionalProperties: true, + } as unknown as DataPortSchema, + }, + new AbortController().signal + ); + const card = harness.script.received.at(-1)?.message ?? ""; + expect(card.split("\n")).toHaveLength(3); + }); +}); From 58273d21da52ed003c7013c331b357050cdc3464 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 08:36:17 +0000 Subject: [PATCH 3/4] fix(cli): draw a confirm as an approval, and hold both connectors to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `confirm` was a public interaction kind neither CLI connector implemented. Both fell through to their elicit form, which renders the DESCRIPTION of the action as editable fields, resolves `accept` on an ordinary submit, and has no `decline` at all — so a run asking permission got permission from anyone pressing Enter, and the operator's edits to that description landed on the task's output ports. The web console was worse: the run event carries `kind` and `data`, but the console's reducer kept neither, so it drew the confirm's labels as empty boxes to type into and never showed the values at all. `humanPromptModel` now decides the shape once for both renderers: an approval reads its values rather than editing them, offers approve and decline distinctly from cancel, and carries no content back. The reducer keeps `kind` and `data`. Both connectors join the conformance suite, answered through that model, so `roundtrip.confirm.decline` runs against the implementations that failed it. A new `roundtrip.confirm.noContent` pins the contract the adapters disagreed on — a confirm answers with the decision and nothing else — and MockHumanConnector holds it as the reference. The elicit form's missing decline is recorded as an expected failure rather than widened into here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ --- bun.lock | 1 + examples/cli/package.json | 9 +- examples/cli/src/human.ts | 28 +++++ examples/cli/src/ui/HumanInteractionHost.tsx | 47 ++++++++ examples/cli/src/ui/model/humanPrompt.test.ts | 79 ++++++++++++ examples/cli/src/ui/model/humanPrompt.ts | 114 ++++++++++++++++++ examples/cli/src/web/client/state.ts | 16 ++- .../cli/src/web/client/views/HumanPrompt.tsx | 75 ++++++++---- packages/test/package.json | 1 + .../human-connector/MockHumanConnector.ts | 17 ++- .../human-connector/assertions/roundtrip.ts | 24 ++++ .../src/contract/human-connector/types.ts | 1 + .../InkHumanConnector.conformance.test.ts | 47 ++++++++ ...RunEventHumanConnector.conformance.test.ts | 89 ++++++++++++++ .../test/src/test/human/cliHumanSurface.ts | 62 ++++++++++ packages/util/src/human/HumanConnector.ts | 11 +- 16 files changed, 593 insertions(+), 28 deletions(-) create mode 100644 examples/cli/src/human.ts create mode 100644 examples/cli/src/ui/model/humanPrompt.test.ts create mode 100644 examples/cli/src/ui/model/humanPrompt.ts create mode 100644 packages/test/src/test/human/InkHumanConnector.conformance.test.ts create mode 100644 packages/test/src/test/human/RunEventHumanConnector.conformance.test.ts create mode 100644 packages/test/src/test/human/cliHumanSurface.ts diff --git a/bun.lock b/bun.lock index b008de897..8b18c6d20 100644 --- a/bun.lock +++ b/bun.lock @@ -320,6 +320,7 @@ "@workglow/bun-webview": "workspace:*", "@workglow/cactus": "workspace:*", "@workglow/chrome-ai": "workspace:*", + "@workglow/cli": "workspace:*", "@workglow/cloudflare": "workspace:*", "@workglow/deepseek": "workspace:*", "@workglow/duckdb": "workspace:*", diff --git a/examples/cli/package.json b/examples/cli/package.json index 78b37b069..2dc082cea 100644 --- a/examples/cli/package.json +++ b/examples/cli/package.json @@ -17,7 +17,7 @@ "dev-js": "bun build --watch --target=bun --sourcemap=external --packages=external --outdir ./dist ./src/workglow.ts", "dev-lib": "bun build --watch --target=bun --sourcemap=external --packages=external --outdir ./dist ./src/lib.ts", "dev-types": "tsc --watch --preserveWatchOutput", - "build-example": "concurrently -c 'auto' -n 'js,js-worker-hft,lib,web,types' 'bun run build-js' 'bun run build-js-worker-hft' 'bun run build-lib' 'bun run build-web' 'bun run build-types'", + "build-example": "concurrently -c 'auto' -n 'js,js-worker-hft,lib,human,web,types' 'bun run build-js' 'bun run build-js-worker-hft' 'bun run build-lib' 'bun run build-human' 'bun run build-web' 'bun run build-types'", "build-clean": "rm -fr dist/* tsconfig.tsbuildinfo", "build-js": "bun build --target=bun --packages=external --outdir ./dist ./src/workglow.ts", "build-js-worker-hft": "bun build --target=node --packages=external --outdir ./dist ./src/worker_hft.ts", @@ -25,7 +25,8 @@ "build-types": "rm -f tsconfig.tsbuildinfo && tsc", "test": "vitest run --config ../../vitest.config.ts --project cli", "test:watch": "vitest", - "build-web": "bun build --target=browser --minify --outdir ./dist/web ./src/web/client/main.tsx && cp src/web/client/index.html src/web/client/app.css dist/web/" + "build-web": "bun build --target=browser --minify --outdir ./dist/web ./src/web/client/main.tsx && cp src/web/client/index.html src/web/client/app.css dist/web/", + "build-human": "bun build --target=bun --packages=external --outdir ./dist ./src/human.ts" }, "bin": "./dist/workglow.js", "files": [ @@ -37,6 +38,10 @@ ".": { "types": "./dist/lib.d.ts", "import": "./dist/lib.js" + }, + "./human": { + "types": "./dist/human.d.ts", + "import": "./dist/human.js" } }, "dependencies": { diff --git a/examples/cli/src/human.ts b/examples/cli/src/human.ts new file mode 100644 index 000000000..7bc212064 --- /dev/null +++ b/examples/cli/src/human.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The CLI's human-in-the-loop surface, on its own entry so a conformance suite + * can reach the two connectors without loading the command tree behind + * {@link ../lib.ts}. + */ + +export { getCliHumanInteractionEnqueue, setCliHumanInteractionEnqueue } from "./cliHumanBridge"; +export type { CliHumanInteractionEnqueue } from "./cliHumanBridge"; +export { RunEventHumanConnector } from "./run-events/RunEventHumanConnector"; +export type { AnswerReaderFactory } from "./run-events/RunEventHumanConnector"; +export type { RunEvent } from "./run-events/RunEventTypes"; +export type { RunEventSink } from "./run-events/runEventChannel"; +export { InkHumanConnector } from "./ui/InkHumanConnector"; +export { humanPromptModel } from "./ui/model/humanPrompt"; +export type { + HumanPromptDetail, + HumanPromptModel, + HumanPromptShape, + HumanPromptSource, +} from "./ui/model/humanPrompt"; +export { emptyRunView, reduceRunEvent } from "./web/client/state"; +export type { RunViewState } from "./web/client/state"; diff --git a/examples/cli/src/ui/HumanInteractionHost.tsx b/examples/cli/src/ui/HumanInteractionHost.tsx index 170927c84..6e4fd1aa5 100644 --- a/examples/cli/src/ui/HumanInteractionHost.tsx +++ b/examples/cli/src/ui/HumanInteractionHost.tsx @@ -12,6 +12,7 @@ import { prepareSchemaFormFields, type PromptFieldDescriptor } from "../input/pr import { deepMerge } from "../input/resolve-input"; import { SchemaPromptApp } from "./SchemaPromptApp"; import { asDataPortSchemaObject } from "./humanSchema"; +import { humanPromptModel } from "./model/humanPrompt"; function abortError(): Error { const e = new Error("The operation was aborted"); @@ -90,6 +91,50 @@ function HumanDisplayPanel({ ); } +function HumanConfirmPanel({ + request, + onFinish, +}: { + readonly request: IHumanRequest; + readonly onFinish: (r: IHumanResponse) => void; +}): React.ReactElement { + const model = humanPromptModel({ + kind: request.kind, + message: request.message, + schema: request.contentSchema, + data: request.contentData, + }); + + useInput((input, key) => { + const answer = (action: IHumanResponse["action"]): void => + onFinish({ requestId: request.requestId, action, content: undefined, done: true }); + const typed = input.toLowerCase(); + if (typed === "y" || key.return) { + answer("accept"); + } else if (typed === "n") { + answer("decline"); + } else if (key.escape) { + answer("cancel"); + } + }); + + return ( + + + {model.title} + + {model.message ? {model.message} : null} + {model.details.map((detail) => ( + + {detail.label}: + {detail.value} + + ))} + y/Enter to approve · n to decline · Esc to cancel + + ); +} + function HumanElicitPanel({ request, onFinish, @@ -232,6 +277,8 @@ function HumanInteractionPanel({ return ; case "elicit": return ; + case "confirm": + return ; default: return ; } diff --git a/examples/cli/src/ui/model/humanPrompt.test.ts b/examples/cli/src/ui/model/humanPrompt.test.ts new file mode 100644 index 000000000..4b7ab3d19 --- /dev/null +++ b/examples/cli/src/ui/model/humanPrompt.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { humanPromptModel } from "./humanPrompt"; + +const confirmSchema = { + properties: { action: { title: "Action" }, reaches: { title: "Reaches" } }, +}; + +describe("humanPromptModel", () => { + it("draws a confirm as an approval that can be refused", () => { + const model = humanPromptModel({ + kind: "confirm", + message: 'Run workflow "export"?', + schema: confirmSchema, + data: { action: "Run workflow", reaches: "network:http" }, + }); + expect(model.shape).toBe("approval"); + // "decline" is a refusal and "cancel" is walking away undecided; a caller + // acts differently on each, so an approval has to offer both. + expect(model.actions).toEqual(["accept", "decline", "cancel"]); + expect(model.details).toEqual([ + { label: "Action", value: "Run workflow" }, + { label: "Reaches", value: "network:http" }, + ]); + }); + + it("never carries content back from an approval", () => { + // The schema describes the action. Anything returned under it would be an + // edit to that description landing on the task's output ports. + const model = humanPromptModel({ + kind: "confirm", + message: "?", + schema: confirmSchema, + data: {}, + }); + expect(model.carriesContent).toBe(false); + }); + + it("keeps a detail to one row whatever the value contains", () => { + // `contentData` is an input port, so a value carrying a newline would + // otherwise draw a second labelled row nobody can tell from a real one. + const model = humanPromptModel({ + kind: "confirm", + message: "?", + schema: confirmSchema, + data: { reaches: "https://attacker.test\n\nReaches: nothing" }, + }); + expect(model.details).toHaveLength(1); + expect(model.details[0]!.value).not.toContain("\n"); + }); + + it("still draws an elicit as a form whose values are the person's to write", () => { + const model = humanPromptModel({ + kind: "elicit", + message: "Name?", + schema: confirmSchema, + data: undefined, + }); + expect(model.shape).toBe("form"); + expect(model.details).toEqual([]); + expect(model.carriesContent).toBe(true); + }); + + it("asks nothing for a one-way kind", () => { + const model = humanPromptModel({ + kind: "notify", + message: "Done.", + schema: {}, + data: { jobId: "1" }, + }); + expect(model.shape).toBe("acknowledge"); + expect(model.carriesContent).toBe(false); + }); +}); diff --git a/examples/cli/src/ui/model/humanPrompt.ts b/examples/cli/src/ui/model/humanPrompt.ts new file mode 100644 index 000000000..65e7f7dc8 --- /dev/null +++ b/examples/cli/src/ui/model/humanPrompt.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HumanResponseAction } from "@workglow/util"; + +/** One value a person reads before answering, under the label to read it by. */ +export interface HumanPromptDetail { + readonly label: string; + readonly value: string; +} + +/** + * How a request is drawn, and therefore what it can be answered with. + * + * - "acknowledge": one-way. There is nothing to decide. + * - "form": fields to fill in. The answer IS the data. + * - "approval": one described action. The answer is a decision, and the + * description is read rather than edited. + */ +export type HumanPromptShape = "acknowledge" | "form" | "approval"; + +export interface HumanPromptModel { + readonly shape: HumanPromptShape; + /** Heading for the panel or card. */ + readonly title: string; + readonly message: string; + /** Read-only values. Empty for a form, whose values are the person's to write. */ + readonly details: readonly HumanPromptDetail[]; + /** Every answer the rendering offers, in the order it offers them. */ + readonly actions: readonly HumanResponseAction[]; + /** Whether an accepted answer carries data back to the caller. */ + readonly carriesContent: boolean; +} + +/** + * The request as both renderers see it. The Ink host holds an `IHumanRequest`; + * the console receives the same fields off the run's event stream. + */ +export interface HumanPromptSource { + readonly kind: string; + readonly message: string; + readonly schema: unknown; + readonly data: unknown; +} + +/** + * A detail occupies one row, whatever it contains. + * + * `contentData` is an input port on `HumanInputTask`, so its values are as + * reachable as anything else a model supplies; a line break left in one lets a + * value draw further labelled rows of its own. + */ +function oneLine(text: string): string { + return text.replace(/\r\n|[\n\r\u2028\u2029]/g, "\\n"); +} + +function detailsOf(schema: unknown, data: unknown): HumanPromptDetail[] { + if (typeof data !== "object" || data === null) return []; + const properties = (schema as { properties?: Record } | null) + ?.properties; + return Object.entries(data as Record).map(([key, value]) => { + const title = properties?.[key]?.title; + const label = typeof title === "string" && title ? title : key; + const rendered = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value)); + return { label: oneLine(label), value: oneLine(rendered) }; + }); +} + +/** + * What to draw for one human request, decided once for both renderers. + * + * Kept renderer-free so the Ink panel and the web console cannot disagree about + * what a request is asking — the case that matters is "confirm", where drawing + * a form instead of an approval turns the description of an action into fields + * to edit and leaves a person no way to say no. + */ +export function humanPromptModel(source: HumanPromptSource): HumanPromptModel { + if (source.kind === "notify" || source.kind === "display") { + return { + shape: "acknowledge", + title: source.kind === "notify" ? "Notice" : "Display", + message: source.message, + details: detailsOf(source.schema, source.data), + actions: ["accept", "cancel"], + carriesContent: false, + }; + } + if (source.kind === "confirm") { + // The schema describes the action, so its fields are values to read. Drawn + // as a form they become inputs to edit, the edits come back as the task's + // output, and the one answer an approval exists for — "no" — has nowhere to + // be pressed. "decline" is a refusal; "cancel" is walking away undecided, + // and a caller acts differently on each. + return { + shape: "approval", + title: "Approval required", + message: source.message, + details: detailsOf(source.schema, source.data), + actions: ["accept", "decline", "cancel"], + carriesContent: false, + }; + } + return { + shape: "form", + title: "Input required", + message: source.message, + details: [], + actions: ["accept", "cancel"], + carriesContent: true, + }; +} diff --git a/examples/cli/src/web/client/state.ts b/examples/cli/src/web/client/state.ts index 750a5b1c8..98189f158 100644 --- a/examples/cli/src/web/client/state.ts +++ b/examples/cli/src/web/client/state.ts @@ -57,8 +57,20 @@ export interface RunViewState { readonly state: RunState | "running"; readonly error: string | undefined; readonly output: unknown; + /** + * `kind` and `data` are carried, not just the schema: a confirm's schema + * describes the action and its data IS what the person reads before + * deciding, so a view holding only the schema can draw the description as + * empty boxes to type in and nothing else. + */ readonly humanRequest: - | { readonly requestId: string; readonly message: string; readonly schema: unknown } + | { + readonly requestId: string; + readonly kind: string; + readonly message: string; + readonly schema: unknown; + readonly data: unknown; + } | undefined; readonly lastSeq: number; readonly nextOrder: number; @@ -168,8 +180,10 @@ export function reduceRunEvent( ...state, humanRequest: { requestId: event.requestId, + kind: event.kind, message: event.message, schema: event.schema, + data: event.data, }, }; // One graph of possibly several finished. The last one wins: a command that diff --git a/examples/cli/src/web/client/views/HumanPrompt.tsx b/examples/cli/src/web/client/views/HumanPrompt.tsx index 2d87ceab7..90bbb108a 100644 --- a/examples/cli/src/web/client/views/HumanPrompt.tsx +++ b/examples/cli/src/web/client/views/HumanPrompt.tsx @@ -5,8 +5,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { HumanResponseAction } from "@workglow/util"; import type { JSX } from "preact"; import { useState } from "preact/hooks"; +import { humanPromptModel } from "../../../ui/model/humanPrompt"; interface SchemaLike { readonly properties?: Record; @@ -14,8 +16,8 @@ interface SchemaLike { } /** - * A run asking its operator something. The CLI renders this as an Ink form; the - * console renders the same schema, and the answer travels back down the same + * A run asking its operator something. The CLI renders this as an Ink panel; the + * console renders the same model, and the answer travels back down the same * channel the request came up. */ export function HumanPrompt({ @@ -23,24 +25,39 @@ export function HumanPrompt({ onAnswer, canAnswer = true, }: { - request: { requestId: string; message: string; schema: unknown }; - onAnswer: (action: "accept" | "cancel", content: Record | undefined) => void; + request: { + requestId: string; + kind: string; + message: string; + schema: unknown; + data: unknown; + }; + onAnswer: (action: HumanResponseAction, content: Record | undefined) => void; /** False while the CLI is not answering; the run cannot receive a reply. */ canAnswer?: boolean; }): JSX.Element { const [values, setValues] = useState>({}); + const model = humanPromptModel(request); const schema = (request.schema ?? {}) as SchemaLike; - const properties = Object.entries(schema.properties ?? {}); + const properties = model.shape === "form" ? Object.entries(schema.properties ?? {}) : []; + const disabledTitle = canAnswer ? undefined : "the CLI is not responding"; return (
- The run is asking - {request.message} + {model.shape === "approval" ? "The run needs approval" : "The run is asking"} + {model.message}
+ {/* An approval's values are read, never typed into. */} + {model.details.map((detail) => ( +
+
{detail.label}
+
{detail.value}
+
+ ))} {properties.map(([key, property]) => (
{property.title ?? key}
@@ -54,21 +71,35 @@ export function HumanPrompt({
))}
- - + {model.actions.includes("accept") ? ( + + ) : null} + {model.actions.includes("decline") ? ( + + ) : null} + {model.actions.includes("cancel") ? ( + + ) : null}
diff --git a/packages/test/package.json b/packages/test/package.json index 59290ac5c..e1323ecf1 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -42,6 +42,7 @@ "@workglow/bun-webview": "workspace:*", "@workglow/cactus": "workspace:*", "@workglow/chrome-ai": "workspace:*", + "@workglow/cli": "workspace:*", "@workglow/cloudflare": "workspace:*", "@workglow/deepseek": "workspace:*", "@workglow/duckdb": "workspace:*", diff --git a/packages/test/src/contract/human-connector/MockHumanConnector.ts b/packages/test/src/contract/human-connector/MockHumanConnector.ts index 2159fa60e..26836698a 100644 --- a/packages/test/src/contract/human-connector/MockHumanConnector.ts +++ b/packages/test/src/contract/human-connector/MockHumanConnector.ts @@ -179,9 +179,22 @@ export class MockHumanConnector implements IHumanConnector { } if (entry.kind === "deferred") { const res = await awaitDeferred(entry, signal); - return { ...res, requestId: request.requestId }; + return this.shape(request, res); } const resolved = typeof entry.entry === "function" ? await entry.entry(request) : entry.entry; - return { ...resolved, requestId: request.requestId }; + return this.shape(request, resolved); + } + + /** + * A confirm answers with the decision and nothing else, whatever a script + * put beside it — the reference connector holds the same contract every + * adapter is measured against. + */ + private shape(request: IHumanRequest, response: IHumanResponse): IHumanResponse { + return { + ...response, + requestId: request.requestId, + content: request.kind === "confirm" ? undefined : response.content, + }; } } diff --git a/packages/test/src/contract/human-connector/assertions/roundtrip.ts b/packages/test/src/contract/human-connector/assertions/roundtrip.ts index e3a279d9f..8056a4f0f 100644 --- a/packages/test/src/contract/human-connector/assertions/roundtrip.ts +++ b/packages/test/src/contract/human-connector/assertions/roundtrip.ts @@ -54,6 +54,7 @@ export function roundtripBlock( const itConfirmAccept = expectFails.has("roundtrip.confirm.accept") ? itExpectFail : it; const itConfirmDecline = expectFails.has("roundtrip.confirm.decline") ? itExpectFail : it; const itConfirmDetails = expectFails.has("roundtrip.confirm.details") ? itExpectFail : it; + const itConfirmNoContent = expectFails.has("roundtrip.confirm.noContent") ? itExpectFail : it; describe.skipIf(!opts.capabilities.elicit)("Roundtrip elicit", () => { itAccept( @@ -140,6 +141,29 @@ export function roundtripBlock( opts.timeout ); + itConfirmNoContent( + "an accepted confirm answers with the decision and nothing else", + async () => { + // A confirm's schema describes the action, so anything a connector + // sends back under it is an answer to a form nobody asked. It reaches + // the task's output ports (`HumanInputTask` spreads `content` there), + // which is how an edit to the DESCRIPTION of an action becomes part of + // what the graph does with it. + const { connector, script } = getHandle(); + script.push({ + requestId: "x", + action: "accept", + content: { action: "something else" }, + done: true, + }); + const ac = new AbortController(); + const res = await connector.send(confirmReq(fixture, "rt-confirm-content"), ac.signal); + expect(res.action).toBe("accept"); + expect(res.content).toBeUndefined(); + }, + opts.timeout + ); + itConfirmDetails( "the action's details reach the person deciding", async () => { diff --git a/packages/test/src/contract/human-connector/types.ts b/packages/test/src/contract/human-connector/types.ts index 3b1da7a69..41dbafbc3 100644 --- a/packages/test/src/contract/human-connector/types.ts +++ b/packages/test/src/contract/human-connector/types.ts @@ -18,6 +18,7 @@ export type HumanConnectorAssertionId = | "roundtrip.confirm.accept" | "roundtrip.confirm.decline" | "roundtrip.confirm.details" + | "roundtrip.confirm.noContent" | "abort.beforeSend" | "abort.midElicit" | "concurrent.isolation" diff --git a/packages/test/src/test/human/InkHumanConnector.conformance.test.ts b/packages/test/src/test/human/InkHumanConnector.conformance.test.ts new file mode 100644 index 000000000..01aa8be07 --- /dev/null +++ b/packages/test/src/test/human/InkHumanConnector.conformance.test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InkHumanConnector, setCliHumanInteractionEnqueue } from "@workglow/cli/human"; + +import { runHumanConnectorConformance } from "../../contract/human-connector/runHumanConnectorConformance"; +import { createCliHumanSurface } from "./cliHumanSurface"; + +/** + * The terminal connector, answered through what its Ink host actually draws. + * + * The bridge the host installs is stood in for here so the suite can script the + * person's side, but every answer still has to be one the drawn panel offers — + * see {@link createCliHumanSurface}. + */ +runHumanConnectorConformance({ + name: "InkHumanConnector", + timeout: 10_000, + factory: async () => { + const surface = createCliHumanSurface(); + setCliHumanInteractionEnqueue(surface.answer); + return { + connector: new InkHumanConnector(), + script: surface.script, + dispose: async () => { + setCliHumanInteractionEnqueue(undefined); + }, + }; + }, + capabilities: { + elicit: true, + confirm: true, + notify: true, + display: true, + multiTurn: true, + concurrent: true, + abortMidElicit: true, + }, + // The elicit form offers submit and Esc, so a person can walk away from it + // but cannot refuse it. Unlike a confirm, nothing turns on the difference + // there yet — an elicit's caller wanted a value and gets none either way — + // so this is recorded rather than papered over. + expectedFailures: ["roundtrip.decline"], +}); diff --git a/packages/test/src/test/human/RunEventHumanConnector.conformance.test.ts b/packages/test/src/test/human/RunEventHumanConnector.conformance.test.ts new file mode 100644 index 000000000..5d4f40fae --- /dev/null +++ b/packages/test/src/test/human/RunEventHumanConnector.conformance.test.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { emptyRunView, reduceRunEvent, RunEventHumanConnector } from "@workglow/cli/human"; +import type { RunEvent, RunEventSink } from "@workglow/cli/human"; +import type { IHumanRequest } from "@workglow/util"; +import type { DataPortSchema } from "@workglow/util/schema"; + +import { runHumanConnectorConformance } from "../../contract/human-connector/runHumanConnectorConformance"; +import { createCliHumanSurface } from "./cliHumanSurface"; + +/** + * The web console's connector, answered through what the console can draw. + * + * The request is reduced into the console's own view state first, so the + * assertions see exactly what reaches the browser: a field the event stream + * never carried, or the reducer dropped, is a field the person deciding never + * had. + */ +runHumanConnectorConformance({ + name: "RunEventHumanConnector", + timeout: 10_000, + factory: async () => { + const surface = createCliHumanSurface(); + let onLine: ((line: string) => void) | undefined; + + const answer = (event: Extract): void => { + const view = reduceRunEvent(emptyRunView(), event); + const shown = view.humanRequest; + if (!shown) return; + const request: IHumanRequest = { + requestId: shown.requestId, + targetHumanId: "default", + // Everything the console does not know is a form to fill in — which is + // what it falls back to when the stream carries no kind. + kind: (shown as { kind?: IHumanRequest["kind"] }).kind ?? "elicit", + message: shown.message, + contentSchema: (shown.schema ?? {}) as DataPortSchema, + contentData: (shown as { data?: Record }).data, + expectsResponse: true, + mode: "single", + metadata: undefined, + }; + void surface.answer(request, new AbortController().signal).then( + (response) => onLine?.(JSON.stringify(response)), + () => undefined + ); + }; + + const sink: RunEventSink = { + emit: (event) => { + if (event.k === "human_request") answer(event); + }, + close: async () => {}, + }; + + return { + connector: new RunEventHumanConnector(sink, (handler) => { + onLine = handler; + return () => { + onLine = undefined; + }; + }), + script: surface.script, + dispose: async () => {}, + }; + }, + capabilities: { + elicit: true, + confirm: true, + notify: true, + display: true, + multiTurn: true, + concurrent: true, + // An abort resolves as `cancel` rather than rejecting: this connector runs + // in a child process whose question nobody is reading any more, and a + // rejection would surface as a task failure rather than the cancellation it + // is. `abort.beforeSend` states the opposite, so it is declared failing. + abortMidElicit: false, + }, + // "roundtrip.decline": the console's form offers Send and Cancel, so a person + // can walk away from an elicit but cannot refuse it — recorded rather than + // papered over, since an elicit's caller wanted a value and gets none either + // way. A confirm is the case where the difference matters, and it is covered. + expectedFailures: ["abort.beforeSend", "roundtrip.decline"], +}); diff --git a/packages/test/src/test/human/cliHumanSurface.ts b/packages/test/src/test/human/cliHumanSurface.ts new file mode 100644 index 000000000..8779eea90 --- /dev/null +++ b/packages/test/src/test/human/cliHumanSurface.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { humanPromptModel } from "@workglow/cli/human"; +import type { HumanPromptSource } from "@workglow/cli/human"; +import type { IHumanRequest, IHumanResponse } from "@workglow/util"; + +import { MockHumanConnector } from "../../contract/human-connector/MockHumanConnector"; +import type { MockResponseScript } from "../../contract/human-connector/types"; + +/** + * A person sitting in front of what the CLI actually drew. + * + * The scripted answer says what they want to say; `humanPromptModel` says what + * the panel or card lets them say. When the rendering offers the scripted + * action they give it, and otherwise they are left with the rendering's primary + * action — which is the whole point of driving the suite through the model + * rather than around it: a confirm drawn as a form has no "decline" to press, + * so `roundtrip.confirm.decline` fails instead of passing on an answer no + * rendering could have produced. + * + * The scripted queue, deferred entries, abort handling and the notify/display + * fast path come from {@link MockHumanConnector}, so the surface under test is + * the rendering and nothing else. + */ +export interface CliHumanSurface { + readonly script: MockResponseScript; + answer(request: IHumanRequest, signal: AbortSignal): Promise; +} + +export function createCliHumanSurface( + sourceOf: (request: IHumanRequest) => HumanPromptSource = requestAsPromptSource +): CliHumanSurface { + const scripted = new MockHumanConnector({ supportsFollowUp: false }); + return { + script: scripted.script, + answer: async (request, signal) => { + const wanted = await scripted.send(request, signal); + const model = humanPromptModel(sourceOf(request)); + const action = model.actions.includes(wanted.action) ? wanted.action : model.actions[0]!; + return { + requestId: request.requestId, + action, + content: action === "accept" && model.carriesContent ? wanted.content : undefined, + done: wanted.done, + }; + }, + }; +} + +/** What the Ink host holds: the request itself. */ +export function requestAsPromptSource(request: IHumanRequest): HumanPromptSource { + return { + kind: request.kind, + message: request.message, + schema: request.contentSchema, + data: request.contentData, + }; +} diff --git a/packages/util/src/human/HumanConnector.ts b/packages/util/src/human/HumanConnector.ts index d716f409e..94437e027 100644 --- a/packages/util/src/human/HumanConnector.ts +++ b/packages/util/src/human/HumanConnector.ts @@ -85,7 +85,16 @@ export interface IHumanResponse { * - "cancel": user dismissed without choosing */ readonly action: HumanResponseAction; - /** The human's response data (present when action is "accept" and kind is "elicit") */ + /** + * The human's response data. Present only for an accepted "elicit"; every + * other kind answers `undefined`. + * + * A confirm in particular carries none: its schema describes the action + * awaiting approval rather than fields to fill in, so anything sent back + * under it answers a form that was never asked — and a caller spreading + * `content` onto its own output would be taking an edit to the DESCRIPTION + * of an action as part of the action. + */ readonly content: Record | undefined; /** Whether the conversation is complete. Always true for "single" mode. */ readonly done: boolean; From 258536de5f0b27cc6b8dc7b0ee1476f987e7105d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 19:11:55 +0000 Subject: [PATCH 4/4] fix(test): hold the reference connector to the content contract it documents `shape()` stripped `content` for a confirm only, while `IHumanResponse.content` is documented as present only for an accepted elicit. A scripted decline or cancel could still hand data back, so the conformance suite could not fail an adapter that leaks a refused form onto `HumanInputTask`'s output ports. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ --- .../human-connector/MockHumanConnector.ts | 11 +++++--- .../human/MockHumanConnector.unit.test.ts | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/test/src/contract/human-connector/MockHumanConnector.ts b/packages/test/src/contract/human-connector/MockHumanConnector.ts index 26836698a..a135cfeef 100644 --- a/packages/test/src/contract/human-connector/MockHumanConnector.ts +++ b/packages/test/src/contract/human-connector/MockHumanConnector.ts @@ -186,15 +186,18 @@ export class MockHumanConnector implements IHumanConnector { } /** - * A confirm answers with the decision and nothing else, whatever a script - * put beside it — the reference connector holds the same contract every - * adapter is measured against. + * Content belongs to an accepted elicit and nothing else, whatever a script + * put beside the action — the reference connector holds the same contract + * every adapter is measured against. A confirm answers with the decision + * alone, and a refused elicit answers with no data, since `HumanInputTask` + * spreads `content` onto its output ports either way. */ private shape(request: IHumanRequest, response: IHumanResponse): IHumanResponse { + const carriesContent = request.kind === "elicit" && response.action === "accept"; return { ...response, requestId: request.requestId, - content: request.kind === "confirm" ? undefined : response.content, + content: carriesContent ? response.content : undefined, }; } } diff --git a/packages/test/src/test/human/MockHumanConnector.unit.test.ts b/packages/test/src/test/human/MockHumanConnector.unit.test.ts index 4f9a81b6e..435147096 100644 --- a/packages/test/src/test/human/MockHumanConnector.unit.test.ts +++ b/packages/test/src/test/human/MockHumanConnector.unit.test.ts @@ -263,6 +263,33 @@ describe("MockHumanConnector — notify/display fast-resolve", () => { }); }); +describe("MockHumanConnector — content belongs to an accepted elicit", () => { + // `IHumanResponse.content` is documented as present only for an accepted + // elicit. The reference connector is the yardstick the conformance suite + // measures adapters against, so a script that puts content beside a refusal + // must not be able to hand it back — otherwise the suite cannot fail an + // adapter that leaks a declined form's data onto `HumanInputTask`'s outputs. + it.each(["decline", "cancel"] as const)( + "drops scripted content on an elicit the person answered with %s", + async (action) => { + const c = new MockHumanConnector(); + c.script.push({ requestId: "x", action, content: { secret: "leaked" }, done: true }); + const ac = new AbortController(); + const res = await c.send(elicitReq("r1"), ac.signal); + expect(res.action).toBe(action); + expect(res.content).toBeUndefined(); + } + ); + + it("keeps scripted content on an accepted elicit", async () => { + const c = new MockHumanConnector(); + c.script.push({ requestId: "x", action: "accept", content: { kept: true }, done: true }); + const ac = new AbortController(); + const res = await c.send(elicitReq("r1"), ac.signal); + expect(res.content).toEqual({ kept: true }); + }); +}); + describe("MockHumanConnector — clear() rejects pending deferreds", () => { it("clear() unblocks an in-flight send() awaiting a deferred", async () => { const c = new MockHumanConnector();