diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 8207c9fb1..b5f158179 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -206,6 +206,23 @@ RAG tasks: `ChunkVectorUpsertTask` (`knowledgeBase` + `chunks` + `vector`, optio `method: "similarity" | "hybrid"`), `HierarchyJoinTask`, `RerankerTask`, `QueryExpanderTask`, `TextChunkerTask`, `HierarchicalChunkerTask`. +`AgentTask` is the turn loop: one `ToolCallingTask` per round, then the tools the model asked +for, then their results back to it, until the model answers or `maxRounds` runs out. +`messages` goes in and comes back out, so the host owns the conversation. **Every `tool_use` +is answered** — an unknown tool, arguments failing the tool's schema, a throw, a person +declining — because dropping the call orphans it and the provider rejects the next round. A +tool is backed by a registered task (looked up by `taskType`, else `name`) or by a +`ToolDefinition.execute` function, which is handed a `ToolExecuteContext` — the tool-use id +its answer belongs to, and the run's signal — and may throw a `ToolCallError` to report a +failure in its own words rather than wrapped. The turn also emits a `snapshot` of `messages` +after every message it records, so a host can draw a tool card from the moment the model asks +for it; a `snapshot` rather than an object-delta because an array delta is folded as an upsert +list and successive whole-list snapshots would append into a transcript several times its +length. A tool reaching beyond `INFERENCE_ENTITLEMENTS` is put to +`IHumanConnector` as a `confirm` first: `requiresApproval` overrides that per tool, +`approval: "never"` turns it off for a headless run, and with no connector registered such a +call is refused rather than run. + Conversation helpers for a host keeping its own message list, none of which run inside a task: `normalizeHistoryForModel` / `trimHistoryForModel` (`ChatHistory.ts`), and `collectToolUseIds` / `uniquifyToolCallIds` / `repairDuplicateToolCallIds` (`ToolCallIds.ts`). @@ -402,6 +419,29 @@ terminal runs. Three load-bearing properties: `timeline`, `markdown`, `empty` and `error`; a status widget contributes meters **or** text lines, since most of what an operator checks has no denominator to draw a bar against. +`workglow agent chat` is the terminal consumer of `AgentTask`: a line-based REPL that +streams the model's reply straight to stdout — so the transcript stays in scrollback, which +the Ink run UI's clear-on-complete would erase — prints one row per tool call off the task's +own progress messages, and carries `messages` from one turn's output into the next's input. +`--tools` takes task type names and **has no default**: what an agent may call decides what it +can reach. Approvals go through `PromptHumanConnector`, the connector for anything prompting +BETWEEN runs rather than during one: `InkHumanConnector` needs a mounted +`HumanInteractionHost` and throws without one, while this draws its own prompt and gives the +screen back. It has no `followUp` — a modal prompt settles the question it asked — and +decides form-vs-approval through the same `humanPromptModel` the Ink panel and the console +read. + +**The same command serves the web console**, because a chat needs no channel the console did +not already have: each turn runs through `withCli` so its rows and text report up the event +stream, and the next message is asked for as an ordinary `elicit` whose field carries +`format: "chat-message"` — the marker the console keys on to draw a composer instead of a +one-line field, and to fold the answer into a transcript instead of a form somebody once +filled in. A reported session does NOT install `PromptHumanConnector`: the channel installs +its own connector, and overriding it would point a console session's approvals at an Ink +prompt on a process whose stdout is a pipe. `chatTranscript` (`web/client/state.ts`) zips +what the console sent against the `AgentTask` rows it saw, one turn per message, so the +conversation is derived rather than a second copy of the run. + `workglow mcp serve` is the second server the CLI hosts: the registered tasks offered to MCP clients as tools, one per task type, named for the registered type itself (`task list` prints the same types with the `Task` suffix trimmed) and carrying the diff --git a/examples/cli/src/agent/chatCommands.ts b/examples/cli/src/agent/chatCommands.ts new file mode 100644 index 000000000..aed914d2d --- /dev/null +++ b/examples/cli/src/agent/chatCommands.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * What a typed line means before the model sees it. + * + * Only lines that are exactly a known command are one: a message beginning + * "/exit is the command that..." is a message, and a model that never receives + * it because of a prefix match is a chat that silently eats input. + */ +export type ChatLineIntent = + | { readonly kind: "blank" } + | { readonly kind: "quit" } + | { readonly kind: "reset" } + | { readonly kind: "help" } + | { readonly kind: "unknown-command"; readonly typed: string } + | { readonly kind: "message"; readonly text: string }; + +const COMMANDS: ReadonlyMap = new Map([ + ["/exit", "quit"], + ["/quit", "quit"], + ["/reset", "reset"], + ["/help", "help"], +]); + +export function classifyChatLine(line: string): ChatLineIntent { + const text = line.trim(); + if (text.length === 0) return { kind: "blank" }; + if (!text.startsWith("/")) return { kind: "message", text }; + const known = COMMANDS.get(text.toLowerCase()); + if (known === "quit") return { kind: "quit" }; + if (known === "reset") return { kind: "reset" }; + if (known === "help") return { kind: "help" }; + // A lone word starting with "/" is a mistyped command far more often than a + // message; anything longer is prose that happens to open with a slash. + if (/^\/\S*$/.test(text)) return { kind: "unknown-command", typed: text }; + return { kind: "message", text }; +} + +export const CHAT_HELP_LINES: readonly string[] = [ + "/exit, /quit end the session", + "/reset forget the conversation so far", + "/help this list", +]; diff --git a/examples/cli/src/agent/chatTranscript.ts b/examples/cli/src/agent/chatTranscript.ts new file mode 100644 index 000000000..0d02478fc --- /dev/null +++ b/examples/cli/src/agent/chatTranscript.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Where a chat turn is written, and the one thing that is easy to get wrong + * about it: a model's text arrives as deltas that rarely end on a line break, + * and everything else the turn prints — a tool line, an approval, the next + * prompt — has to start at column 0 or it lands in the middle of a sentence. + * + * Kept apart from the loop so the rule can be read, and tested, without a + * terminal. + */ +export interface ChatTranscript { + /** Model text, exactly as it arrived. */ + delta(text: string): void; + /** A line about the run rather than from the model. */ + note(line: string): void; + /** Close the turn, leaving the cursor at column 0. */ + endTurn(): void; +} + +export function createChatTranscript(write: (text: string) => void): ChatTranscript { + let atLineStart = true; + const breakLine = (): void => { + if (!atLineStart) { + write("\n"); + atLineStart = true; + } + }; + return { + delta(text) { + if (text.length === 0) return; + write(text); + atLineStart = text.endsWith("\n"); + }, + note(line) { + breakLine(); + write(`${line}\n`); + }, + endTurn() { + breakLine(); + }, + }; +} diff --git a/examples/cli/src/agent/runAgentChat.ts b/examples/cli/src/agent/runAgentChat.ts new file mode 100644 index 000000000..f6277eafa --- /dev/null +++ b/examples/cli/src/agent/runAgentChat.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AgentApprovalMode, AgentTaskOutput, ChatMessage, ToolDefinition } from "@workglow/ai"; +import { AgentTask } from "@workglow/ai"; +import type { StreamEvent } from "@workglow/task-graph"; +import { + globalServiceRegistry, + HUMAN_CONNECTOR, + resolveHumanConnector, + ServiceRegistry, + uuid4, +} from "@workglow/util"; +import type { DataPortSchema } from "@workglow/util/schema"; +import { ensureRunReporting } from "../run-events/runReporting"; +import { withCli } from "../run-interactive"; +import { createInterface } from "node:readline/promises"; +import { formatError } from "../util"; +import { PromptHumanConnector } from "../ui/PromptHumanConnector"; +import { CHAT_HELP_LINES, classifyChatLine } from "./chatCommands"; +import { createChatTranscript } from "./chatTranscript"; + +/** + * The session's two ends, injectable so the loop can be driven without a + * terminal. Defaults are readline and stdout. + */ +export interface AgentChatIo { + /** One line from the person, or `undefined` at end of input. */ + readonly ask: (prompt: string) => Promise; + readonly write: (text: string) => void; +} + +export interface AgentChatOptions { + readonly model: string; + readonly tools: readonly ToolDefinition[]; + readonly systemPrompt: string | undefined; + readonly maxRounds: number | undefined; + readonly approval: AgentApprovalMode; +} + +/** + * One question, on a readline interface that exists only for the length of it. + * + * A long-lived interface keeps listeners on stdin, and the approval prompts + * this session raises render their own Ink app over the same terminal — two + * readers of one stdin is a session that drops keystrokes into whichever + * happens to be listening. Creating and closing per question means nothing but + * the prompt of the moment ever holds it. + */ +async function askLine(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return await rl.question(prompt); + } catch { + // The interface closes on EOF (Ctrl-D), which rejects the pending question. + return undefined; + } finally { + rl.close(); + } +} + +/** + * The schema of the one thing this loop asks a person for. + * + * `format` is the marker a renderer keys on: the console draws a chat composer + * for it rather than the one-line text field every other string port gets, and + * folds the answer into the transcript instead of showing it as a form it once + * filled in. + */ +export const CHAT_MESSAGE_SCHEMA: DataPortSchema = { + type: "object", + properties: { + message: { type: "string", title: "Message", format: "chat-message" }, + }, + required: ["message"], + additionalProperties: false, +}; + +/** + * A child of the host's registry carrying the connector this session prompts + * through — but ONLY when the session owns a terminal. + * + * A run reporting to a parent process already has a connector wired to that + * channel, installed with the channel itself. Overriding it here would point a + * console session's approvals at an Ink prompt nobody can see, on a process + * whose stdout is a pipe. + */ +export function chatRegistry(parent: ServiceRegistry, reported: boolean): ServiceRegistry { + if (reported) return parent; + const registry = new ServiceRegistry(parent.container.createChildContainer()); + registry.registerInstance(HUMAN_CONNECTOR, new PromptHumanConnector()); + return registry; +} + +/** + * The next message, asked through whoever is listening. + * + * A reported run has no terminal to read a line from — its stdin is not a + * person — so the question goes up the same channel every other question does + * and the answer comes back down it. Declining or dismissing ends the session, + * which is what closing the composer means. + */ +export function askThroughConnector( + registry: ServiceRegistry, + signal: AbortSignal +): () => Promise { + return async (): Promise => { + const response = await resolveHumanConnector({ registry }).send( + { + requestId: uuid4(), + targetHumanId: "default", + kind: "elicit", + message: "Your message", + contentSchema: CHAT_MESSAGE_SCHEMA, + contentData: undefined, + expectsResponse: true, + mode: "single", + metadata: undefined, + }, + signal + ); + if (response.action !== "accept") return undefined; + const message = response.content?.message; + return typeof message === "string" ? message : undefined; + }; +} + +/** + * The chat loop: read a line, run one {@link AgentTask} turn, carry the + * conversation forward. + * + * `messages` is this loop's only state — the task takes the history in and + * hands it back with the turn appended, so nothing here has to know what a + * tool result or a tool-call id looks like. + */ +export async function runAgentChat(options: AgentChatOptions, io?: AgentChatIo): Promise { + const reported = ensureRunReporting() !== undefined; + const registry = chatRegistry(globalServiceRegistry, reported); + const sessionAbort = new AbortController(); + const ask = io?.ask ?? (reported ? askThroughConnector(registry, sessionAbort.signal) : askLine); + const transcript = createChatTranscript(io?.write ?? ((text) => void process.stdout.write(text))); + let messages: ChatMessage[] = []; + + transcript.note( + options.tools.length === 0 + ? "No tools — this agent can talk, and nothing else. Pass --tools to give it some." + : `Tools: ${options.tools.map((tool) => tool.name).join(", ")}` + ); + transcript.note("/help for commands, /exit to leave."); + + for (;;) { + transcript.endTurn(); + const line = await ask("\n› "); + if (line === undefined) return; + const intent = classifyChatLine(line); + if (intent.kind === "quit") return; + if (intent.kind === "blank") continue; + if (intent.kind === "reset") { + messages = []; + transcript.note("Conversation cleared."); + continue; + } + if (intent.kind === "help") { + for (const help of CHAT_HELP_LINES) transcript.note(help); + continue; + } + if (intent.kind === "unknown-command") { + transcript.note(`Unknown command ${intent.typed}. /help for the list.`); + continue; + } + messages = await runTurn(intent.text, messages, options, registry, transcript); + } +} + +async function runTurn( + text: string, + messages: ChatMessage[], + options: AgentChatOptions, + registry: ServiceRegistry, + transcript: ReturnType +): Promise { + const task = new AgentTask(); + const controller = new AbortController(); + const onInterrupt = (): void => controller.abort(); + process.on("SIGINT", onInterrupt); + + let phase: string | undefined; + const offStream = task.subscribe("stream_chunk", (event: StreamEvent) => { + if (event.type === "text-delta") transcript.delta(event.textDelta); + }); + const offProgress = task.subscribe("progress", (_progress, message) => { + // Only the tool lines are worth a row: "Thinking" is what the blank space + // between the prompt and the first token already says. + if (!message || message === phase) return; + phase = message; + if (message.startsWith("Running ")) transcript.note(` · ${message.slice("Running ".length)}`); + }); + + try { + // Through `withCli` rather than `task.run` so a session the console + // started reports its rows and its text up the event channel like every + // other command. `interactive: false` keeps the Ink run UI out of it: on a + // terminal that UI clears its frame when the run completes, which is the + // transcript this session is writing. + const output = (await withCli(task, { interactive: false, suppressResultOutput: true }).run( + { + model: options.model, + prompt: text, + messages, + tools: [...options.tools], + systemPrompt: options.systemPrompt, + maxRounds: options.maxRounds, + approval: options.approval, + }, + { registry, signal: controller.signal } + )) as AgentTaskOutput; + if (output.stopReason === "max-rounds") { + transcript.note(` · stopped after ${output.rounds} rounds without an answer`); + } + return output.messages; + } catch (error) { + if (controller.signal.aborted) { + // The turn is gone but the conversation is not: an interrupted turn wrote + // nothing to `messages`, so the next one continues from where it was. + transcript.note(" · interrupted"); + return messages; + } + transcript.note(` · ${formatError(error)}`); + return messages; + } finally { + process.off("SIGINT", onInterrupt); + offStream(); + offProgress(); + } +} diff --git a/examples/cli/src/commands/agent.ts b/examples/cli/src/commands/agent.ts index e53ca0f8b..1f47bc950 100644 --- a/examples/cli/src/commands/agent.ts +++ b/examples/cli/src/commands/agent.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { ToolDefinition } from "@workglow/ai"; +import { AgentTask, taskTypesToTools } from "@workglow/ai"; import { computeGraphInputSchema, createGraphFromGraphJSON, @@ -28,6 +30,8 @@ import { ensureCredentialStoreUnlocked } from "../keyring"; import { createAgentRepository } from "../storage"; import { renderSelectPrompt, renderWorkflowRun } from "../ui/render"; import { formatError, formatTable, outputResult } from "../util"; +import { runAgentChat } from "../agent/runAgentChat"; +import { ensureRunReporting } from "../run-events/runReporting"; export function registerAgentCommand(program: Command): void { const agent = program.command("agent").description("Manage and run agents"); @@ -351,4 +355,89 @@ export function registerAgentCommand(program: Command): void { process.exit(1); } }); + + agent + .command("chat") + .description("Talk to a model that can run registered tasks as tools") + .option("-m, --model ", "Model to use; prompted for when omitted") + .option( + "-t, --tools ", + "Comma-separated task types the model may call. None by default — an agent reaches only what it is given." + ) + .option("-s, --system ", "System prompt") + .option("--max-rounds ", "Model calls per turn before it gives up", parseRounds) + .option( + "--no-approval", + "Run every tool without asking. For a session you are not watching; the default confirms anything reaching past the model." + ) + .action(async (opts: Record) => { + // A session started from the web console has no terminal and does not + // need one: it asks and answers over the run's event channel. What it + // cannot do is read a line from a pipe nobody is typing into. + if (!process.stdin.isTTY && !ensureRunReporting()) { + console.error( + "agent chat needs a terminal, or the web console. Use `workglow task run AgentTask` for a scripted turn." + ); + process.exit(1); + } + let tools: ToolDefinition[]; + try { + tools = toolsFromTypes(opts.tools as string | undefined); + } catch (err) { + console.error(`Error: ${formatError(err)}`); + process.exit(1); + } + const model = await resolveChatModel(opts.model as string | undefined); + await ensureCredentialStoreUnlocked(); + await runAgentChat({ + model, + tools, + systemPrompt: opts.system as string | undefined, + maxRounds: opts.maxRounds as number | undefined, + // Commander gives `--no-approval` as `approval: false`. + approval: opts.approval === false ? "never" : "beyond-inference", + }); + }); +} + +function parseRounds(raw: string): number { + const rounds = Number.parseInt(raw, 10); + if (!Number.isFinite(rounds) || rounds < 1) { + throw new Error(`--max-rounds must be a positive integer, got "${raw}"`); + } + return rounds; +} + +/** + * The task types named on `--tools`, as tool definitions. + * + * No default, for the reason a pinned search provider has none: which tools an + * agent holds decides what it can reach, and inheriting a set nobody chose is + * how a chat session ends up able to write files. + */ +function toolsFromTypes(raw: string | undefined): ToolDefinition[] { + const names = (raw ?? "") + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + return names.length === 0 ? [] : taskTypesToTools(names); +} + +/** `--model`, or the same picker every other model port gets. */ +async function resolveChatModel(named: string | undefined): Promise { + if (named) return named; + // Only the model port, so the picker asks the one thing a session needs and + // does not walk the rest of the task's inputs. + const full = AgentTask.inputSchema() as DataPortSchemaObject; + const schema: DataPortSchemaObject = { + type: "object", + properties: { model: full.properties.model! }, + required: ["model"], + }; + const filled = await promptMissingInput({}, schema); + const model = filled.model; + if (typeof model !== "string" || model.length === 0) { + throw new Error("No model chosen"); + } + return model; } diff --git a/examples/cli/src/human.ts b/examples/cli/src/human.ts index 7bc212064..a040e669a 100644 --- a/examples/cli/src/human.ts +++ b/examples/cli/src/human.ts @@ -17,6 +17,8 @@ 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 { PromptHumanConnector } from "./ui/PromptHumanConnector"; +export type { PromptHumanRenderers } from "./ui/PromptHumanConnector"; export { humanPromptModel } from "./ui/model/humanPrompt"; export type { HumanPromptDetail, diff --git a/examples/cli/src/run-interactive.ts b/examples/cli/src/run-interactive.ts index 8a4c3a2ea..5394de7df 100644 --- a/examples/cli/src/run-interactive.ts +++ b/examples/cli/src/run-interactive.ts @@ -142,7 +142,13 @@ export function tuiDisabledByEnv(): boolean { export interface WithCliTaskHandle { readonly kind: "task"; - run(overrides?: Record): Promise; + /** + * `runConfig` reaches `task.run` and the Ink renderer alike — the + * implementation has always threaded it, and the caller that needs it is one + * running a task inside something longer than a command, where the registry + * and the abort signal are the session's rather than the process's. + */ + run(overrides?: Record, runConfig?: Partial): Promise; abort(): void; } diff --git a/examples/cli/src/test/agentChat.test.ts b/examples/cli/src/test/agentChat.test.ts new file mode 100644 index 000000000..f6b5f8ccf --- /dev/null +++ b/examples/cli/src/test/agentChat.test.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AiProviderRunFn, ModelConfig } from "@workglow/ai"; +import { + AiProviderRegistry, + DirectExecutionStrategy, + getAiProviderRegistry, + setAiProviderRegistry, +} from "@workglow/ai"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { IHumanRequest } from "@workglow/util"; +import { Container, HUMAN_CONNECTOR, ServiceRegistry } from "@workglow/util"; +import { + askThroughConnector, + chatRegistry, + CHAT_MESSAGE_SCHEMA, + runAgentChat, + type AgentChatIo, +} from "../agent/runAgentChat"; + +const PROVIDER = "mock-chat-provider"; + +const MODEL: ModelConfig = { + model_id: "mock/chat-1", + provider: PROVIDER, + title: "", + description: "", + capabilities: ["tool-use"], + provider_config: {}, + metadata: {}, +}; + +/** Answers each turn with the next scripted reply; the last repeats. */ +function scriptModel(replies: readonly string[]): () => number { + let called = 0; + const runFn: AiProviderRunFn = async (_input, _model, _signal, emit) => { + const reply = replies[Math.min(called, replies.length - 1)]!; + called++; + emit({ type: "text-delta", port: "text", textDelta: reply }); + emit({ type: "finish", data: {} }); + }; + getAiProviderRegistry().registerRunFn(PROVIDER, { serves: ["tool-use"], runFn }); + return () => called; +} + +/** How many messages the model was handed, per turn. */ +function historySizes(): { readonly sizes: number[]; readonly runFn: AiProviderRunFn } { + const sizes: number[] = []; + const runFn: AiProviderRunFn = async (input, _model, _signal, emit) => { + sizes.push(((input as { messages?: unknown[] }).messages ?? []).length); + emit({ type: "text-delta", port: "text", textDelta: "ok" }); + emit({ type: "finish", data: {} }); + }; + return { sizes, runFn }; +} + +function scriptedIo(lines: readonly string[]): { io: AgentChatIo; out: () => string } { + let index = 0; + const written: string[] = []; + return { + io: { + ask: async () => lines[index++], + write: (text) => written.push(text), + }, + out: () => written.join(""), + }; +} + +describe("agent chat loop", () => { + beforeEach(() => { + setAiProviderRegistry(new AiProviderRegistry()); + getAiProviderRegistry().setDefaultStrategy(new DirectExecutionStrategy()); + }); + + afterEach(() => { + getAiProviderRegistry().unregisterProvider(PROVIDER); + }); + + const baseOptions = { + model: MODEL as unknown as string, + tools: [], + systemPrompt: undefined, + maxRounds: undefined, + approval: "never" as const, + }; + + it("runs a turn per message and prints the reply", async () => { + const called = scriptModel(["Hello back."]); + const { io, out } = scriptedIo(["hello", "/exit"]); + + await runAgentChat(baseOptions, io); + + expect(called()).toBe(1); + expect(out()).toContain("Hello back."); + }); + + it("ends at end of input as well as on /exit", async () => { + scriptModel(["…"]); + const { io } = scriptedIo(["hello", undefined as unknown as string]); + await expect(runAgentChat(baseOptions, io)).resolves.toBeUndefined(); + }); + + it("carries the conversation forward, and /reset drops it", async () => { + const { sizes, runFn } = historySizes(); + getAiProviderRegistry().registerRunFn(PROVIDER, { serves: ["tool-use"], runFn }); + const { io } = scriptedIo(["one", "two", "/reset", "three", "/exit"]); + + await runAgentChat(baseOptions, io); + + // Turn 1 sends just the user message; turn 2 sends that turn plus this + // one's; after /reset turn 3 is back to a single message. + expect(sizes).toEqual([1, 3, 1]); + }); + + it("says what it can reach, and that it reaches nothing without --tools", async () => { + scriptModel(["hi"]); + const { io, out } = scriptedIo(["/exit"]); + + await runAgentChat(baseOptions, io); + + expect(out()).toContain("No tools"); + }); + + it("keeps the session alive when a turn fails, and keeps the history", async () => { + let calls = 0; + const runFn: AiProviderRunFn = async (input, _model, _signal, emit) => { + calls++; + if (calls === 1) throw new Error("provider exploded"); + const messages = (input as { messages?: unknown[] }).messages ?? []; + emit({ type: "text-delta", port: "text", textDelta: `saw ${messages.length}` }); + emit({ type: "finish", data: {} }); + }; + getAiProviderRegistry().registerRunFn(PROVIDER, { serves: ["tool-use"], runFn }); + const { io, out } = scriptedIo(["boom", "again", "/exit"]); + + await runAgentChat(baseOptions, io); + + expect(out()).toContain("provider exploded"); + // A failed turn recorded nothing, so the next one is still the first + // message the model ever sees. + expect(out()).toContain("saw 1"); + }); + + describe("under a run that reports to a parent process", () => { + it("asks for the next message through the human connector", async () => { + const asked: IHumanRequest[] = []; + const registry = new ServiceRegistry(new Container()); + registry.registerInstance(HUMAN_CONNECTOR, { + send: async (request: IHumanRequest) => { + asked.push(request); + return { + requestId: request.requestId, + action: "accept" as const, + content: { message: "hello" }, + done: true, + }; + }, + }); + + const ask = askThroughConnector(registry, new AbortController().signal); + + expect(await ask()).toBe("hello"); + expect(asked).toHaveLength(1); + expect(asked[0]!.kind).toBe("elicit"); + // The marker a renderer keys on to draw a composer rather than a field. + expect(asked[0]!.contentSchema).toEqual(CHAT_MESSAGE_SCHEMA); + }); + + it("ends the session when the person closes the composer", async () => { + const registry = new ServiceRegistry(new Container()); + registry.registerInstance(HUMAN_CONNECTOR, { + // Carrying content on a decline is a connector misbehaving — libs says + // only an accepted elicit answers with data — and the session must end + // on the action rather than on whether anything came back with it. + send: async (request: IHumanRequest) => ({ + requestId: request.requestId, + action: "decline" as const, + content: { message: "typed, then dismissed" }, + done: true, + }), + }); + + // `undefined` is how the loop reads end-of-input, the same as Ctrl-D. + expect(await askThroughConnector(registry, new AbortController().signal)()).toBeUndefined(); + }); + + it("leaves the channel's own connector in place", () => { + const parent = new ServiceRegistry(new Container()); + const installed = { + send: async () => ({ + requestId: "x", + action: "decline" as const, + content: undefined, + done: true, + }), + }; + parent.registerInstance(HUMAN_CONNECTOR, installed); + + // A console session's approvals have to reach the channel; a child + // registry carrying an Ink prompt would answer nobody. + expect(chatRegistry(parent, true)).toBe(parent); + expect(chatRegistry(parent, true).get(HUMAN_CONNECTOR)).toBe(installed); + // On a terminal it is the other way round: the session prompts itself. + expect(chatRegistry(parent, false).get(HUMAN_CONNECTOR)).not.toBe(installed); + }); + }); + + it("answers /help without calling the model", async () => { + const called = scriptModel(["never"]); + const { io, out } = scriptedIo(["/help", "/exit"]); + + await runAgentChat(baseOptions, io); + + expect(called()).toBe(0); + expect(out()).toContain("/reset"); + }); +}); diff --git a/examples/cli/src/test/chatCommands.test.ts b/examples/cli/src/test/chatCommands.test.ts new file mode 100644 index 000000000..b76b292a4 --- /dev/null +++ b/examples/cli/src/test/chatCommands.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { classifyChatLine } from "../agent/chatCommands"; +import { createChatTranscript } from "../agent/chatTranscript"; + +describe("classifyChatLine", () => { + it("reads the commands it knows, in any case, around whitespace", () => { + expect(classifyChatLine("/exit").kind).toBe("quit"); + expect(classifyChatLine(" /QUIT ").kind).toBe("quit"); + expect(classifyChatLine("/reset").kind).toBe("reset"); + expect(classifyChatLine("/help").kind).toBe("help"); + }); + + it("sends prose that merely starts with a command to the model", () => { + // A prefix match here is a chat that silently eats a message. + const intent = classifyChatLine("/exit is the command that ends this, right?"); + expect(intent).toEqual({ + kind: "message", + text: "/exit is the command that ends this, right?", + }); + }); + + it("calls a lone unknown slash-word a mistyped command, not a message", () => { + expect(classifyChatLine("/resett")).toEqual({ kind: "unknown-command", typed: "/resett" }); + }); + + it("treats an empty line as nothing to send", () => { + expect(classifyChatLine(" ").kind).toBe("blank"); + }); +}); + +describe("createChatTranscript", () => { + function capture() { + const written: string[] = []; + return { written, transcript: createChatTranscript((text) => written.push(text)) }; + } + + it("starts a note on its own line when the model stopped mid-sentence", () => { + const { written, transcript } = capture(); + transcript.delta("Looking that up"); + transcript.note(" · FetchUrlTask"); + expect(written.join("")).toBe("Looking that up\n · FetchUrlTask\n"); + }); + + it("does not open a blank line when the model already ended one", () => { + const { written, transcript } = capture(); + transcript.delta("Done.\n"); + transcript.note(" · one round"); + expect(written.join("")).toBe("Done.\n · one round\n"); + }); + + it("writes deltas through untouched", () => { + const { written, transcript } = capture(); + transcript.delta("Hel"); + transcript.delta("lo"); + expect(written).toEqual(["Hel", "lo"]); + }); + + it("closes a turn onto column 0, and only when it is not already there", () => { + const { written, transcript } = capture(); + transcript.delta("tail"); + transcript.endTurn(); + transcript.endTurn(); + expect(written.join("")).toBe("tail\n"); + }); + + it("ignores an empty delta rather than counting it as content", () => { + const { written, transcript } = capture(); + transcript.delta("x\n"); + transcript.delta(""); + transcript.note("after"); + expect(written.join("")).toBe("x\nafter\n"); + }); +}); diff --git a/examples/cli/src/test/promptAbort.test.ts b/examples/cli/src/test/promptAbort.test.ts new file mode 100644 index 000000000..690962524 --- /dev/null +++ b/examples/cli/src/test/promptAbort.test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { releaseOnAbort, type PromptInstance } from "../ui/promptAbort"; + +function fakeInstance(): PromptInstance & { readonly calls: string[] } { + const calls: string[] = []; + return { + calls, + clear: () => calls.push("clear"), + unmount: () => calls.push("unmount"), + }; +} + +describe("releaseOnAbort", () => { + it("takes the terminal back when the signal aborts", () => { + const controller = new AbortController(); + const instance = fakeInstance(); + let settled: unknown = "unset"; + + releaseOnAbort(controller.signal, instance, (value) => { + settled = value; + }); + controller.abort(); + + expect(instance.calls).toEqual(["clear", "unmount"]); + expect(settled).toBeUndefined(); + }); + + it("releases immediately when the signal is already aborted", () => { + const controller = new AbortController(); + controller.abort(); + const instance = fakeInstance(); + + releaseOnAbort(controller.signal, instance, () => {}); + + expect(instance.calls).toEqual(["clear", "unmount"]); + }); + + it("does nothing after the prompt settled and detached", () => { + const controller = new AbortController(); + const instance = fakeInstance(); + + const detach = releaseOnAbort(controller.signal, instance, () => {}); + detach(); + controller.abort(); + + // `clear()` writes an erase sequence, so a listener left attached past a + // normal answer wipes whatever is on screen when the run later aborts. + expect(instance.calls).toEqual([]); + }); + + it("leaves no listener behind per prompt on one long-lived signal", () => { + const controller = new AbortController(); + const instances = [fakeInstance(), fakeInstance(), fakeInstance()]; + + for (const instance of instances) { + releaseOnAbort(controller.signal, instance, () => {})(); + } + controller.abort(); + + // A turn holding one signal across several prompts would otherwise erase + // the screen once per prompt it had asked. + expect(instances.flatMap((instance) => instance.calls)).toEqual([]); + }); + + it("detaches idempotently, and with no signal at all", () => { + const instance = fakeInstance(); + const detach = releaseOnAbort(undefined, instance, () => {}); + expect(() => { + detach(); + detach(); + }).not.toThrow(); + expect(instance.calls).toEqual([]); + }); +}); diff --git a/examples/cli/src/ui/PromptHumanConnector.ts b/examples/cli/src/ui/PromptHumanConnector.ts new file mode 100644 index 000000000..0586f4847 --- /dev/null +++ b/examples/cli/src/ui/PromptHumanConnector.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IHumanConnector, IHumanRequest, IHumanResponse } from "@workglow/util"; +import { prepareSchemaFormFields, type PromptFieldDescriptor } from "../input/prompt"; +import { asDataPortSchemaObject } from "./humanSchema"; +import { humanPromptModel } from "./model/humanPrompt"; +import { renderSchemaPrompt, renderSelectPrompt } from "./render"; + +/** + * The prompts this connector draws, injectable so the mapping can be exercised + * without a terminal — and so a host with its own prompt stack can supply it. + */ +export interface PromptHumanRenderers { + readonly select: ( + options: ReadonlyArray<{ label: string; value: string }>, + message: string | undefined, + signal: AbortSignal + ) => Promise; + readonly form: ( + fields: readonly PromptFieldDescriptor[], + signal: AbortSignal + ) => Promise | undefined>; + readonly notice: (lines: readonly string[]) => void; +} + +const defaultRenderers: PromptHumanRenderers = { + select: (options, message, signal) => renderSelectPrompt([...options], message, signal), + form: (fields, signal) => renderSchemaPrompt(fields, undefined, signal), + notice: (lines) => { + for (const line of lines) console.log(line); + }, +}; + +/** + * Rejects when `signal` aborts, so a pending prompt does not outlive the run + * that asked the question. + * + * The renderer is handed the same signal and tears its own app down, but the + * rejection is raised here rather than left to it: `IHumanConnector` requires + * an aborted `send` to reject, and a renderer that resolves `undefined` on + * abort would be indistinguishable from a person pressing Esc — which is a + * `cancel` a caller may act on. + */ +function whenAborted(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + const fail = (): void => + reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError")); + if (signal.aborted) { + fail(); + return; + } + signal.addEventListener("abort", fail, { once: true }); + }); +} + +async function untilAborted(work: Promise, signal: AbortSignal): Promise { + const aborted = whenAborted(signal); + // Held so the loser of the race is not reported as an unhandled rejection. + aborted.catch(() => {}); + return await Promise.race([work, aborted]); +} + +/** Labels for the two decisions an approval offers, in the order it offers them. */ +const APPROVAL_OPTIONS = [ + { label: "Approve", value: "accept" }, + { label: "Decline", value: "decline" }, +] as const; + +/** + * The terminal connector for anything that is NOT inside the Ink run UI. + * + * {@link InkHumanConnector} hands a request to a mounted + * {@link HumanInteractionHost} and throws when there is none — right for a + * command whose whole life is one graph run, and useless to anything that + * prompts between runs rather than during one. This draws its own prompt for + * the length of the question and takes the screen back afterwards, so a + * transcript printed around it survives. + * + * What each request is asking is decided by {@link humanPromptModel}, the same + * function the Ink panel and the web console read, so the three cannot disagree + * about whether something is a form or an approval — the case that matters, + * since a confirm drawn as a form leaves a person no way to say no. + * + * There is deliberately no `followUp`: a modal prompt settles the question it + * asked, so no response is ever `done: false` and there is no partial answer for + * a follow-up to continue. Declaring one that just asked again would report a + * multi-turn conversation this surface cannot hold. + */ +export class PromptHumanConnector implements IHumanConnector { + private readonly renderers: PromptHumanRenderers; + + constructor(renderers: PromptHumanRenderers = defaultRenderers) { + this.renderers = renderers; + } + + async send(request: IHumanRequest, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const model = humanPromptModel({ + kind: request.kind, + message: request.message, + schema: request.contentSchema, + data: request.contentData, + }); + const settle = ( + action: IHumanResponse["action"], + content?: Record + ): IHumanResponse => ({ + requestId: request.requestId, + action, + content: action === "accept" && model.carriesContent ? content : undefined, + done: true, + }); + + if (model.shape === "acknowledge") { + this.renderers.notice([ + `${model.title}: ${model.message}`, + ...model.details.map((detail) => ` ${detail.label}: ${detail.value}`), + ]); + return settle("accept"); + } + + if (model.shape === "approval") { + // Printed rather than offered as fields: the schema of a confirm + // describes the action awaiting approval, and drawing it as a form would + // turn a description into something to edit. + this.renderers.notice([ + model.title, + ...model.details.map((detail) => ` ${detail.label}: ${detail.value}`), + ]); + const chosen = await untilAborted( + this.renderers.select(APPROVAL_OPTIONS, model.message, signal), + signal + ); + // Walking away is not the same answer as refusing, and the caller acts + // differently on each. + if (chosen === undefined) return settle("cancel"); + return settle(chosen === "decline" ? "decline" : "accept"); + } + + const schema = asDataPortSchemaObject(request.contentSchema); + const fields = await prepareSchemaFormFields( + (request.contentData as Record | undefined) ?? {}, + schema + ); + const values = await untilAborted(this.renderers.form(fields, signal), signal); + if (values === undefined) return settle("cancel"); + return settle("accept", values); + } +} diff --git a/examples/cli/src/ui/TaskRunApp.tsx b/examples/cli/src/ui/TaskRunApp.tsx index 4911ac62b..d93e7711d 100644 --- a/examples/cli/src/ui/TaskRunApp.tsx +++ b/examples/cli/src/ui/TaskRunApp.tsx @@ -152,9 +152,12 @@ export function TaskRunApp({ if (msg) progressRef.current.msg = msg; }); - task.events.on("stream_chunk", (event: { type: string; text?: string }) => { - if (event.type === "text-delta" && event.text) { - setStreamText((prev) => prev + event.text); + // `textDelta`, not `text`: that is the field name on StreamTextDelta, and + // the loose local type here is why reading the wrong one showed as an + // empty panel rather than as a compile error. + task.events.on("stream_chunk", (event: { type: string; textDelta?: string }) => { + if (event.type === "text-delta" && event.textDelta) { + setStreamText((prev) => prev + event.textDelta); } }); diff --git a/examples/cli/src/ui/promptAbort.ts b/examples/cli/src/ui/promptAbort.ts new file mode 100644 index 000000000..17abd7909 --- /dev/null +++ b/examples/cli/src/ui/promptAbort.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +/** The part of an Ink instance a prompt has to take back. */ +export interface PromptInstance { + clear(): void; + unmount(): void; +} + +/** + * Gives the terminal back when the caller stops waiting. + * + * A prompt mounts an Ink app and resolves when a person answers it; neither is + * reachable from outside, so an aborted run would otherwise leave the app on + * screen and the promise pending forever. + * + * **Detach when the prompt settles.** `clear()` writes an erase sequence to + * stdout, so a listener left attached past a normal answer wipes whatever is on + * screen the next time the run aborts — in a session that keeps its transcript + * in scrollback, that is the conversation. A run holding one signal across + * several prompts would also stack a listener per prompt, and erase once each. + * + * @returns the detach, which is safe to call more than once and when no signal + * was given. + */ +export function releaseOnAbort( + signal: AbortSignal | undefined, + instance: PromptInstance, + resolve: (value: undefined) => void +): () => void { + if (!signal) return () => {}; + const release = (): void => { + instance.clear(); + instance.unmount(); + resolve(undefined); + }; + if (signal.aborted) { + release(); + return () => {}; + } + signal.addEventListener("abort", release, { once: true }); + return () => signal.removeEventListener("abort", release); +} diff --git a/examples/cli/src/ui/render.ts b/examples/cli/src/ui/render.ts index 70dad6205..58d8cd11b 100644 --- a/examples/cli/src/ui/render.ts +++ b/examples/cli/src/ui/render.ts @@ -11,6 +11,7 @@ import React from "react"; import type { PromptFieldDescriptor } from "../input/prompt"; import { getCliTheme } from "../terminal/detectTerminalTheme"; import { formatError, outputResult } from "../util"; +import { releaseOnAbort } from "./promptAbort"; import { CliThemeProvider } from "./CliThemeContext"; import { SchemaPromptApp } from "./SchemaPromptApp"; import type { SearchSelectAppProps, SearchSelectItem } from "./SearchSelectApp"; @@ -133,16 +134,20 @@ export interface SchemaPromptRenderOptions { export async function renderSchemaPrompt( fields: readonly PromptFieldDescriptor[], - options?: SchemaPromptRenderOptions + options?: SchemaPromptRenderOptions, + signal?: AbortSignal ): Promise | undefined> { return new Promise | undefined>((resolve) => { + let detachAbort = (): void => {}; const onComplete = (values: Record) => { + detachAbort(); instance.clear(); instance.unmount(); resolve(values); }; const onCancel = () => { + detachAbort(); instance.clear(); instance.unmount(); console.log("Cancelled."); @@ -159,6 +164,7 @@ export async function renderSchemaPrompt( }) ) ); + detachAbort = releaseOnAbort(signal, instance, resolve); }); } @@ -195,10 +201,13 @@ export async function renderSearchSelect( export async function renderSelectPrompt( options: Array<{ label: string; value: string }>, - message?: string + message?: string, + signal?: AbortSignal ): Promise { return new Promise((resolve) => { + let detachAbort = (): void => {}; const onSelect = (value: string) => { + detachAbort(); instance.clear(); instance.unmount(); const label = message?.replace(/:$/, "") ?? "Selected"; @@ -208,6 +217,7 @@ export async function renderSelectPrompt( }; const onCancel = () => { + detachAbort(); instance.clear(); instance.unmount(); console.log("Cancelled."); @@ -224,5 +234,6 @@ export async function renderSelectPrompt( }) ) ); + detachAbort = releaseOnAbort(signal, instance, resolve); }); } diff --git a/examples/cli/src/web/client/main.tsx b/examples/cli/src/web/client/main.tsx index a15291c08..034d7a2ac 100644 --- a/examples/cli/src/web/client/main.tsx +++ b/examples/cli/src/web/client/main.tsx @@ -43,14 +43,18 @@ import { } from "./heartbeat"; import { loadRailWidths, saveRailWidths, type RailSide, type RailWidths } from "./railWidths"; import { + appendChatAsk, applyRecord, + chatTranscript, emptyRunView, filterCommandTree, + isChatRequest, openPathsFor, stackedPane, type RunViewState, type StackedPane, } from "./state"; +import { ChatTranscript } from "./views/ChatTranscript"; import { CommandTree } from "./views/CommandTree"; import { GroupView } from "./views/GroupView"; import { HumanPrompt } from "./views/HumanPrompt"; @@ -610,7 +614,37 @@ function App(): JSX.Element { {error} ) : null} - {view.humanRequest && run ? ( + {view.humanRequest && run && isChatRequest(view.humanRequest) ? ( + { + if (!cli.online) return; + void answerHuman(run.id, { + requestId: view.humanRequest!.requestId, + action: "accept", + content: { message: text }, + done: true, + }); + setView((current) => ({ + ...appendChatAsk(current, text), + humanRequest: undefined, + })); + }} + onEnd={() => { + if (!cli.online) return; + void answerHuman(run.id, { + requestId: view.humanRequest!.requestId, + action: "decline", + content: undefined, + done: true, + }); + setView((current) => ({ ...current, humanRequest: undefined })); + }} + /> + ) : null} + {view.humanRequest && run && !isChatRequest(view.humanRequest) ? ( { expect(stackedPane("back")).toBe("list"); }); }); + +describe("the console's view of a conversation", () => { + const chatSchema = { + type: "object", + properties: { message: { type: "string", format: "chat-message" } }, + }; + + function turnRow(state: RunViewState, id: string): RunViewState { + return reduceRunEvent(state, { + k: "task_added", + id, + type: "AgentTask", + label: "Agent", + depth: 0, + }); + } + + it("tells a chat request apart from a form", () => { + expect( + isChatRequest({ + requestId: "r1", + kind: "elicit", + message: "Your message", + schema: chatSchema, + data: undefined, + }) + ).toBe(true); + expect( + isChatRequest({ + requestId: "r2", + kind: "elicit", + message: "Your name", + schema: { type: "object", properties: { name: { type: "string" } } }, + data: undefined, + }) + ).toBe(false); + // An approval carrying the same field is still an approval — drawing it as + // a chat box would leave nowhere to say no. + expect( + isChatRequest({ + requestId: "r3", + kind: "confirm", + message: "Run it?", + schema: chatSchema, + data: undefined, + }) + ).toBe(false); + expect(isChatRequest(undefined)).toBe(false); + }); + + it("pairs each answer with the turn it started", () => { + let state = appendChatAsk(emptyRunView(), "first"); + state = turnRow(state, "t1"); + state = reduceRunEvent(state, { k: "text", id: "t1", delta: "one" }); + state = reduceRunEvent(state, { k: "status", id: "t1", status: "COMPLETED" }); + state = appendChatAsk(state, "second"); + state = turnRow(state, "t2"); + state = reduceRunEvent(state, { k: "text", id: "t2", delta: "tw" }); + + expect(chatTranscript(state)).toEqual([ + { role: "user", text: "first", pending: false }, + { role: "assistant", text: "one", pending: false }, + { role: "user", text: "second", pending: false }, + // Still running, and showing what it has said so far rather than nothing. + { role: "assistant", text: "tw", pending: true }, + ]); + }); + + it("shows a message whose turn has not written anything yet", () => { + let state = appendChatAsk(emptyRunView(), "hello"); + state = turnRow(state, "t1"); + + expect(chatTranscript(state)).toEqual([ + { role: "user", text: "hello", pending: false }, + { role: "assistant", text: "", pending: true }, + ]); + }); + + it("shows a message whose turn has not started at all", () => { + const state = appendChatAsk(emptyRunView(), "hello"); + expect(chatTranscript(state)).toEqual([{ role: "user", text: "hello", pending: false }]); + }); + + it("leaves a turn that finished silently out rather than showing an empty reply", () => { + let state = appendChatAsk(emptyRunView(), "hello"); + state = turnRow(state, "t1"); + state = reduceRunEvent(state, { k: "status", id: "t1", status: "COMPLETED" }); + + expect(chatTranscript(state)).toEqual([{ role: "user", text: "hello", pending: false }]); + }); + + it("ignores rows that are not turns", () => { + let state = appendChatAsk(emptyRunView(), "hello"); + state = reduceRunEvent(state, { + k: "task_added", + id: "tool", + type: "FetchUrlTask", + label: "Fetch", + depth: 1, + }); + state = reduceRunEvent(state, { k: "text", id: "tool", delta: "not the answer" }); + + expect(chatTranscript(state)).toEqual([{ role: "user", text: "hello", pending: false }]); + }); +}); diff --git a/examples/cli/src/web/client/state.ts b/examples/cli/src/web/client/state.ts index 98189f158..1f878bfa1 100644 --- a/examples/cli/src/web/client/state.ts +++ b/examples/cli/src/web/client/state.ts @@ -72,10 +72,80 @@ export interface RunViewState { readonly data: unknown; } | undefined; + /** + * What the person has sent this session, in order. + * + * Held rather than derived because it never crosses the event stream: the + * console typed it and answered with it, and what comes back up is the run + * getting on with the turn. + */ + readonly chatAsks: readonly string[]; readonly lastSeq: number; readonly nextOrder: number; } +/** One side of a conversation, as the console draws it. */ +export interface ChatEntry { + readonly role: "user" | "assistant"; + readonly text: string; + /** Whether this turn is still being written. */ + readonly pending: boolean; +} + +/** The task whose rows are turns of a conversation. */ +const CHAT_TURN_TYPE = "AgentTask"; + +/** + * Whether a run is asking for the next message of a conversation rather than + * filling in a form. + * + * Keyed on the `format` the asking side puts on the field, which is the same + * way every other port says what it means. A chat drawn as a one-line text + * input is answerable, but it is not a conversation, and the answer it takes + * shows up afterwards as a form somebody once filled in. + */ +export function isChatRequest(request: RunViewState["humanRequest"]): boolean { + if (!request || request.kind !== "elicit") return false; + const properties = (request.schema as { properties?: Record } | null) + ?.properties; + return Object.values(properties ?? {}).some((field) => field?.format === "chat-message"); +} + +/** + * The conversation, zipped from the two halves the console holds: what it sent, + * and what each turn wrote back. + * + * One turn is one {@link CHAT_TURN_TYPE} row, in the order the rows arrived, so + * the Nth answer belongs under the Nth message — a turn is only ever started by + * a message, so the pairing cannot slip. A turn still running shows what it has + * said so far rather than nothing. + */ +export function chatTranscript(state: RunViewState): readonly ChatEntry[] { + const turns = [...state.rows.values()] + .filter((row) => row.type === CHAT_TURN_TYPE) + .sort((left, right) => left.order - right.order); + const entries: ChatEntry[] = []; + for (let index = 0; index < state.chatAsks.length; index++) { + entries.push({ role: "user", text: state.chatAsks[index]!, pending: false }); + const turn = turns[index]; + if (!turn) continue; + const settled = SETTLED.has(turn.status); + if (turn.streamText.length === 0 && !settled) { + entries.push({ role: "assistant", text: "", pending: true }); + continue; + } + if (turn.streamText.length > 0) { + entries.push({ role: "assistant", text: turn.streamText, pending: !settled }); + } + } + return entries; +} + +/** Records a message the console just sent, so the transcript can show it. */ +export function appendChatAsk(state: RunViewState, text: string): RunViewState { + return { ...state, chatAsks: [...state.chatAsks, text] }; +} + export function emptyRunView(): RunViewState { return { rows: new Map(), @@ -87,6 +157,7 @@ export function emptyRunView(): RunViewState { error: undefined, output: undefined, humanRequest: undefined, + chatAsks: [], lastSeq: 0, nextOrder: 0, }; diff --git a/examples/cli/src/web/client/views/ChatTranscript.tsx b/examples/cli/src/web/client/views/ChatTranscript.tsx new file mode 100644 index 000000000..718d96088 --- /dev/null +++ b/examples/cli/src/web/client/views/ChatTranscript.tsx @@ -0,0 +1,92 @@ +/** @jsxImportSource preact */ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { JSX } from "preact"; +import { useState } from "preact/hooks"; +import type { ChatEntry } from "../state"; + +/** + * A conversation, and the box to add to it. + * + * The run asks for each message the same way it asks for anything else, so this + * is a rendering of a human request rather than a channel of its own — what + * makes it a conversation rather than a form is that the answers stay on + * screen underneath the question. + */ +export function ChatTranscript({ + entries, + message, + canAnswer, + onSend, + onEnd, +}: { + readonly entries: readonly ChatEntry[]; + /** What the run called the thing it is asking for. */ + readonly message: string; + /** False while the CLI is not answering; the run cannot receive a reply. */ + readonly canAnswer: boolean; + readonly onSend: (text: string) => void; + readonly onEnd: () => void; +}): JSX.Element { + const [draft, setDraft] = useState(""); + const trimmed = draft.trim(); + const send = (): void => { + if (!canAnswer || trimmed.length === 0) return; + setDraft(""); + onSend(trimmed); + }; + + return ( +
+
+ {entries.length === 0 ? ( +
+ Nothing said yet. +
+ ) : ( + entries.map((entry, index) => ( +
+
+ {entry.role === "user" ? "You" : "Agent"} + {entry.pending ? · still writing : null} +
+
+ {entry.text} +
+
+ )) + )} +
+
{message}
+