sygnal 6.1.1 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +3 -1
- package/dist/ai.cjs.js +36 -0
- package/dist/ai.cjs.js.map +1 -1
- package/dist/ai.esm.js +1 -1
- package/dist/astro/server.cjs.js +3 -3
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3 -3
- package/dist/astro/server.mjs.map +1 -1
- package/dist/diagnostics.cjs.js +174 -6
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +174 -6
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/adapters.md +2 -1
- package/dist/guide/agent.md +119 -7
- package/dist/guide/ai-cards.md +319 -0
- package/dist/guide/ai-chat.md +64 -16
- package/dist/guide/ai-compose.md +881 -0
- package/dist/guide/ai-decisions.md +17 -1
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/error-boundaries.md +2 -1
- package/dist/guide/forms.md +1 -1
- package/dist/guide/http.md +5 -4
- package/dist/guide/mcp-apps.md +4 -2
- package/dist/guide/persistence.md +1 -1
- package/dist/guide/recipes/ai-form-fill.md +1 -1
- package/dist/guide/recipes/ai-summarize.md +1 -1
- package/dist/guide/recipes/ai-support-inbox.md +1 -1
- package/dist/guide/resources.md +11 -10
- package/dist/guide/ui/combobox.md +1 -1
- package/dist/guide/undo.md +1 -1
- package/dist/guide/web-components.md +1 -0
- package/dist/guide/webmcp.md +1 -2
- package/dist/index.cjs.js +5842 -370
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +679 -13
- package/dist/index.esm.js +5832 -370
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vike/onRenderClient.cjs.js +3 -2
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +3 -2
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/llms.txt +8 -6
- package/package.json +2 -1
- package/src/ai.d.ts +683 -12
- package/src/ai.ts +6 -0
- package/src/collection.ts +2 -1
- package/src/core/instance.ts +6 -3
- package/src/core/markers/lazy.ts +7 -3
- package/src/core/markers/portal.ts +5 -2
- package/src/core/markers/suspense.ts +3 -2
- package/src/core/markers/transition.ts +6 -3
- package/src/core/registry.ts +9 -5
- package/src/cycle/dom/IsolateModule.ts +25 -22
- package/src/extra/ai/agent/index.ts +111 -26
- package/src/extra/ai/chat/behavior.ts +368 -87
- package/src/extra/ai/chat/driver.ts +7 -4
- package/src/extra/ai/compose/agent.ts +55 -0
- package/src/extra/ai/compose/ask.ts +147 -0
- package/src/extra/ai/compose/behavior.ts +183 -0
- package/src/extra/ai/compose/catalog.ts +211 -0
- package/src/extra/ai/compose/composed.ts +61 -0
- package/src/extra/ai/compose/decisions.ts +1649 -0
- package/src/extra/ai/compose/diag.ts +33 -0
- package/src/extra/ai/compose/index.ts +23 -0
- package/src/extra/ai/compose/layout.ts +441 -0
- package/src/extra/ai/compose/llm.ts +1120 -0
- package/src/extra/ai/compose/render.ts +159 -0
- package/src/extra/ai/compose/testing.ts +71 -0
- package/src/extra/ai/compose/types.ts +159 -0
- package/src/extra/ai/decide.ts +97 -1
- package/src/extra/ai/index.ts +6 -1
- package/src/extra/ai/webmcp.ts +5 -3
- package/src/extra/diagnostics/checks/next.ts +86 -2
- package/src/extra/diagnostics/codes.ts +64 -3
- package/src/extra/fetchDriver.ts +58 -27
- package/src/extra/lens.ts +15 -0
- package/src/extra/ssr.ts +3 -3
- package/src/extra/testing.ts +2 -0
- package/src/extra/widget.ts +3 -3
- package/src/index.d.ts +42 -1
- package/src/index.ts +8 -0
- package/src/lazy.ts +3 -2
- package/src/portal.ts +5 -2
- package/src/pragma/index.ts +2 -1
- package/src/suspense.ts +5 -2
- package/src/switchable.ts +2 -1
- package/src/transition.ts +5 -2
- package/src/vike/onRenderClient.ts +3 -2
package/dist/guide/adapters.md
CHANGED
|
@@ -24,7 +24,8 @@ npm install react react-dom
|
|
|
24
24
|
The React component here is a small one of our own, written with React's `createElement` (the JSX on these pages is Sygnal's); a component from npm is wrapped the same way:
|
|
25
25
|
|
|
26
26
|
```js live-file=./Rating.js
|
|
27
|
-
// Rating.js
|
|
27
|
+
// Rating.js
|
|
28
|
+
// a React component
|
|
28
29
|
import { createElement as h } from 'react'
|
|
29
30
|
|
|
30
31
|
export function Rating({ value, max, onChange }) {
|
package/dist/guide/agent.md
CHANGED
|
@@ -69,6 +69,7 @@ TodoApp.agent = {
|
|
|
69
69
|
| `label(state)` | A [Collection item](#collection-items)'s label for the model |
|
|
70
70
|
| `untrusted` | `read` returns text users typed (see [Security](#security)). Unset, Sygnal infers it from the strings in the projection |
|
|
71
71
|
| `actions` | The only actions agents may run, by action name |
|
|
72
|
+
| `queries` | Read-only tools that take an input: data too large for `read`, or answers computed from the state ([Queries](#queries)) |
|
|
72
73
|
|
|
73
74
|
Each entry of `actions`:
|
|
74
75
|
|
|
@@ -79,6 +80,8 @@ Each entry of `actions`:
|
|
|
79
80
|
| `consequential` | Ask the user before running it |
|
|
80
81
|
| `when(state)` | The tool is offered only while this is true |
|
|
81
82
|
| `idempotent` | A call that changes nothing is a success (`{ ok: true, unchanged: true }`), not an error |
|
|
83
|
+
| `until` | The call answers once the action's result is in state: a resource name, or a function of the state ([Waiting for a result](#waiting-for-a-result-until)) |
|
|
84
|
+
| `timeout` | With `until`: how long to wait, in ms (default 10,000) |
|
|
82
85
|
|
|
83
86
|
### Input schemas
|
|
84
87
|
|
|
@@ -109,7 +112,7 @@ In TypeScript, on a component typed with its actions ([TypeScript](https://sygna
|
|
|
109
112
|
|
|
110
113
|
## What agents see
|
|
111
114
|
|
|
112
|
-
**Tools.** One per listed action, plus `<name>_read` when the declaration has a `read`. The todo app above offers:
|
|
115
|
+
**Tools.** One per listed action and [query](#queries), plus `<name>_read` when the declaration has a `read`. The todo app above offers:
|
|
113
116
|
|
|
114
117
|
| Tool | Input | Hints |
|
|
115
118
|
|---|---|---|
|
|
@@ -131,7 +134,7 @@ Every call, from any kind of agent, goes through the same steps:
|
|
|
131
134
|
2. **Availability.** A tool whose `when` is false now gets `{ ok: false, error: 'todos_clear_done is not available now' }`.
|
|
132
135
|
3. **Confirmation.** A `consequential` action waits for the user (each consumer asks in its own way). A declined call is `{ ok: false, error: 'the user declined' }`. The target and `when` are checked again after the answer.
|
|
133
136
|
4. **Dispatch.** The action runs with `cause: 'agent'`, through the same queue, reducers and render as a click. It shows in `t.actions`, the DevTools action log and the dev MCP endpoint's `recent_actions` with that cause.
|
|
134
|
-
5. **The result**, after the render: `{ ok: true, state }`, where `state` is the nearest `read` projection (the component's, or for an item, its own or its list's).
|
|
137
|
+
5. **The result**, after the render: `{ ok: true, state }`, where `state` is the nearest `read` projection (the component's, or for an item, its own or its list's). An action with [`until`](#waiting-for-a-result-until) answers later, once its result is in state.
|
|
135
138
|
|
|
136
139
|
**No-op detection.** A call succeeds when one of the action's handlers had an effect: a reducer changed the state, a driver sink got a value, or an `EFFECT` ran. A call that changed nothing is reported as a failure, not a false success. Here, `SET_FILTER` to the filter already set, on an action without `idempotent`:
|
|
137
140
|
|
|
@@ -158,6 +161,107 @@ A refusal is reported even on an `idempotent` action.
|
|
|
158
161
|
|
|
159
162
|
**One at a time.** Calls run serially, in the order the model made them, so each one sees the previous one's result. A model that sends three calls at once gets three results computed in sequence.
|
|
160
163
|
|
|
164
|
+
## Waiting for a result: `until`
|
|
165
|
+
|
|
166
|
+
Some actions only start something: a search sets the query, and a [`resources`](./resources.md) entry fetches the results. Right after the action, the results aren't there yet, so the call would answer with `status: 'loading'`, and the model would have to guess or ask again. With `until`, the call answers once the result is in state:
|
|
167
|
+
|
|
168
|
+
```jsx
|
|
169
|
+
// Shop.jsx
|
|
170
|
+
import { z } from 'zod'
|
|
171
|
+
|
|
172
|
+
export default function Shop({ state }) {
|
|
173
|
+
const { status, data } = state.search
|
|
174
|
+
return (
|
|
175
|
+
<section>
|
|
176
|
+
{status === 'loading' && <p>Searching…</p>}
|
|
177
|
+
<ul className="results">{(data ?? []).map((p) => <li key={p.id}>{p.name}</li>)}</ul>
|
|
178
|
+
</section>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
Shop.initialState = { query: '' }
|
|
183
|
+
|
|
184
|
+
Shop.resources = { search: (state) => state.query && { url: '/api/search', query: { q: state.query } } }
|
|
185
|
+
|
|
186
|
+
Shop.model = { SEARCH: (state, query) => ({ ...state, query }) }
|
|
187
|
+
|
|
188
|
+
Shop.agent = {
|
|
189
|
+
name: 'shop',
|
|
190
|
+
description: 'The product search the user sees',
|
|
191
|
+
read: (state) => ({ query: state.query, status: state.search.status, results: (state.search.data ?? []).map(({ id, name }) => ({ id, name })) }),
|
|
192
|
+
actions: {
|
|
193
|
+
SEARCH: { description: 'Search the catalog; the results show on the page', input: z.string().min(1), until: 'search' },
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`shop_search({ value: 'red shoes' })` now answers with the results the user sees, `{ ok: true, state: { query: 'red shoes', status: 'success', results: [...] } }`, or with `status: 'error'` when the request failed.
|
|
199
|
+
|
|
200
|
+
- **`until: 'search'`** names one of the component's resources. The call answers once that resource is neither loading nor refreshing, so a [`keepPrevious`](./resources.md) request or a refetch, which keep the status and set `refreshing`, are waited for too. A name the component's `resources` doesn't have is [SYG248](https://sygnal.js.org/reference/errors/#syg248): the call answers at once.
|
|
201
|
+
- **`until: (state) => boolean`** is for a result a reply action writes (a driver request with an `ok` action). The action's own reducer must mark it pending, `status: 'loading'`, so the test is false right after the action: `until: (state) => state.status !== 'loading'`. A throwing `until` ends the wait ([SYG241](https://sygnal.js.org/reference/errors/#syg241)).
|
|
202
|
+
- **The test runs after the action's render and after every render that changed something.** A result already in state answers at once.
|
|
203
|
+
- **No result within `timeout`** (default 10 s): `{ ok: true, pending: true, note, state }`. The note tells the model the result will be in the app state later, where the `chat` behavior's next request shows it.
|
|
204
|
+
- **Calls run one at a time**, so a waiting call delays the model's next one. The [`chat` behavior](#an-in-app-assistant-chat) ends the wait when the user presses Stop or sends a new message (the call answers `pending`), and `agentTools()` has `cancel()` for other consumers.
|
|
205
|
+
- An item that removes itself while its call waits answers `{ ok: true, removed: true, state }`.
|
|
206
|
+
|
|
207
|
+
The side effect stays where it was: the request goes through `resources` or a driver, and a test answers it with the fetch fake, `await t.respond('HTTP', [...])`. `t.callTool()` waits for it like any agent.
|
|
208
|
+
|
|
209
|
+
## Queries
|
|
210
|
+
|
|
211
|
+
`read` goes to the model whole, with every request. That suits a summary, not a table of 5,000 invoices: sending them all costs tokens on every turn, and a model reads a long list badly anyway. A query is a read-only tool that takes an input and answers from the state: the model asks for the overdue invoices of one customer and gets those.
|
|
212
|
+
|
|
213
|
+
```jsx
|
|
214
|
+
// Invoices.jsx
|
|
215
|
+
import { z } from 'zod'
|
|
216
|
+
|
|
217
|
+
export default function Invoices({ state }) {
|
|
218
|
+
return <p className="count">{state.invoices.length} invoices</p>
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
Invoices.initialState = { invoices: [] }
|
|
222
|
+
|
|
223
|
+
const Filter = z.object({
|
|
224
|
+
customer: z.string().optional().describe('The customer name'),
|
|
225
|
+
overdue: z.boolean().optional(),
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
Invoices.agent = {
|
|
229
|
+
name: 'invoices',
|
|
230
|
+
description: 'The invoices',
|
|
231
|
+
untrusted: true,
|
|
232
|
+
read: (state) => ({ count: state.invoices.length }), // the summary, on every request
|
|
233
|
+
queries: {
|
|
234
|
+
// invoices_find({ customer?, overdue? })
|
|
235
|
+
find: {
|
|
236
|
+
description: 'The invoices matching a filter, at most 50',
|
|
237
|
+
input: Filter,
|
|
238
|
+
run: (state, { customer, overdue }) => state.invoices
|
|
239
|
+
.filter((i) => (customer === undefined || i.customer === customer) && (overdue === undefined || i.overdue === overdue))
|
|
240
|
+
.slice(0, 50),
|
|
241
|
+
},
|
|
242
|
+
// invoices_total()
|
|
243
|
+
total: { description: 'The sum of all invoices', run: (state) => state.invoices.reduce((sum, i) => sum + i.total, 0) },
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
| Key | What it does |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `description` | What the query returns, for the model |
|
|
251
|
+
| `input` | The schema of its input, as for an action ([Input schemas](#input-schemas)). Without one the query takes no arguments |
|
|
252
|
+
| `when(state)` | The tool is offered only while this is true |
|
|
253
|
+
| `run(state, input)` | The result: a pure, synchronous function of the component's state now and the validated input |
|
|
254
|
+
|
|
255
|
+
- **A query is a tool named `<name>_<query>`**, marked read-only. It is offered next to the actions, to every kind of agent (the command bar, which picks one action, leaves read-only tools out).
|
|
256
|
+
- **A call validates the input and checks `when`**, as for an action, then returns `{ ok: true, result }` with what `run` returned. Nothing is dispatched, nothing is confirmed, and nothing renders: a query can't change the app.
|
|
257
|
+
- **It must be synchronous.** A `run` that returns a promise (an `async` function, a `fetch`) fails the call and is [SYG246](https://sygnal.js.org/reference/errors/#syg246), as is a query without `run`. Data that has to be loaded belongs to an action: its model entry asks for it (a [`resources`](./resources.md) entry or a driver request), and [`until`](#waiting-for-a-result-until) makes the call answer with the result.
|
|
258
|
+
- **A throw** fails the call with its message and is [SYG241](https://sygnal.js.org/reference/errors/#syg241).
|
|
259
|
+
- **Actions and queries share the tool names.** A query whose snake-case name is an action's (`find` next to `FIND`) is [SYG247](https://sygnal.js.org/reference/errors/#syg247), and only the action is offered.
|
|
260
|
+
- **On a Collection item component**, a query gets the same key parameter as the item's actions (`contact_notes({ id, word })`), and answers from that item's state.
|
|
261
|
+
- **Results are data.** When they hold text users typed, set `untrusted: true` on the declaration: WebMCP then marks the queries with `untrustedContentHint` too ([Security](#security)). Keep results small (filter, then cut): WebMCP cuts a result over 1,500 characters of JSON ([SYG242](https://sygnal.js.org/reference/errors/#syg242)).
|
|
262
|
+
|
|
263
|
+
In TypeScript, `run`'s `state` is typed from the component; type the input yourself, `run: (state, input: z.infer<typeof Filter>) => …`, since it isn't inferred from `input`.
|
|
264
|
+
|
|
161
265
|
## Collection items
|
|
162
266
|
|
|
163
267
|
An item component's actions don't become one tool per item. They become one tool per action, with a parameter that names the item: an enum of the live keys (the items' `id`s), routed to the item with that key.
|
|
@@ -259,7 +363,7 @@ export default {
|
|
|
259
363
|
}
|
|
260
364
|
```
|
|
261
365
|
|
|
262
|
-
```jsx live live-height=380
|
|
366
|
+
```jsx live live-height=380 title="TodoApp.jsx"
|
|
263
367
|
import { z } from 'zod'
|
|
264
368
|
import { Collection } from 'sygnal'
|
|
265
369
|
import { chat, messageText } from 'sygnal/ai'
|
|
@@ -361,10 +465,12 @@ In production the provider key belongs on your server: `makeChatDriver({ transpo
|
|
|
361
465
|
| `prompt` | | The prompt field (its input sets `prompt`) |
|
|
362
466
|
| `stop`, `approve`, `deny`, `regenerate` | | Buttons: their clicks dispatch `STOP`, `APPROVE`, `DENY`, `REGENERATE` |
|
|
363
467
|
| `instructions` | | System instructions: your app's own text |
|
|
364
|
-
| `agent` | the host's and its shown descendants' | Whose declarations become tools: `false` for none, `[Comp, ...]` for only these (inside the host) |
|
|
468
|
+
| `agent` | the host's and its shown descendants' | Whose declarations become tools: `false` for none (a plain chat, [AI Chat](./ai-chat.md#the-same-chat-with-the-chat-behavior)), `[Comp, ...]` for only these (inside the host) |
|
|
365
469
|
| `maxSteps` | `8` | The most requests per turn; tool calls in a reply past it are not run |
|
|
366
470
|
| `sink` | `'LLM'` | The chat driver's name |
|
|
367
|
-
| `model`, `transportOptions` | | A model id,
|
|
471
|
+
| `model`, `transportOptions` | | A model id, or a function of the host's state that returns one, read per request (`(state) => state.chatModel`, for a model picker); extra request keys for the transport |
|
|
472
|
+
| `clientTools` | | Tools the app answers itself with `TOOL_OUTPUT` ([client tools](./ai-cards.md#client-tools)) |
|
|
473
|
+
| `ask` | | `{ catalog, backend?, instructions?, direct? }`: the `ask_user` tool, which asks the user with a composed card ([Cards in a Chat](./ai-cards.md)) |
|
|
368
474
|
|
|
369
475
|
The slice, `state.assistant` for `uses = { assistant: chat(...) }`:
|
|
370
476
|
|
|
@@ -377,7 +483,7 @@ The slice, `state.assistant` for `uses = { assistant: chat(...) }`:
|
|
|
377
483
|
| `pending` | A consequential call waiting for `APPROVE` / `DENY`: `{ tool, component, action, description, input, key?, label? }`, else `null` |
|
|
378
484
|
| `error` | The last failure's message, else `null` |
|
|
379
485
|
|
|
380
|
-
Actions, as `'assistant.X'`: `SEND` (the form's submit; or dispatch it with the text as data), `STOP` (aborts the reply, keeps the text so far, declines a waiting call), `REGENERATE` (sends the last user message again), `APPROVE`, `DENY`, and `DONE` (the turn is over: `{ message, text, finishReason, usage, steps }`; a host entry `'assistant.DONE'` runs after the behavior's).
|
|
486
|
+
Actions, as `'assistant.X'`: `SEND` (the form's submit; or dispatch it with the text as data), `STOP` (aborts the reply, keeps the text so far, declines a waiting call, and answers a call waiting on [`until`](#waiting-for-a-result-until) as pending), `REGENERATE` (sends the last user message again), `APPROVE`, `DENY`, and `DONE` (the turn is over: `{ message, text, finishReason, usage, steps }`; a host entry `'assistant.DONE'` runs after the behavior's).
|
|
381
487
|
|
|
382
488
|
**The markup belongs to the host's view.** A behavior listens to selectors in its host's own view, like every intent ([SYG104](https://sygnal.js.org/reference/errors/#syg104)): elements rendered inside a child component are out of its reach. So the panel is a plain function the host's view calls, `{assistantPanel(state.assistant)}`, not a `<AssistantPanel />` component. A function can still live in its own file and be shared.
|
|
383
489
|
|
|
@@ -385,6 +491,8 @@ Actions, as `'assistant.X'`: `SEND` (the form's submit; or dispatch it with the
|
|
|
385
491
|
|
|
386
492
|
**Approvals.** A consequential call sets `pending` and waits. Render `pending.description` and `pending.label` with the approve and deny buttons. `APPROVE` runs it; `DENY` tells the model the user declined, and the model continues from there. `STOP` and removing the component decline too.
|
|
387
493
|
|
|
494
|
+
**Composing the page.** An assistant can arrange a [composed page](./ai-compose.md) as an action: `actions: { ...compose.action('page') }` gives it the tool `<name>_page_compose({ prompt, fresh? })`, whose result waits for the composition, and `read: (state) => ({ page: compose.summary(state.page) })` shows it the page ([Compose from an agent](./ai-compose.md#compose-from-an-agent)).
|
|
495
|
+
|
|
388
496
|
## A command bar: `commandBar`
|
|
389
497
|
|
|
390
498
|
For "do one thing in this app" from one line of text, a chat model's conversation loop is more than you need. `commandBar` asks a **decision model** one question per command: which of the `agent` actions it means, and which Collection item. The answer comes with a confidence, and the action runs through the [call rules](#the-call-rules) only when the model is sure enough. In the research behind this feature, a local decision model picked the right action and target for 8 of 8 commands, in about 570 ms each. Decision models, `decide()` and confidence are in [AI Decisions](./ai-decisions.md).
|
|
@@ -414,7 +522,7 @@ export default {
|
|
|
414
522
|
}
|
|
415
523
|
```
|
|
416
524
|
|
|
417
|
-
```jsx live live-height=300
|
|
525
|
+
```jsx live live-height=300 title="Commands.jsx"
|
|
418
526
|
import { z } from 'zod'
|
|
419
527
|
import { Collection } from 'sygnal'
|
|
420
528
|
import { commandBar } from 'sygnal/ai'
|
|
@@ -581,6 +689,8 @@ it('is operable by agents', async () => {
|
|
|
581
689
|
```
|
|
582
690
|
|
|
583
691
|
- `t.tools()` lists the offered tools, plus any declared tool that can't be offered (SYG240) with its `error`.
|
|
692
|
+
- A [query](#queries) is called the same way: `await t.callTool('invoices_find', { customer: 'Acme' })` resolves with `{ ok: true, result }`.
|
|
693
|
+
- A call to an action with [`until`](#waiting-for-a-result-until) resolves once its result is in state: start it, answer the request (`await t.respond('HTTP', data)`), then await it. Under `vi.useFakeTimers()`, advancing past `timeout` gives `{ ok: true, pending: true }`.
|
|
584
694
|
- `t.callTool(name, args, { confirm })` resolves with the [call result](#the-call-rules) after the render. A consequential tool without `confirm` throws, so a test can't approve one by accident.
|
|
585
695
|
- `t.agentContext()` returns what the model sees: one entry per declaration with a `read`, keyed by the declaration's `name`, holding what its `read` returned. That is why the example above reads `todos.todos`: the declaration is named `todos`, and its `read` returns `{ todos, filter }`. An item declaration's entry is an array with one projection per live item, each with its `id` (`{ todo: [{ id: 1, done: false }] }` for `read: (s) => ({ done: s.done })`).
|
|
586
696
|
|
|
@@ -589,6 +699,8 @@ To test the assistant itself, drive the `LLM` fake: `t.stream('LLM', [{ toolCall
|
|
|
589
699
|
## Related
|
|
590
700
|
|
|
591
701
|
- [AI Chat](./ai-chat.md): the chat driver under the `chat` behavior, transports, and the raw tool loop
|
|
702
|
+
- [Cards in a Chat](./ai-cards.md): client tools (`clientTools`, `TOOL_OUTPUT`) and `chat({ ask })`
|
|
703
|
+
- [Composing UI with a Model](./ai-compose.md#compose-from-an-agent): `compose.action()`, the page as an agent action
|
|
592
704
|
- [AI Decisions](./ai-decisions.md): decision models, `decide()` and confidence, which the command bar uses
|
|
593
705
|
- [WebMCP](./webmcp.md): the same tools for the browser's agent (experimental)
|
|
594
706
|
- [MCP Apps](./mcp-apps.md): your app inside Claude, ChatGPT or VS Code, with its tools
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Cards in a Chat
|
|
3
|
+
|
|
4
|
+
Some answers are easier to click than to type: one of a few options, a plan from the app's list, a short form, "does this look right?". With `chat({ ask })` the assistant asks for them with a **card**. The model calls the `ask_user` tool and says in one sentence what it needs, the app composes a card from **parts it prepared** (the same [catalog](./ai-compose.md#the-catalog) as a composed page), and the user's answer goes back to the model as the tool's result.
|
|
5
|
+
|
|
6
|
+
As with a composed page, the model only chooses: every button and label on the card is the app's own component, and the answer is a value your component sends.
|
|
7
|
+
|
|
8
|
+
## A plan assistant
|
|
9
|
+
|
|
10
|
+
The demo server below stands in for the model. It matches words and replies in the Open Responses format a real model streams, through the page's `LLM` driver:
|
|
11
|
+
|
|
12
|
+
- the chat's first request: "Let me ask.", then an `ask_user` call;
|
|
13
|
+
- a card's request (a structured-output request: it has `text.format`): the layout JSON a chat model writes, from the call's `need`;
|
|
14
|
+
- a request after a card's answer (the last input is the tool's output, `{ part, answer }`): the next question, or the end.
|
|
15
|
+
|
|
16
|
+
```js live-server
|
|
17
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
18
|
+
|
|
19
|
+
const words = (text) => text.match(/\S+\s*/g)
|
|
20
|
+
const say = (chunks) => ({ sse: encodeOpenResponses(chunks), delayMs: 300, chunkMs: 40 })
|
|
21
|
+
const ask = (need) => ({ toolCall: { name: 'ask_user', input: { need } } })
|
|
22
|
+
|
|
23
|
+
// the card for a need: the layout JSON compose.llm() asks for (a real model reads the catalog)
|
|
24
|
+
function card(need) {
|
|
25
|
+
const children = /plan/i.test(need)
|
|
26
|
+
? [{ part: 'plans' }]
|
|
27
|
+
: [{ part: 'choice', params: { question: need, options: ['Just me', '2 to 10', 'More than 10'] } }]
|
|
28
|
+
return JSON.stringify({ asked_for: children.map((c) => c.part), children }).match(/[\s\S]{1,16}/g)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
'POST /v1/responses': ({ json }) => {
|
|
33
|
+
const last = json.input.at(-1)
|
|
34
|
+
// compose.llm() building a card: its message is the call's `need`
|
|
35
|
+
if (json.text?.format) return say(card(String(last.content)))
|
|
36
|
+
// a card was answered (or closed): its output is { part, answer } (or { error })
|
|
37
|
+
if (last.type === 'function_call_output') {
|
|
38
|
+
const out = JSON.parse(last.output)
|
|
39
|
+
if (out.part === 'choice' && out.answer === 'Just me') return say([...words('Then Free is enough. '), ask('Pick the plan you want')])
|
|
40
|
+
if (out.part === 'choice') return say([...words(`For ${out.answer.toLowerCase()} people, Team or Business. `), ask('Pick the plan you want')])
|
|
41
|
+
if (out.part === 'plans') return say(words(`Done: you are on the ${out.answer} plan.`))
|
|
42
|
+
return say(words('No card then. Tell me how many people will use it.'))
|
|
43
|
+
}
|
|
44
|
+
return say(['Let me ask. ', ask('How many people will use it?')])
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The catalog: parts with `result: true` answer a card. `choice` has free-text params (the model writes the question and the options); `plans` is the app's own list.
|
|
50
|
+
|
|
51
|
+
```jsx live-file=./cards.jsx title="cards.jsx"
|
|
52
|
+
import { defineCatalog, compose } from 'sygnal/ai'
|
|
53
|
+
|
|
54
|
+
export const PLANS = [
|
|
55
|
+
{ id: 'free', name: 'Free', price: '$0' },
|
|
56
|
+
{ id: 'team', name: 'Team', price: '$12' },
|
|
57
|
+
{ id: 'business', name: 'Business', price: '$30' },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
export function Choice({ question, options = [], answer, frozen }) {
|
|
61
|
+
return (
|
|
62
|
+
<div className="choice">
|
|
63
|
+
<p>{question}</p>
|
|
64
|
+
{options.map((o) => <button type="button" className={o === answer ? 'option picked' : 'option'} data={{ value: o }} disabled={frozen}>{o}</button>)}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
Choice.intent = ({ DOM }) => ({ PICK: DOM.click('.option').data('value') })
|
|
69
|
+
Choice.model = { PICK: { PARENT: (state, value, next, props) => compose.answer(props, value) } }
|
|
70
|
+
|
|
71
|
+
// no `state` in the catalog entry: the part sees the host's state, as any child does
|
|
72
|
+
export function PlanPicker({ state, answer, frozen }) {
|
|
73
|
+
return (
|
|
74
|
+
<ul className="plans">
|
|
75
|
+
{state.plans.map((p) => (
|
|
76
|
+
<li><button type="button" className={p.id === answer ? 'plan picked' : 'plan'} data={{ id: p.id }} disabled={frozen}>{p.name} ({p.price})</button></li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
PlanPicker.intent = ({ DOM }) => ({ PICK: DOM.click('.plan').data('id') })
|
|
82
|
+
PlanPicker.model = { PICK: { PARENT: (state, id, next, props) => compose.answer(props, id) } }
|
|
83
|
+
|
|
84
|
+
export const catalog = defineCatalog({
|
|
85
|
+
parts: {
|
|
86
|
+
choice: {
|
|
87
|
+
component: Choice, result: true,
|
|
88
|
+
description: 'A question with 2 to 5 short options; the user picks one',
|
|
89
|
+
params: {
|
|
90
|
+
question: { type: 'text', maxLength: 120, required: true },
|
|
91
|
+
options: { type: 'list', items: { maxLength: 40 }, min: 2, max: 5, required: true },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
plans: { component: PlanPicker, result: true, description: 'The pricing plans to pick from (Free, Team, Business)' },
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```css live
|
|
100
|
+
.plan-chat { display: grid; gap: 0.5rem; }
|
|
101
|
+
.plan-chat ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
|
102
|
+
.plan-chat .ask-card { border: 1px solid #8884; border-radius: 6px; padding: 0.4rem 0.6rem; }
|
|
103
|
+
.plan-chat .ask-card p { margin: 0 0 0.3rem; }
|
|
104
|
+
.plan-chat .ask-card ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 0.4rem; }
|
|
105
|
+
.plan-chat .ask-card fieldset { border: 0; margin: 0; padding: 0; }
|
|
106
|
+
.plan-chat .option, .plan-chat .plan { margin-right: 0.3rem; }
|
|
107
|
+
.plan-chat .picked { font-weight: bold; }
|
|
108
|
+
.plan-chat .card-status { font-size: 0.8rem; opacity: 0.7; }
|
|
109
|
+
.plan-chat .ask { display: flex; gap: 0.5rem; }
|
|
110
|
+
.plan-chat .ask input { flex: 1; }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The demo asks on load. Answer the card: the answer goes back to the model, which asks again with a second card, built from the app's plans.
|
|
114
|
+
|
|
115
|
+
```jsx live live-height=380 title="PlanChat.jsx"
|
|
116
|
+
import { chat, askCard } from 'sygnal/ai'
|
|
117
|
+
import { catalog, PLANS } from './cards.jsx'
|
|
118
|
+
|
|
119
|
+
// a message part: text, or an ask_user call with its card
|
|
120
|
+
function part(p, state) {
|
|
121
|
+
if (p.type === 'text') return <p>{p.text}</p>
|
|
122
|
+
if (p.type !== 'tool-ask_user') return null
|
|
123
|
+
return (
|
|
124
|
+
<section className="ask-card">
|
|
125
|
+
{askCard(catalog, state, p) ?? <p>Composing a card…</p>}
|
|
126
|
+
<span className="card-status">{p.ui?.status}</span>
|
|
127
|
+
</section>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function PlanChat({ state }) {
|
|
132
|
+
const a = state.assistant
|
|
133
|
+
const busy = a.status === 'submitted' || a.status === 'streaming'
|
|
134
|
+
return (
|
|
135
|
+
<div className="plan-chat">
|
|
136
|
+
<ol aria-live="polite">
|
|
137
|
+
{a.messages.map((m) => <li className={m.role}><b>{m.role}:</b> {m.parts.map((p) => part(p, state))}</li>)}
|
|
138
|
+
{a.draft && <li className="assistant"><b>assistant:</b> {a.draft}</li>}
|
|
139
|
+
</ol>
|
|
140
|
+
<form className="ask">
|
|
141
|
+
<input className="prompt" aria-label="Message" value={a.prompt} />
|
|
142
|
+
<button type="submit" disabled={busy}>Send</button>
|
|
143
|
+
<button type="button" className="stop" hidden={!busy}>Stop</button>
|
|
144
|
+
</form>
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
PlanChat.initialState = { plans: PLANS }
|
|
150
|
+
|
|
151
|
+
PlanChat.uses = {
|
|
152
|
+
assistant: chat({
|
|
153
|
+
form: '.ask', prompt: '.prompt', stop: '.stop',
|
|
154
|
+
instructions: 'Help the user choose a plan. Ask with ask_user when they need to choose something.',
|
|
155
|
+
ask: { catalog },
|
|
156
|
+
}),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
PlanChat.model = {
|
|
160
|
+
// the demo asks on load only to have something to show
|
|
161
|
+
BOOTSTRAP: { EFFECT: (state, data, next) => next('assistant.SEND', 'Which plan should I take?') },
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default PlanChat
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## How a card works
|
|
168
|
+
|
|
169
|
+
`chat({ ask: { catalog, backend?, instructions?, direct? } })` adds the tool `ask_user({ need })` to the chat's requests. Its description tells the model to say in one sentence what it needs, and to prefer a card to a question in text when the answer is one of a few options or structured. `instructions` adds your own guidance on when and how to ask.
|
|
170
|
+
|
|
171
|
+
1. **The model calls `ask_user`** with its `need`. The call stays open (`input-available`), and its card starts composing.
|
|
172
|
+
2. **The backend composes the card** from the `need` and the app state, never the conversation: `compose.llm()` by default, on the chat's own sink, or `compose.decisions()` ([below](#two-backends)). Cards compose one at a time, each under its own request key (`assistant.ask:<call id>`), so a card's request never aborts the chat's.
|
|
173
|
+
3. **The host renders it** with `askCard(catalog, state, part)` wherever it renders the message's `tool-ask_user` part. It is null while there is no card yet.
|
|
174
|
+
4. **The user answers** in a result part, which sends `compose.answer(props, value)` to `PARENT`. The call closes with the output `{ part, answer }` (the part's id, the value), and once no call is open the chat sends the next request, so the model continues from the answer.
|
|
175
|
+
|
|
176
|
+
The card lives on the tool part, as `part.ui`:
|
|
177
|
+
|
|
178
|
+
| Field | |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `status` | `composing` (queued, or its request is out), `open` (answerable), `answered`, `failed`, `cancelled` |
|
|
181
|
+
| `layout` | The card: a layout, as on a composed page |
|
|
182
|
+
| `answer` | `{ node, part, value }` once answered, else `null` |
|
|
183
|
+
| `local`, `ui` | The card's `local` parts' state and container state, per node key |
|
|
184
|
+
|
|
185
|
+
- **Statuses of the chat.** While a card composes, the chat's `status` is `'streaming'`. Once every open card is composed and only the user's answers are outstanding, it is `'ready'`, and the turn is not over (no `DONE`). Several `ask_user` calls in one reply give several cards; the next request goes when all are answered.
|
|
186
|
+
- **A failed card** (its request fails, or the reply is not a valid card) closes the call with the error "could not show a card: …; ask in text instead", and the turn goes on: the model can ask in text.
|
|
187
|
+
- **STOP** aborts a card that is composing and cancels the open ones: their calls close as "not answered: stopped by the user", and nothing is sent. **SEND** of a new message cancels them too ("not answered: the user sent a new message"), so the model sees its question went unanswered. A cancelled or answered card renders frozen.
|
|
188
|
+
|
|
189
|
+
### Result parts
|
|
190
|
+
|
|
191
|
+
A part with `result: true` answers the card. It sends its answer to `PARENT` with `compose.answer(props, value)`: `props` carries the card's `scope` and the part's `node`, so the answer reaches the right call. Any value works: a string, an id, a form's values.
|
|
192
|
+
|
|
193
|
+
- `askCard` renders each card in its own scope, so two cards with the same part are two instances, with their own `local` state (a small form keeps what the user typed, per card).
|
|
194
|
+
- The answered part gets the prop `answer` (its value, to mark the pick), and every part of a card that isn't open gets `frozen`. The card is wrapped in a disabled `<fieldset>` too.
|
|
195
|
+
- The host hears result parts as well: `CHILD.select(PlanPicker)` in the host's intent gets `{ scope, node, value }`, to keep the pick in app state.
|
|
196
|
+
- A catalog for `ask` needs at least one result part ([SYG686](https://sygnal.js.org/reference/errors/#syg686)), and a result part rendered outside a card can't answer anything ([SYG160](https://sygnal.js.org/reference/errors/#syg160)).
|
|
197
|
+
|
|
198
|
+
## Two backends
|
|
199
|
+
|
|
200
|
+
| | `compose.llm()` (the default) | `compose.decisions({ model })` |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| Who composes the card | The chat model, in a second, structured-output request | A decision model, from the parts' descriptions |
|
|
203
|
+
| Sink | The chat's (`LLM`) | `HTTP` (`/api/decide` by default) |
|
|
204
|
+
| Parts it can use | All, free-text params too (a question and its options written for this need) | Prepared parts only: a part with required free text is LLM-only |
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
chat({ ask: { catalog, backend: compose.decisions({ model: 'jev-latest' }) } })
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Why two steps.** The chat model only says what it needs (`need`), and a separate request builds the card. Writing the card as the tool's arguments in one step needs a provider that enforces tool schemas: on Ollama, none of the measured calls with the layout as tool arguments was a valid card as emitted, against 5 of 5 with the two-step form.
|
|
211
|
+
|
|
212
|
+
**`direct: true`** is that one-step form, for providers that do enforce tool schemas: the `ask_user` input schema is the catalog's layout schema, and the card opens as soon as the call arrives, with no compose round trip. An input that is not a card closes the call with the error, so the model can try again. It is LLM-only.
|
|
213
|
+
|
|
214
|
+
**What the backend explains** stays with the card as `part.ui.decisions`, as a [composed page](./ai-compose.md) keeps it in its slice: `compose.llm()`'s plan (`asked_for`) and repairs, or `compose.decisions()`'s answers per step by question name (`use_<part>`, `rec_<part>__<n>`, `first_<a>_vs_<b>`, ...). It is persisted with the card, for an explain panel.
|
|
215
|
+
|
|
216
|
+
**Switching models.** `model` may be a function of the host's state, read per request: `chat({ model: (state) => state.chatModel, ask: { catalog } })` follows the app's model picker with one chat behavior, and the default card backend follows it too (`compose.llm({ model: (state) => state.chatModel })` for an explicit one). The card backend itself is fixed when `chat()` is called, since it decides the sink the cards go to: an app that lets the user pick it makes one host component per backend.
|
|
217
|
+
|
|
218
|
+
**Call `chat()` before `run()`.** The first `chat()` call links the sygnal/ai behaviors to the apps started after it (and to the page's first running app). A host component defined in a module the entry imports is in time; one created only later, in a second app, runs without tools ([SYG442](https://sygnal.js.org/reference/errors/#syg442)). An app that makes host components on demand calls `chat()` once at load.
|
|
219
|
+
|
|
220
|
+
## Client tools
|
|
221
|
+
|
|
222
|
+
`ask_user` is one case of a **client tool**: a tool the app answers itself, often with the user's help, instead of an `agent` action that runs on its own. `clientTools` declares your own:
|
|
223
|
+
|
|
224
|
+
```jsx
|
|
225
|
+
import { z } from 'zod'
|
|
226
|
+
import { chat, openToolCalls } from 'sygnal/ai'
|
|
227
|
+
|
|
228
|
+
function confirmations(a) {
|
|
229
|
+
return openToolCalls(a).filter((p) => p.type === 'tool-confirm').map((p) => (
|
|
230
|
+
<p className="confirm">
|
|
231
|
+
{p.input.question}
|
|
232
|
+
<button type="button" className="yes" data={{ id: p.toolCallId }}>Yes</button>
|
|
233
|
+
<button type="button" className="no" data={{ id: p.toolCallId }}>No</button>
|
|
234
|
+
</p>
|
|
235
|
+
))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function Booking({ state }) {
|
|
239
|
+
const a = state.assistant
|
|
240
|
+
return (
|
|
241
|
+
<main>
|
|
242
|
+
{confirmations(a)}
|
|
243
|
+
<form className="ask"><input className="prompt" aria-label="Message" value={a.prompt} /></form>
|
|
244
|
+
</main>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
Booking.uses = {
|
|
249
|
+
assistant: chat({
|
|
250
|
+
form: '.ask', prompt: '.prompt',
|
|
251
|
+
clientTools: {
|
|
252
|
+
confirm: { description: 'Ask the user a yes / no question', inputSchema: z.object({ question: z.string().min(1) }) },
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
Booking.intent = ({ DOM }) => ({ YES: DOM.click('.yes').data('id'), NO: DOM.click('.no').data('id') })
|
|
258
|
+
|
|
259
|
+
Booking.model = {
|
|
260
|
+
YES: { EFFECT: (state, id, next) => next('assistant.TOOL_OUTPUT', { toolCallId: id, output: { confirmed: true } }) },
|
|
261
|
+
NO: { EFFECT: (state, id, next) => next('assistant.TOOL_OUTPUT', { toolCallId: id, output: { confirmed: false } }) },
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- A client tool is `{ description, inputSchema }`: a JSON Schema, or a Standard Schema (zod, valibot) with a JSON Schema form. A Standard Schema input is validated first; an invalid one closes the call with the issues, so the model can repair it.
|
|
266
|
+
- Its calls stay open until `'assistant.TOOL_OUTPUT'` answers them: `{ toolCallId, output }`, or `{ toolCallId, errorText }` for an error. A TOOL_OUTPUT for a call that isn't open does nothing ([SYG685](https://sygnal.js.org/reference/errors/#syg685)).
|
|
267
|
+
- `openToolCalls(state.assistant)` lists the calls of the last message that wait for an answer (each a `tool-<name>` part with its `input`).
|
|
268
|
+
- A host entry `'assistant.TOOL_CALL'` runs once when each call opens, with `{ id, name, input }` (the input after validation).
|
|
269
|
+
- STOP and SEND close open calls as they close cards. A client tool with the name of an `agent` tool replaces it ([SYG684](https://sygnal.js.org/reference/errors/#syg684)).
|
|
270
|
+
|
|
271
|
+
These are the AI SDK's `addToolOutput` and `onToolCall`, on the `chat` behavior.
|
|
272
|
+
|
|
273
|
+
## Persistence
|
|
274
|
+
|
|
275
|
+
Cards live on the messages (`part.ui`), so [`persist()`](./persistence.md) of the chat slice keeps them: an answered card comes back frozen with its pick, an open card stays answerable, and a card that was composing composes again. A chat saved while a reply streamed comes back `'ready'`, so Send works after a reload.
|
|
276
|
+
|
|
277
|
+
```js
|
|
278
|
+
import { persist } from 'sygnal'
|
|
279
|
+
|
|
280
|
+
PlanChat.persist = persist({ key: 'plan-chat', pick: ['assistant'] })
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
On the wire, the `openResponses()` and `chatCompletions()` transports send only a call's input and output, never `ui`. `uiMessageStream()` sends the parts as they are: a server route that stores conversations must store `body.messages` as received to keep the cards ([Storing conversations](./ai-chat.md#storing-conversations)).
|
|
284
|
+
|
|
285
|
+
## Testing
|
|
286
|
+
|
|
287
|
+
`composeWith(t, catalog, tree)` answers a card's pending composition too, whichever backend it uses. Here `makePlanChat()` returns the demo's component without its `BOOTSTRAP` entry:
|
|
288
|
+
|
|
289
|
+
```js
|
|
290
|
+
import { it, expect } from 'vitest'
|
|
291
|
+
import { renderComponent } from 'sygnal'
|
|
292
|
+
import { composeWith } from 'sygnal/ai'
|
|
293
|
+
import { catalog } from './cards.jsx'
|
|
294
|
+
import { makePlanChat } from './PlanChat.jsx'
|
|
295
|
+
|
|
296
|
+
it('asks with a card, and the pick goes back to the model', async () => {
|
|
297
|
+
const t = renderComponent(makePlanChat())
|
|
298
|
+
t.simulateAction('assistant.SEND', 'Which plan should I take?')
|
|
299
|
+
await t.stream('LLM', [{ toolCall: { id: 'c1', name: 'ask_user', input: { need: 'Pick the plan you want' } } }])
|
|
300
|
+
await composeWith(t, catalog, [{ part: 'plans' }])
|
|
301
|
+
expect(t.state.assistant.status).toBe('ready')
|
|
302
|
+
|
|
303
|
+
t.simulateEvent('.plan[data-id="team"]', 'click')
|
|
304
|
+
await t.waitForState((s) => s.assistant.status === 'submitted')
|
|
305
|
+
const call = t.requests('LLM').at(-1).messages.at(-1).parts.find((p) => p.type === 'tool-ask_user')
|
|
306
|
+
expect(call.output).toEqual({ part: 'plans', answer: 'team' })
|
|
307
|
+
t.dispose()
|
|
308
|
+
})
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
- `t.stream('LLM', chunks)` answers the chat's request; a `{ toolCall }` chunk is a call.
|
|
312
|
+
- To test a failed card, fail its request: `t.fail('LLM', new Error('overloaded'), 'assistant.CARD_FAILED')`.
|
|
313
|
+
- A client tool: `t.simulateAction('assistant.TOOL_OUTPUT', { toolCallId: 'c1', output: { confirmed: true } })`.
|
|
314
|
+
|
|
315
|
+
## Related
|
|
316
|
+
|
|
317
|
+
- [Composing UI with a Model](./ai-compose.md): catalogs, parts and the two backends
|
|
318
|
+
- [AI Chat](./ai-chat.md): the chat driver, transports and tools
|
|
319
|
+
- [Agents](./agent.md): the `chat` behavior and its call rules
|