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/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); }); 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/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..a135cfeef 100644 --- a/packages/test/src/contract/human-connector/MockHumanConnector.ts +++ b/packages/test/src/contract/human-connector/MockHumanConnector.ts @@ -179,9 +179,25 @@ 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); + } + + /** + * 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: carriesContent ? response.content : undefined, + }; } } 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/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); + }); +}); 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(); 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;