From 4149a989f82806c089260728abb94c65d0ee4bd5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 13 Oct 2026 23:11:45 +0200 Subject: [PATCH 1/4] feat(ai-harness): createSessionView, one live store for a session in any UI --- .changeset/ai-harness-view.md | 5 + docs/config.json | 5 + docs/harness/auth.md | 13 +- docs/harness/connect.md | 4 +- docs/harness/custom-ui.md | 539 ++++++++++ docs/harness/fork-and-reset.md | 2 +- docs/harness/media.md | 2 + docs/harness/plugins.md | 2 +- docs/harness/subagents.md | 2 +- examples/ts-code-mode-web/package.json | 2 +- examples/ts-react-chat/package.json | 2 +- examples/ts-solid-chat/package.json | 2 +- packages/ai-harness/package.json | 7 + packages/ai-harness/src/view/index.ts | 686 ++++++++++++ packages/ai-harness/src/view/reduce.ts | 785 ++++++++++++++ packages/ai-harness/src/view/types.ts | 161 +++ .../tests/view-client-tools.test.ts | 175 ++++ packages/ai-harness/tests/view-fixtures.ts | 60 ++ packages/ai-harness/tests/view-media.test.ts | 375 +++++++ packages/ai-harness/tests/view-paths.test.ts | 814 +++++++++++++++ packages/ai-harness/tests/view-reduce.test.ts | 988 ++++++++++++++++++ packages/ai-harness/tests/view.test.ts | 466 +++++++++ packages/ai-harness/vite.config.ts | 2 +- pnpm-lock.yaml | 16 +- 24 files changed, 5097 insertions(+), 18 deletions(-) create mode 100644 .changeset/ai-harness-view.md create mode 100644 docs/harness/custom-ui.md create mode 100644 packages/ai-harness/src/view/index.ts create mode 100644 packages/ai-harness/src/view/reduce.ts create mode 100644 packages/ai-harness/src/view/types.ts create mode 100644 packages/ai-harness/tests/view-client-tools.test.ts create mode 100644 packages/ai-harness/tests/view-fixtures.ts create mode 100644 packages/ai-harness/tests/view-media.test.ts create mode 100644 packages/ai-harness/tests/view-paths.test.ts create mode 100644 packages/ai-harness/tests/view-reduce.test.ts create mode 100644 packages/ai-harness/tests/view.test.ts diff --git a/.changeset/ai-harness-view.md b/.changeset/ai-harness-view.md new file mode 100644 index 0000000000..9e8a153b20 --- /dev/null +++ b/.changeset/ai-harness-view.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-harness': minor +--- + +Add `createSessionView()` in `@tanstack/ai-harness/view`. It turns a session (local, or remote through `createHarnessClient()`) into one live TanStack Store state: messages with their parts, tool calls, approvals, client tools, questions, sign-ins, child agents, media with signed URLs, settings, commands, and plugin state. It also gives actions (`send`, `command`, `approve`, `reject`, `cancel`, ...) and typed events (`view.on('turnEnd', ...)`, plugin events). The state works with any UI library. diff --git a/docs/config.json b/docs/config.json index 02a28d1429..e8ce1ee679 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1008,6 +1008,11 @@ "to": "harness/shared-threads", "addedAt": "2026-10-13" }, + { + "label": "Build your own UI", + "to": "harness/custom-ui", + "addedAt": "2026-10-13" + }, { "label": "Run agents from a harness", "to": "harness/subagents", diff --git a/docs/harness/auth.md b/docs/harness/auth.md index 55321d44cd..bd0c43edda 100644 --- a/docs/harness/auth.md +++ b/docs/harness/auth.md @@ -115,14 +115,21 @@ When the credential is missing, the turn stops with an interrupt: - `wait` is for a tool of the running chat turn. Do not pass it in a command, a background agent, or a hook. The credentials of a command never wait. - Provider keys (`ctx.keys.require`) do not wait. -In a web app, run the connector command when the user presses your connect button. The session sends a `harness.auth_required` event with the `url` and the `userCode` to show. A client can run only the commands in `expose.commands`, so the harness above exposes `connect:github` (see [Choose what clients can change](./connect#choose-what-clients-can-change)): +In a web app, the wait shows in `state.signIns` of the session view, also after a reload. When the user presses your connect button, run the connector command. Each sign-in then has the `url` and the `userCode` to show. A client can run only the commands in `expose.commands`, so the harness above exposes `connect:github` (see [Choose what clients can change](./connect#choose-what-clients-can-change)): ```ts group=harness-auth-wait-client import { createHarnessClient } from '@tanstack/ai-harness/client' +import { createSessionView } from '@tanstack/ai-harness/view' -const client = createHarnessClient({ url: '/api/harness', threadId: 'user-1-thread' }) +const view = createSessionView( + createHarnessClient({ url: '/api/harness', threadId: 'user-1-thread' }), +) -export const connectGithub = () => client.command('connect:github') +view.on('signIn', (signIn) => { + console.log(`Connect ${signIn.connector} to continue the turn.`) +}) + +export const connectGithub = () => view.command('connect:github') ``` After the sign-in, the turn finishes its work with no new message. diff --git a/docs/harness/connect.md b/docs/harness/connect.md index f86f11659c..960b7bcf16 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -69,7 +69,7 @@ export const team = defineHarness({ | `agents` | Run these agents, for example with `client.agents..start(input)`. | | `settings` | Change these [thread settings](./thread-settings) with `client.configure(settings)`. | | `config` | Set these plugin config keys with `client.setConfig(key, value)`. | -| `commands` | Run these commands with `client.command(name)`. | +| `commands` | Run these commands with `client.command(name)`, or with `/name` in a [session view](./custom-ui) on a client. | - Any other input of these kinds gets `{ status: 'rejected', reason: 'not_exposed' }`. - `GET describe` and `client.describe()` list only the exposed commands and config keys. A UI built from them shows only what a client can use. @@ -163,7 +163,7 @@ export function addTitle(operationId: string) { } ``` -The id of the run is the `operationId` in the receipt of `client.agents.drafter.start()`. +The id of the run is the `operationId` in the receipt of `client.agents.drafter.start()`. A [session view](./custom-ui#3-act-on-the-session) also lists the running agents with their ids. - `mode: 'steer'` (the default): the message joins the next model call of the run. - `mode: 'followUp'`: the agent runs again after the run ends. `receipt.operationId` is the id of the new run. diff --git a/docs/harness/custom-ui.md b/docs/harness/custom-ui.md new file mode 100644 index 0000000000..a78bbea267 --- /dev/null +++ b/docs/harness/custom-ui.md @@ -0,0 +1,539 @@ +--- +title: Build your own UI +id: harness-custom-ui +order: 13 +description: "Show a harness session in your own terminal screen or web app. One live store holds the messages, tool calls, approvals, and plugin state, and works with any UI library." +keywords: + - tanstack ai + - harness + - session view + - tanstack store + - ink + - custom ui +--- + +You want your own screen for your harness: your own layout in a terminal, or a page in your web app. `session.events()` gives you raw AG-UI events. To show them, you must join text deltas, follow each tool call, and keep a list of open approvals. That is a reducer, and you must keep it correct. + +`createSessionView` does that work. It keeps one live state in a TanStack Store, and it gives you actions and typed events. You show the state with any UI library. + +At the end of this page you have a terminal screen in about 20 lines, and the same view in a web app. + +## Install + +Install the harness and the TanStack Store adapter for your UI library. With no UI library, the harness is enough. + + + +react: @tanstack/ai-harness @tanstack/react-store +vue: @tanstack/ai-harness @tanstack/vue-store +solid: @tanstack/ai-harness @tanstack/solid-store +svelte: @tanstack/ai-harness @tanstack/svelte-store +preact: @tanstack/ai-harness @tanstack/preact-store +angular: @tanstack/ai-harness @tanstack/angular-store +vanilla: @tanstack/ai-harness +octane: @tanstack/ai-harness + + + +The terminal screen on this page uses Ink, which needs Node 22 or later. Install Ink and React too: + + + +react: ink react + + + +## 1. Make a view + +Open a session, then give it to `createSessionView`: + +```ts group=harness-custom-ui +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createSessionView } from '@tanstack/ai-harness/view' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) + +const host = createHarnessHost({ persistence: memoryPersistence() }) +const session = await host.open(assistant, { threadId: 'thread-1' }) + +const view = createSessionView(session) +await view.ready +``` + +`view.ready` resolves when the view has the saved messages, the status, and the commands and settings of the session. If you open a thread again, its history is in the view immediately. + +## 2. Show the state + +`view.store` is a TanStack Store. `useSelector` reads one part of the state, and your component updates only when that part changes. This Ink screen shows the messages and the status: + +```tsx ignore +import { Box, Text, render } from 'ink' +import { useSelector } from '@tanstack/react-store' +import type { SessionView } from '@tanstack/ai-harness/view' + +function Screen({ view }: { view: SessionView }) { + const messages = useSelector(view.store, (state) => state.messages) + const status = useSelector(view.store, (state) => state.status) + return ( + + {messages.map((message) => + message.role === 'assistant' ? ( + + {message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')} + + ) : ( + + {message.role === 'user' ? `> ${message.text}` : message.text} + + ), + )} + {status} + + ) +} + +render() +await view.send('Summarize the README.') +``` + +Put the code of steps 1 and 2 in one `.tsx` file, then run it. The screen shows your prompt, then the answer while it streams, then the status `idle`. Press Ctrl+C to quit. + +Each TanStack Store adapter reads the store the same way. Vue, Solid, Svelte, and Preact have `useSelector`. Angular has `injectSelector`. With no UI library, subscribe to the store: + +```ts group=harness-custom-ui +const subscription = view.store.subscribe((state) => { + console.log(`${state.status}: ${state.messages.length} messages`) +}) +``` + +Call `subscription.unsubscribe()` to stop. + +### What the state holds + +The conversation: + +- `messages`: the lines to show. Each message has a `role`: `user`, `assistant`, or `notice`. +- `status`: `idle`, `running`, or `requires_action`. +- `connection`: `open`, `reconnecting`, or `closed`. + +What waits for the user: + +- `approvals`: tool calls that wait for a yes or a no. +- `clientTools`: tool calls that run in your UI and wait for their output. See [Run a tool in the browser](#run-a-tool-in-the-browser). +- `questions`: questions from a command, a plugin, or an MCP server. See [Show a question](#show-a-question). +- `signIns`: connectors that need a sign-in, with a `url` and a `userCode` when the connector gives them. + +The session: + +- `threadId`: the id of the conversation. +- `agents`: the background agents that run now. Each one has an `id`, a `name`, and `send`. +- `queuedTurns`: the number of messages that wait for their turn. +- `commands`, `config`, and `tools`: what the session has, for a help screen or a settings panel. +- `plugins`: the saved state of each plugin, by plugin name. + +An assistant message has `parts`. Each part is one of these: + +- `text` or `reasoning`: the text, which grows while it streams. +- `tool-call`: a tool call with its `name`, `args`, and `status` (`running`, `done`, `failed`, or `needs-approval`). +- `agent`: a child agent, with its own `parts`. +- `media`: a file that an agent made. See [Show media](#show-media). + +A notice has a `kind`: + +- `info`: the session continues a turn that a crash stopped, or a [reset](./fork-and-reset#reset-with-a-handoff-note) started a fresh context. +- `error`: a turn or an action failed. +- `rejected`: the session did not accept a message. +- `command`: the text result of a command. +- `ui`: a line that you added with `view.notice(text)`. + +## 3. Act on the session + +Start and stop work: + +- `view.send(text)`: sends a prompt. While a turn runs, the text steers that turn. Text that starts with `/` runs a command, for example `/goal all tests pass`. +- `view.command(name, input)`: runs a command, for example from a button. +- `view.setConfig(key, value)`: changes a setting, for example `view.setConfig('mode', 'plan')`. +- `view.cancel()`: cancels the running turn. +- `view.dispose()`: stops the view when your screen closes. After that, each action throws an error. + +On a view with a [`HarnessClient`](#a-ui-in-the-browser), two actions need `expose` in the harness: + +- `view.command`, and a `/command` in `view.send`, run only the commands in `expose.commands`. +- `view.setConfig` sets only the keys in `expose.config`. + +For any other command or key, the view adds a `rejected` notice with the text `Not accepted: not_exposed`. It also calls your `'error'` handler with that text. On a client, `commands` and `config` in the state list only the exposed items. + +See [Choose what clients can change](./connect#choose-what-clients-can-change). + +Answer what waits: + +- `approval.approve()` and `approval.reject()`: answer one approval. `view.approve(id)` and `view.reject(id)` do the same by id. +- `view.approveAll()` and `view.rejectAll()`: answer all open approvals. +- `call.resolve(output)` and `call.fail(message)`: answer one client tool call. +- `question.answer(value)`: answers a question. + +If a turn waits for more than one approval or client tool, the turn continues after you answer all of them. + +Message a background agent: + +- `agent.send(text, mode)`: sends a message to one item of `state.agents`. `mode` is `'steer'` (the default) or `'followUp'`. See [Message a running agent](./subagents#message-a-running-agent). + +```ts group=harness-custom-ui +const [agent] = view.store.get().agents +if (agent) { + await agent.send('Add a title.', 'followUp') +} +``` + +`send` needs a source that can message agents. A session and a `HarnessClient` both can. If you give `createSessionView` your own `SessionViewSource` without `sendToAgent`, `send` rejects with an error. + +## 4. Listen for events + +Some items need attention when they arrive. `view.on` calls your handler for each one. When a tool call waits for approval, this handler rings the terminal bell and adds a line to the screen: + +```ts group=harness-custom-ui +view.on('approval', (approval) => { + process.stdout.write('\u0007') + view.notice(`${approval.tool} waits for your answer.`) +}) +``` + +Items that wait for the user: + +- `'approval'`: an approval. Call `approve()` or `reject()` on it. +- `'clientTool'`: a client tool call. Call `resolve(output)` or `fail(message)` on it. +- `'question'`: a question with a `message`, a `schema`, a `url` when the user must open a page, and `answer(value)`. +- `'signIn'`: a connector that needs a sign-in, with `connector`, `url`, and `userCode`. + +Progress: + +- `'toolCall'`: `{ id, name }`, when a tool call starts. +- `'agent'`: `{ id, name, status }`, when a child agent starts, finishes, or fails. +- `'error'`: the error text. The view also adds it to `messages` as a notice. +- `'turnEnd'`: `{ operationId }`, when a chat turn ends. + +The view calls your handler after the item is in the state, so the handler can read `view.store.get()` and find the item. `view.on` returns a function that removes the handler. + +## Show a question + +A command or a plugin can ask the user a question. Each item of `state.questions` has: + +- `id` and `message`. +- `schema`: the JSON Schema of the answer, when the question has one. Without a schema, the answer is text. +- `secret`: the answer is a key or a password. Hide what the user types. +- `url`: a page that the user must open before they answer, for example a payment page. +- `answer(value)`: sends the answer. + +When a question has a `url`, show the link. The user opens it, then answers. The session does not open the page. This handler adds the link to the screen: + +```ts group=harness-custom-ui +view.on('question', (question) => { + if (question.url) view.notice(`Open ${question.url}, then answer.`) +}) +``` + +### Show a field only when it applies + +A schema can make a field apply only when another field has a value. JSON Schema writes this with `if` and `then`. In this schema, `address` applies only when `delivery` is `mail`: + +```json +{ + "type": "object", + "properties": { + "delivery": { "type": "string", "enum": ["pickup", "mail"] }, + "address": { "type": "string" } + }, + "required": ["delivery"], + "if": { "properties": { "delivery": { "const": "mail" } } }, + "then": { "required": ["address"] } +} +``` + +The view gives you the schema as is. Your UI reads the condition. This function returns the fields that `then` adds for the answer so far: + +```ts group=harness-custom-ui +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function fieldsThatApply(schema: unknown, answer: Record) { + if (!isRecord(schema) || !isRecord(schema.if) || !isRecord(schema.then)) { + return [] + } + const rules = isRecord(schema.if.properties) + ? Object.entries(schema.if.properties) + : [] + const matches = rules.every( + ([field, rule]) => isRecord(rule) && answer[field] === rule.const, + ) + const required = schema.then.required + return matches && Array.isArray(required) ? required : [] +} +``` + +Call it each time the user changes a field. Show a field from the result, and hide it again when the result no longer has it: + +```ts group=harness-custom-ui +const [question] = view.store.get().questions +const extra = fieldsThatApply(question?.schema, { delivery: 'mail' }) +// With the schema above, `extra` is ['address']. +``` + +## Show media + +A user sends a screenshot, and an agent draws an image. The view gives you each file as a media part, with a URL that works in ``, `