sygnal 6.2.0 → 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.
Files changed (76) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -1
  3. package/dist/ai.cjs.js +36 -0
  4. package/dist/ai.cjs.js.map +1 -1
  5. package/dist/ai.esm.js +1 -1
  6. package/dist/astro/server.cjs.js +3 -3
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -3
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/diagnostics.cjs.js +164 -3
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +164 -3
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/agent.md +7 -1
  15. package/dist/guide/ai-cards.md +319 -0
  16. package/dist/guide/ai-chat.md +4 -0
  17. package/dist/guide/ai-compose.md +881 -0
  18. package/dist/guide/ai-decisions.md +16 -0
  19. package/dist/guide/http.md +1 -0
  20. package/dist/index.cjs.js +5695 -347
  21. package/dist/index.cjs.js.map +1 -1
  22. package/dist/index.d.ts +638 -8
  23. package/dist/index.esm.js +5685 -347
  24. package/dist/index.esm.js.map +1 -1
  25. package/dist/sygnal.min.js +1 -1
  26. package/dist/sygnal.min.js.map +1 -1
  27. package/dist/vike/config/package.json +1 -1
  28. package/dist/vike/onRenderClient.cjs.js +3 -2
  29. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  30. package/dist/vike/onRenderClient.mjs +3 -2
  31. package/dist/vike/onRenderClient.mjs.map +1 -1
  32. package/llms.txt +6 -4
  33. package/package.json +2 -1
  34. package/src/ai.d.ts +642 -7
  35. package/src/ai.ts +6 -0
  36. package/src/collection.ts +2 -1
  37. package/src/core/instance.ts +6 -3
  38. package/src/core/markers/lazy.ts +7 -3
  39. package/src/core/markers/portal.ts +5 -2
  40. package/src/core/markers/suspense.ts +3 -2
  41. package/src/core/markers/transition.ts +6 -3
  42. package/src/core/registry.ts +9 -5
  43. package/src/cycle/dom/IsolateModule.ts +25 -22
  44. package/src/extra/ai/chat/behavior.ts +354 -81
  45. package/src/extra/ai/chat/driver.ts +7 -4
  46. package/src/extra/ai/compose/agent.ts +55 -0
  47. package/src/extra/ai/compose/ask.ts +147 -0
  48. package/src/extra/ai/compose/behavior.ts +183 -0
  49. package/src/extra/ai/compose/catalog.ts +211 -0
  50. package/src/extra/ai/compose/composed.ts +61 -0
  51. package/src/extra/ai/compose/decisions.ts +1649 -0
  52. package/src/extra/ai/compose/diag.ts +33 -0
  53. package/src/extra/ai/compose/index.ts +23 -0
  54. package/src/extra/ai/compose/layout.ts +441 -0
  55. package/src/extra/ai/compose/llm.ts +1120 -0
  56. package/src/extra/ai/compose/render.ts +159 -0
  57. package/src/extra/ai/compose/testing.ts +71 -0
  58. package/src/extra/ai/compose/types.ts +159 -0
  59. package/src/extra/ai/decide.ts +97 -1
  60. package/src/extra/ai/index.ts +6 -1
  61. package/src/extra/diagnostics/checks/next.ts +86 -2
  62. package/src/extra/diagnostics/codes.ts +54 -0
  63. package/src/extra/fetchDriver.ts +58 -27
  64. package/src/extra/lens.ts +15 -0
  65. package/src/extra/ssr.ts +3 -3
  66. package/src/extra/testing.ts +2 -0
  67. package/src/extra/widget.ts +3 -3
  68. package/src/index.d.ts +41 -0
  69. package/src/index.ts +8 -0
  70. package/src/lazy.ts +3 -2
  71. package/src/portal.ts +5 -2
  72. package/src/pragma/index.ts +2 -1
  73. package/src/suspense.ts +5 -2
  74. package/src/switchable.ts +2 -1
  75. package/src/transition.ts +5 -2
  76. package/src/vike/onRenderClient.ts +3 -2
@@ -57,10 +57,47 @@
57
57
  * the host's dispose stops it. A host in an app made before the first chat() call is reported as
58
58
  * SYG442 and runs without tools. The engine dispatches RESULTS / ASK (cause 'reply') and DONE
59
59
  * ('next') to the host through the runtime API.
60
+ *
61
+ * PLAN-9 H-1, client tools (design: dev-plans/research/ai-compose/chat-client-tools.md, D454,
62
+ * D469): `clientTools: { name: { description, inputSchema } }` go in every request with the agent
63
+ * tools (a name in both: the client tool wins, SYG684; they need no link, so they work under
64
+ * SYG442 too). The app answers their calls:
65
+ * - a reply's client calls stay `input-available` (a Standard Schema input that fails validation
66
+ * is closed at once as output-error with the issues, so the model can repair it), and each fires
67
+ * the host entry 'assistant.TOOL_CALL' (ChatCall) once, when it opens;
68
+ * - 'assistant.TOOL_OUTPUT' `{ toolCallId, output }` / `{ toolCallId, errorText }` (the AI SDK's
69
+ * addToolOutput) answers one: only an input-available client part of the last message (checked
70
+ * on state, so it works after a reload); anything else does nothing (SYG685, dev);
71
+ * - the next request goes once the last message has no input-available and no approval-requested
72
+ * part: RESULTS, APPROVE / DENY, TOOL_OUTPUT, ANSWER and the cards share that one test, so
73
+ * whichever finishes last sends (`continue: true`; it counts as a step);
74
+ * - status: 'ready' while only the user's answers are outstanding (DONE waits for the end of the
75
+ * turn), 'streaming' while agent calls run or a card composes;
76
+ * - STOP (also when only client calls are open) closes them as "not answered: stopped by the
77
+ * user", SEND as "not answered: the user sent a new message"; REGENERATE drops them with their
78
+ * message; past maxSteps they are closed as not run.
79
+ * `openToolCalls(state)`: the input-available tool parts of the last assistant message.
80
+ *
81
+ * PLAN-9 H-2 / H-3: `ask: { catalog, backend?, instructions?, direct? }` adds the client tool
82
+ * `ask_user` (../compose/ask.ts, reached through `catalog._ask`): a card per call on `part.ui`,
83
+ * composed one at a time on the backend's sink (default compose.llm() on this sink; CARD /
84
+ * CARD_FAILED are its reply actions, the request key `<use>.ask:<toolCallId>`), answered by the
85
+ * catalog's result parts (ANSWER `{ scope, node, value }`: CHILD.select of their components, since
86
+ * cards render in the host's view) with the output `{ part, answer }`; CONTROL for the cards'
87
+ * container controls. STOP aborts a card being composed (the driver's `abort: [keys]`).
88
+ *
89
+ * Reload (design rule 12): `persist()` restores the whole slice, so a slice saved mid-request came
90
+ * back busy and SEND stayed locked. RESUME, once at start when the restored slice is busy (or has a
91
+ * card composing): status 'ready' (the partial reply kept, as on STOP), agent calls still open
92
+ * closed as not run, an app confirm dropped (a server approval stays answerable), and a card that
93
+ * was composing composed again; open client calls stay answerable.
60
94
  */
61
95
  import {defineBehavior} from '../../behaviors'
62
96
  import {ABORT} from '../../../shared'
97
+ import {report} from '../../diagnostics/index'
98
+ import xs from '../../xstreamCompat'
63
99
  import {agentTools, hasUserText, labelsUntrusted, unlabel} from '../agent/index'
100
+ import {toJsonSchema, repair, isObj} from '../schema/index'
64
101
  import {linked, engineOf, checkLinked} from '../link'
65
102
  import {messageId} from '../messages'
66
103
 
@@ -84,6 +121,9 @@ const toolsOf = (e: Engine) => e.o.agent === false ? null : e.tools ||= agentToo
84
121
  }),
85
122
  })
86
123
 
124
+ // dev diagnostics (SYG684, SYG685): no-ops with diagnostics off
125
+ const dev = (code: string, message: string, fix: string) => { try { report(code, {component: 'chat', message, fix, severity: 'warn'}) } catch (e) { queueMicrotask(() => { throw e }) } }
126
+
87
127
  // ------------------------------------------------------------------ messages
88
128
  const isTool = (p: any) => typeof p?.type == 'string' && p.type.startsWith('tool-')
89
129
  const openCalls = (m: any): any[] => (m?.parts || []).filter((p: any) => isTool(p) && p.state == 'input-available')
@@ -102,16 +142,23 @@ const withResult = (p: any, r: any) => {
102
142
  const {output: _o, errorText: _e, ...rest} = p
103
143
  return 'error' in r ? {...rest, state: 'output-error', errorText: r.error} : {...rest, state: 'output-available', output: r.output}
104
144
  }
145
+ /** H-1: the tool parts the app answers (clientTools, ask_user) */
146
+ type Cfg = {client: Record<string, any>; ask?: any; kit?: any}
147
+ const isClient = (c: Cfg, p: any) => Object.prototype.hasOwnProperty.call(c.client, p.type.slice(5))
148
+ /** an ask_user card that is still composing / open → cancelled */
149
+ const cancel = (p: any) => p.ui && (p.ui.status == 'composing' || p.ui.status == 'open') ? {...p.ui, status: 'cancelled', pending: null} : p.ui
105
150
  /**
106
151
  * The last message with its open tool parts closed (a turn stopped, failed or capped): a call
107
- * that already ran gets its result (`ran`, G-626), the others `why`; a server approval still
108
- * waiting is denied
152
+ * that already ran gets its result (`ran`, G-626), the others `why` (client calls `cwhy`, their
153
+ * cards cancelled); a server approval still waiting is denied
109
154
  */
110
- const closeOpen = (msgs: any[], why: string, ran?: Map<string, any>) => {
155
+ const closeOpen = (msgs: any[], why: string, ran?: Map<string, any>, c?: Cfg, cwhy = why) => {
111
156
  const m = msgs[msgs.length - 1]
112
157
  if (!openCalls(m).length && !firstAsk(m)) return msgs
113
158
  return [...msgs.slice(0, -1), {...m, parts: m.parts.map((p: any) => !isTool(p) ? p
114
- : p.state == 'input-available' ? (ran?.has(p.toolCallId) ? withResult(p, ran.get(p.toolCallId)) : {...p, state: 'output-error', errorText: why})
159
+ : p.state == 'input-available' ? (ran?.has(p.toolCallId) ? withResult(p, ran.get(p.toolCallId))
160
+ : c && isClient(c, p) ? {...p, state: 'output-error', errorText: cwhy, ...(p.ui && {ui: cancel(p)})}
161
+ : {...p, state: 'output-error', errorText: why})
115
162
  : p.state == 'approval-requested' ? {...p, state: 'output-denied', approval: {...p.approval, approved: false, reason: why}}
116
163
  : p)}]
117
164
  }
@@ -120,61 +167,213 @@ const user = (text: string) => ({id: messageId(), role: 'user', parts: [{type: '
120
167
  const busy = (s: any) => s.status == 'submitted' || s.status == 'streaming'
121
168
  const lastUser = (msgs: any[]) => { for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role == 'user') return i; return -1 }
122
169
  const lastOf = (msgs: any[]) => msgs[msgs.length - 1]
170
+ /** the last message's parts through `f` (an assistant message; otherwise unchanged) */
171
+ const mapLast = (msgs: any[], f: (p: any) => any) => {
172
+ const m = lastOf(msgs)
173
+ return m?.role != 'assistant' ? msgs : [...msgs.slice(0, -1), {...m, parts: m.parts.map(f)}]
174
+ }
175
+ /** the input-available tool parts of the last assistant message (H-1: the calls the app answers, and agent calls while they run) */
176
+ export const openToolCalls = (s: any): any[] => { const m = s && Array.isArray(s.messages) ? lastOf(s.messages) : null; return m?.role == 'assistant' ? openCalls(m) : [] }
177
+ const composing = (p: any) => p.state == 'input-available' && p.ui?.status == 'composing'
178
+
179
+ /**
180
+ * H-1 rule 3 and 4: what the slice does after a call closed or a card changed: the next request
181
+ * (`send`) once nothing is open, else the status: 'streaming' while agent calls run or a card
182
+ * composes (or a server approval waits), 'ready' while only the user's answers are outstanding
183
+ */
184
+ const settle = (s: any, messages: any[], c: Cfg) => {
185
+ const last = lastOf(messages), ask = firstAsk(last), open = openCalls(last)
186
+ if (!ask && !open.length) return {s: {...s, messages, status: 'submitted', pending: null}, send: true}
187
+ const agent = open.some(p => !isClient(c, p))
188
+ const working = ask || agent || open.some(composing)
189
+ return {s: {...s, messages, status: working ? 'streaming' : 'ready', pending: ask ? askInfo(ask) : agent && s.pending?.approvalId === undefined ? s.pending : null}}
190
+ }
123
191
 
124
- /** one step's outcome per (slice, data): STATE and the LLM sink of an action read the same one */
125
- const memo = (f: (s: any, d: any, e?: Engine) => any) => {
192
+ /** H-2: start the first queued card (cards compose one at a time); a card that can't start fails and the next one goes */
193
+ const nextCard = (c: Cfg, msgs: any[], st: any, k: string): {msgs: any[]; request?: any} => {
194
+ if (!c.ask || !c.ask.backend) return {msgs}
195
+ for (;;) {
196
+ const m = lastOf(msgs), parts = m?.role == 'assistant' ? m.parts : []
197
+ if (parts.some((p: any) => composing(p) && p.ui.pending)) return {msgs}
198
+ const p = parts.find(composing)
199
+ if (!p) return {msgs}
200
+ const r = c.kit.startCard(c.ask, p, st, k), np = r.part ?? r
201
+ msgs = mapLast(msgs, (x: any) => x === p ? np : x)
202
+ if (r.request !== undefined) return {msgs, request: r.request}
203
+ }
204
+ }
205
+
206
+ /**
207
+ * one step's outcome per (slice, data, engine, host): STATE, the sinks and EFFECT of an action
208
+ * read the same one (`x`: the host's state and the use's key, compared by value)
209
+ */
210
+ const memo = (f: (s: any, d: any, e?: any, x?: any) => any) => {
126
211
  let l: any[] = []
127
- return (s: any, d: any, e?: Engine) => l[0] === s && l[1] === d && l[2] === e ? l[3] : (l = [s, d, e, f(s, d, e)])[3]
212
+ return (s: any, d: any, e?: any, x?: any) => l[0] === s && l[1] === d && l[2] === e && l[3] === x?.state && l[4] === x?.k ? l[5] : (l = [s, d, e, x?.state, x?.k, f(s, d, e, x)])[5]
213
+ }
214
+
215
+ /**
216
+ * H-1: a client call's Standard Schema input, checked synchronously (the lenient `{ value }`
217
+ * unwrap and repair, as A-1's parseInput): `{ input }` or `{ issues }`; an async schema or a plain
218
+ * JSON Schema is not checked here
219
+ */
220
+ function checkInput(tool: any, input: any): {input?: any; issues?: string} {
221
+ const sc = tool?.inputSchema
222
+ if (!sc || typeof sc['~standard']?.validate != 'function') return {input}
223
+ const c = toJsonSchema(sc)
224
+ let v = c.wrapped && isObj(input) && 'value' in input ? input.value : input
225
+ if (c.schema) v = repair(v, c.wrapped ? c.schema.properties.value : c.schema, c.schema)
226
+ const r = sc['~standard'].validate(v)
227
+ if (!r || typeof r.then == 'function') return {input}
228
+ return r.issues ? {issues: r.issues.map((i: any) => (i.path?.length ? i.path.map((x: any) => isObj(x) ? x.key : x).join('.') + ': ' : '') + i.message).join('; ')} : {input: r.value}
229
+ }
230
+ /** the JSON Schema a request sends for a client tool */
231
+ const schemaOf = (tool: any) => {
232
+ const sc = tool?.inputSchema
233
+ if (sc && typeof sc['~standard']?.validate == 'function') return toJsonSchema(sc).schema ?? {type: 'object'}
234
+ return sc ?? {type: 'object'}
128
235
  }
129
236
 
130
237
  const textOf = (d: any) => typeof d == 'string' ? d : d && typeof d == 'object' && typeof d.text == 'string' && !('target' in d) ? d.text : undefined
131
- const sendStep = /*#__PURE__*/ memo((s, d) => {
132
- const given = textOf(d), text = given ?? s.prompt
133
- if (busy(s) || typeof text != 'string' || !text.trim()) return
134
- return {...s, messages: [...closeOpen(s.messages, 'not run: a new message was sent'), user(text)], prompt: given === undefined ? '' : s.prompt, draft: '', draftReasoning: '', status: 'submitted', error: null, pending: null}
135
- })
136
- const regenStep = /*#__PURE__*/ memo(s => {
137
- const i = lastUser(s.messages)
138
- if (busy(s) || i < 0) return
139
- return {...s, messages: s.messages.slice(0, i + 1), draft: '', draftReasoning: '', status: 'submitted', error: null, pending: null}
140
- })
141
- // a reply: run its open tool calls (status stays streaming), wait for a server approval
142
- // (`pending`), or the turn is over (DONE). Past maxSteps the open calls are closed as not run. A
143
- // reply to a request that continued the last (assistant) message replaces it (G-628: the driver
144
- // grew that message, as the AI SDK does)
145
- const replyStep = /*#__PURE__*/ memo((s, d, e) => {
146
- const m = d?.message
147
- if (!m) return
148
- const max = e?.o.maxSteps ?? 8, calls = openCalls(m), run = calls.length > 0 && !!e && e.steps < max
149
- const msg = calls.length && !run ? closeOpen([m], `not run: the step limit (maxSteps ${max}) was reached`)[0] : m
150
- const ask = !run && firstAsk(msg)
151
- const prev = lastOf(s.messages), messages = prev?.role == 'assistant' ? [...s.messages.slice(0, -1), msg] : [...s.messages, msg]
152
- return {s: {...s, messages, draft: '', draftReasoning: '', status: run || ask ? 'streaming' : 'ready', pending: ask ? askInfo(ask) : null}, calls: run ? calls : null, ask: !!ask, msg}
153
- })
154
- // a reply's tool results: the next request (or the next server approval first). `late`: a call
155
- // that was running when STOP came changed the app: its "not run" part gets the result (G-626)
156
- const resultsStep = /*#__PURE__*/ memo((s, d, e) => {
157
- if (!e || !Array.isArray(d?.results) || (d.turn !== e.turn && !d.late)) return
158
- const out = new Map(d.results.map((r: any) => [r.id, r]))
159
- const fix = (p: any) => {
160
- const r: any = isTool(p) && out.get(p.toolCallId)
161
- return !r ? p : d.late ? (p.state == 'output-error' && /^not run/.test(p.errorText) ? withResult(p, r) : p) : p.state == 'input-available' ? withResult(p, r) : p
238
+ /** steps that read the use's client config; made per chat() call */
239
+ function steps(c: Cfg, o: any) {
240
+ const sendStep = memo((s, d) => {
241
+ const given = textOf(d), text = given ?? s.prompt
242
+ if (busy(s) || typeof text != 'string' || !text.trim()) return
243
+ return {...s, messages: [...closeOpen(s.messages, 'not run: a new message was sent', undefined, c, 'not answered: the user sent a new message'), user(text)], prompt: given === undefined ? '' : s.prompt, draft: '', draftReasoning: '', status: 'submitted', error: null, pending: null}
244
+ })
245
+ const regenStep = memo(s => {
246
+ const i = lastUser(s.messages)
247
+ if (busy(s) || i < 0) return
248
+ return {...s, messages: s.messages.slice(0, i + 1), draft: '', draftReasoning: '', status: 'submitted', error: null, pending: null}
249
+ })
250
+ // a reply: run its agent calls (status stays streaming), open its client calls (TOOL_CALL; cards
251
+ // start composing), wait for a server approval (`pending`), or the turn is over (DONE). Past
252
+ // maxSteps the open calls are closed as not run. A reply to a request that continued the last
253
+ // (assistant) message replaces it (G-628: the driver grew that message, as the AI SDK does)
254
+ const replyStep = memo((s, d, e, p) => {
255
+ const m = d?.message
256
+ if (!m) return
257
+ const max = e?.o.maxSteps ?? 8, calls = openCalls(m), over = !!e && e.steps >= max
258
+ const agent = calls.filter(x => !isClient(c, x)), run = agent.length > 0 && !!e && !over
259
+ // past maxSteps every call is closed; with no engine (SYG442) the agent calls are
260
+ let msg = calls.length && (over || (!e && agent.length)) ? closeOpen([m], `not run: the step limit (maxSteps ${max}) was reached`, undefined, over ? undefined : c)[0] : m
261
+ const opened: any[] = []
262
+ let closed = false
263
+ if (!over && calls.length > agent.length) {
264
+ msg = {...msg, parts: msg.parts.map((x: any) => {
265
+ if (!isTool(x) || x.state != 'input-available' || !isClient(c, x)) return x
266
+ const name = x.type.slice(5), call = {id: x.toolCallId, name, input: x.input}
267
+ let y = x
268
+ if (c.ask && name == 'ask_user') y = c.kit.openCard(c.ask, x, p?.state)
269
+ else {
270
+ const r = checkInput(c.client[name], x.input)
271
+ if (r.issues) y = {...x, state: 'output-error', errorText: `Invalid input: ${r.issues}`}
272
+ else call.input = r.input
273
+ }
274
+ if (y.state == 'input-available') opened.push(call)
275
+ else closed = true
276
+ return y
277
+ })}
278
+ }
279
+ const ask = !run && firstAsk(msg)
280
+ const prev = lastOf(s.messages)
281
+ let messages = prev?.role == 'assistant' ? [...s.messages.slice(0, -1), msg] : [...s.messages, msg]
282
+ const card = nextCard(c, messages, p?.state, p?.k)
283
+ messages = card.msgs
284
+ const last = lastOf(messages), wait = openCalls(last).filter(x => isClient(c, x))
285
+ const send = !run && !ask && !wait.length && closed
286
+ const status = send ? 'submitted' : run || ask || wait.some(composing) ? 'streaming' : 'ready'
287
+ return {s: {...s, messages, draft: '', draftReasoning: '', status, pending: ask ? askInfo(ask) : null}, calls: run ? agent : null, ask: !!ask, msg: last, wait: wait.length > 0, send, opened, compose: card.request}
288
+ })
289
+ // a reply's tool results: the next request (or the next server approval first). `late`: a call
290
+ // that was running when STOP came changed the app: its "not run" part gets the result (G-626)
291
+ const resultsStep = memo((s, d, e) => {
292
+ if (!e || !Array.isArray(d?.results) || (d.turn !== e.turn && !d.late)) return
293
+ const out = new Map(d.results.map((r: any) => [r.id, r]))
294
+ const fix = (p: any) => {
295
+ const r: any = isTool(p) && out.get(p.toolCallId)
296
+ return !r ? p : d.late ? (p.state == 'output-error' && /^not run/.test(p.errorText) ? withResult(p, r) : p) : p.state == 'input-available' ? withResult(p, r) : p
297
+ }
298
+ const msgs = s.messages.map((m: any) => m.role == 'assistant' && m.parts?.some((p: any) => out.has(p.toolCallId)) ? {...m, parts: m.parts.map(fix)} : m)
299
+ if (d.late) return {s: {...s, messages: msgs}}
300
+ return settle(s, msgs, c)
301
+ })
302
+ // APPROVE / DENY of a server approval (G-628): the part goes to approval-responded, as the AI
303
+ // SDK's addToolApprovalResponse does; once none waits, the conversation is sent again and the
304
+ // server runs (or skips) the call, continuing the same message
305
+ const answerStep = memo((s, ok) => {
306
+ const id = s.pending?.approvalId, last = lastOf(s.messages)
307
+ if (id === undefined || last?.role != 'assistant') return
308
+ return settle(s, mapLast(s.messages, (p: any) => isTool(p) && p.state == 'approval-requested' && p.approval?.id === id ? {...p, state: 'approval-responded', approval: {...p.approval, approved: ok}} : p), c)
309
+ })
310
+ /** the input-available client part `id` of the last assistant message, or undefined (SYG685) */
311
+ const openPart = (s: any, id: any, what: string, guard = (_p: any) => true) => {
312
+ const p = openToolCalls(s).find(x => x.toolCallId === id && isClient(c, x) && guard(x))
313
+ if (!p) dev('SYG685', `${what} for the tool call '${id}', which is not waiting for an answer (answered, closed, or not a client tool call of the last message); it is ignored`, 'Answer only the calls in openToolCalls(state); a late answer after STOP, SEND or REGENERATE is dropped on purpose')
314
+ return p
162
315
  }
163
- const msgs = s.messages.map((m: any) => m.role == 'assistant' && m.parts?.some((p: any) => out.has(p.toolCallId)) ? {...m, parts: m.parts.map(fix)} : m)
164
- if (d.late) return {s: {...s, messages: msgs}}
165
- const ask = firstAsk(lastOf(msgs))
166
- return {s: {...s, messages: msgs, status: ask ? 'streaming' : 'submitted', pending: ask ? askInfo(ask) : null}, send: !ask}
167
- })
168
- // APPROVE / DENY of a server approval (G-628): the part goes to approval-responded, as the AI
169
- // SDK's addToolApprovalResponse does; once none waits, the conversation is sent again and the
170
- // server runs (or skips) the call, continuing the same message
171
- const answerStep = /*#__PURE__*/ memo((s, ok) => {
172
- const id = s.pending?.approvalId, last = lastOf(s.messages)
173
- if (id === undefined || last?.role != 'assistant') return
174
- const parts = last.parts.map((p: any) => isTool(p) && p.state == 'approval-requested' && p.approval?.id === id ? {...p, state: 'approval-responded', approval: {...p.approval, approved: ok}} : p)
175
- const msgs = [...s.messages.slice(0, -1), {...last, parts}], ask = firstAsk(lastOf(msgs))
176
- return {s: {...s, messages: msgs, pending: ask ? askInfo(ask) : null, status: ask ? 'streaming' : 'submitted'}, send: !ask}
177
- })
316
+ /** after a call closed or a card changed: the next queued card, then rule 3 */
317
+ const after = (s: any, msgs: any[], x: any) => { const n = nextCard(c, msgs, x?.state, x?.k); return {...settle(s, n.msgs, c), compose: n.request} }
318
+ // H-1 rule 5: TOOL_OUTPUT answers an open client call
319
+ const outputStep = memo((s, d, _e, x) => {
320
+ const p = d && typeof d == 'object' && openPart(s, d.toolCallId, 'TOOL_OUTPUT')
321
+ if (!p) return
322
+ const err = 'errorText' in d
323
+ const {output: _o, errorText: _x, ...rest} = p
324
+ const np = {...rest, ...(err ? {state: 'output-error', errorText: String(d.errorText)} : {state: 'output-available', output: d.output}),
325
+ ...(p.ui && {ui: {...p.ui, status: err ? 'failed' : 'answered', pending: null}})}
326
+ return after(s, mapLast(s.messages, (y: any) => y === p ? np : y), x)
327
+ })
328
+ // H-2: a result part answered an open card: the call's output is `{ part, answer }`
329
+ const answerCard = memo((s, d, _e, x) => {
330
+ const p = d && typeof d == 'object' && openPart(s, d.scope, 'ANSWER', y => y.ui?.status == 'open')
331
+ if (!p) return
332
+ const part = p.ui.layout?.nodes?.[d.node]?.part ?? null
333
+ const np = {...p, state: 'output-available', output: {part, answer: d.value}, ui: {...p.ui, status: 'answered', answer: {node: d.node, part, value: d.value}}}
334
+ return after(s, mapLast(s.messages, (y: any) => y === p ? np : y), x)
335
+ })
336
+ // H-2: the backend's reply for the card in flight (another step, the card, or a failure), then the next card
337
+ const cardStep = (ok: boolean) => memo((s, d, _e, x) => {
338
+ const st = x?.state, k = x?.k
339
+ const card = openToolCalls(s).find((y: any) => composing(y) && y.ui.pending)
340
+ // the reply's request key: the chat driver's `key`, an HTTP reply's `askKey` tag (G-756, ask.ts), a failure's request
341
+ const key = d?.askKey ?? d?.key ?? d?.request?.key
342
+ // a late reply (the card was cancelled or answered, or the turn replaced) is dropped
343
+ if (!card || (typeof key == 'string' && key != `${k}.ask:${card.toolCallId}`)) return
344
+ const r = ok ? c.kit.cardReply(c.ask, card, d, st, k) : c.kit.failed(card, String(d?.error?.message ?? d?.error ?? 'the request failed'))
345
+ const msgs = mapLast(s.messages, (y: any) => y === card ? r.part ?? r : y)
346
+ return r.request !== undefined ? {...settle(s, msgs, c), compose: r.request} : after(s, msgs, x)
347
+ })
348
+ const cardOk = cardStep(true), cardFailed = cardStep(false)
349
+ // H-2: a container control in a card: ui[node][name] = value, on the card's part
350
+ const controlStep = (s: any, d: any) => {
351
+ if (!d || typeof d.scope != 'string') return
352
+ let hit = false
353
+ const messages = s.messages.map((m: any) => m.role != 'assistant' || !m.parts.some((x: any) => x.toolCallId === d.scope && x.ui) ? m
354
+ : {...m, parts: m.parts.map((x: any) => x.toolCallId !== d.scope || !x.ui ? x : (hit = true, {...x, ui: {...x.ui, ui: {...x.ui.ui, [d.key]: {...x.ui.ui?.[d.key], [d.name]: d.value}}}}))})
355
+ return hit ? {...s, messages} : undefined
356
+ }
357
+ // rule 12: a restored busy slice (its request is gone) is made usable again; a composing card is composed again
358
+ const resumeStep = memo((s, _d, _e, p) => {
359
+ const msgs0 = s.messages.length ? mapLast(s.messages, (x: any) => composing(x) ? {...x, ui: {...x.ui, pending: null}} : x) : s.messages
360
+ // agent calls still open never get their results now
361
+ const m = lastOf(msgs0)
362
+ let msgs = m?.role == 'assistant' && openCalls(m).some(x => !isClient(c, x))
363
+ ? mapLast(msgs0, (x: any) => isTool(x) && x.state == 'input-available' && !isClient(c, x) ? {...x, state: 'output-error', errorText: 'not run: the page was reloaded'} : x) : msgs0
364
+ msgs = withPartial(s, msgs)
365
+ const card = nextCard(c, msgs, p?.state, p?.k)
366
+ const ask = firstAsk(lastOf(card.msgs))
367
+ return {s: {...s, messages: card.msgs, draft: '', draftReasoning: '', status: ask || card.request !== undefined ? 'streaming' : 'ready', pending: ask ? askInfo(ask) : null}, compose: card.request}
368
+ })
369
+ /** the partial reply (STOP, RESUME) appended to the last assistant message, or as a new one */
370
+ const withPartial = (s: any, msgs: any[]) => {
371
+ const partial = [...(s.draftReasoning ? [{type: 'reasoning', text: s.draftReasoning}] : []), ...(s.draft ? [{type: 'text', text: s.draft}] : [])]
372
+ const last = lastOf(msgs)
373
+ return !partial.length ? msgs : last?.role == 'assistant' ? [...msgs.slice(0, -1), {...last, parts: [...last.parts, {type: 'step-start'}, ...partial]}] : [...msgs, {id: messageId(), role: 'assistant', parts: partial}]
374
+ }
375
+ return {sendStep, regenStep, replyStep, resultsStep, answerStep, outputStep, answerCard, cardOk, cardFailed, controlStep, resumeStep, withPartial}
376
+ }
178
377
 
179
378
  // ------------------------------------------------------------------ the request
180
379
  /** the app-state message's id and metadata marker (G-631) */
@@ -200,12 +399,15 @@ function appState(t: any, groups: any[], labels: Map<string, any>): any {
200
399
  return {id: APP_STATE, role: 'user', metadata: {sygnal: APP_STATE, untrusted}, parts: [{type: 'text', text}]}
201
400
  }
202
401
 
402
+ const shadowed = new Set<string>()
203
403
  /**
204
404
  * The request for the conversation `messages`: tools and app state of now, the state right before
205
405
  * the last user message; `fresh`: a new turn. When the last message is the assistant's (tool
206
- * results, an approval answered) the reply continues it (`continue: true`, G-628)
406
+ * results, an approval answered) the reply continues it (`continue: true`, G-628). H-1: the client
407
+ * tools join the agent tools (a name in both: the client tool, SYG684); `host`: the host's state
408
+ * (ask's direct layout schema reads it)
207
409
  */
208
- function request(e: Engine | undefined, o: any, k: string, messages: any[], fresh: boolean) {
410
+ function request(e: Engine | undefined, o: any, k: string, messages: any[], fresh: boolean, c?: Cfg, host?: any) {
209
411
  const t = e && toolsOf(e)
210
412
  if (e) {
211
413
  // a new turn: a call still waiting on `until` answers now, as pending
@@ -219,17 +421,26 @@ function request(e: Engine | undefined, o: any, k: string, messages: any[], fres
219
421
  }
220
422
  const state = t && appState(t, groups, labels), i = lastUser(messages)
221
423
  const schema = (x: any) => bare.has(x.name) ? unlabel(x.inputSchema, `their labels are in ${bare.get(x.name)}_labels in the app state`) : x.inputSchema
424
+ const tools: any = Object.fromEntries(list.map((x: any) => [x.name, {description: x.description, inputSchema: schema(x)}]))
425
+ if (c) for (const n in c.client) {
426
+ if (n in tools && !shadowed.has(n)) { shadowed.add(n); dev('SYG684', `the client tool '${n}' has the name of an agent tool; the client tool is sent and its calls are the app's to answer`, 'Rename the client tool, or the agent declaration or action') }
427
+ const x = c.client[n]
428
+ tools[n] = c.ask && n == 'ask_user' ? c.kit.askTool(c.ask, host) : {description: x.description, inputSchema: schemaOf(x)}
429
+ }
222
430
  return {
223
431
  ...o.transportOptions,
224
432
  messages: !state ? messages : i < 0 ? [state, ...messages] : [...messages.slice(0, i), state, ...messages.slice(i)],
225
433
  ...(o.instructions && {instructions: o.instructions}),
226
- ...(list.length && {tools: Object.fromEntries(list.map((x: any) => [x.name, {description: x.description, inputSchema: schema(x)}]))}),
227
- ...(o.model && {model: o.model}),
434
+ ...(Object.keys(tools).length && {tools}),
435
+ ...modelOf(o.model, host),
228
436
  ...(lastOf(messages)?.role == 'assistant' && {continue: true}),
229
437
  key: k, delta: k + '.DELTA', ok: k + '.REPLY', error: k + '.FAILED',
230
438
  }
231
439
  }
232
440
 
441
+ /** `model`: a model id, or a function of the host's state returning one (resolved per request) */
442
+ const modelOf = (m: any, host: any) => { const v = typeof m == 'function' ? m(host) : m; return v ? {model: v} : {} }
443
+
233
444
  /**
234
445
  * run a reply's tool calls one at a time; RESULTS when all are done (dropped if the turn moved on).
235
446
  * The results so far are kept on the engine (`ran`): STOP gives the calls that ran their results
@@ -251,20 +462,60 @@ async function runTools(e: Engine, calls: any[]) {
251
462
  dispatch(e, 'RESULTS', {turn, results}, 'reply')
252
463
  }
253
464
 
465
+ /** a click on a card's container control (catalog.render's `control()` data attributes) */
466
+ const controlOf = (ev: any) => {
467
+ const el = ev?.ownerTarget || ev?.target?.closest?.('[data-compose-control]') || ev?.target
468
+ const ds = el?.dataset || {}
469
+ if (!ds.composeControl || !ds.composeKey || ds.composeScope === undefined) return null
470
+ let value: any = ds.composeValue
471
+ try { value = JSON.parse(value) } catch (_) { /* a plain string */ }
472
+ return {scope: ds.composeScope, key: ds.composeKey, name: ds.composeControl, value}
473
+ }
474
+
254
475
  /**
255
476
  * The chat behavior (PLAN-6 L-3): `uses = { assistant: chat({ sink, form, prompt, stop, approve,
256
- * deny, instructions, model, agent, maxSteps, transportOptions }) }`. Types: src/ai.d.ts.
477
+ * deny, instructions, model, agent, maxSteps, transportOptions, clientTools, ask }) }`. Types:
478
+ * src/ai.d.ts.
257
479
  */
258
480
  export const chat = (options: any = {}): any => {
259
- const {sink = 'LLM', form, prompt, stop, approve, deny, regenerate} = options
481
+ const {sink = 'LLM', form, prompt, stop, approve, deny, regenerate, clientTools, ask} = options
260
482
  const on = (DOM: any, sel: any, ev = 'click', o?: any) => DOM.select(sel).events(ev, o)
261
483
  const E = engineFor
484
+ // H-1 / H-2: the calls the app answers; ask_user's card machinery comes with the catalog
485
+ const kit = ask?.catalog?._ask
486
+ if (ask && !kit) throw new Error('[Sygnal] chat({ ask }): `ask.catalog` must be a catalog made by defineCatalog()')
487
+ const c: Cfg = {client: {...clientTools}, ...(ask && {kit, ask: kit.askConfig(ask, options, sink)})}
488
+ if (ask) c.client.ask_user = {}
489
+ const B: string | undefined = c.ask?.backend?.sink
490
+ const S = steps(c, options)
491
+ const {sendStep, regenStep, replyStep, resultsStep, answerStep} = S
492
+ // the sink handlers' view of the host: its state (props.state) and the use's key
493
+ const P = (p: any, k: string) => p && {state: p.state, k}
494
+ // a step's request on the chat sink (`send`), and the card request on the backend's sink
495
+ const out = (n: any, e: any, o: any, k: string, p: any, which: 'chat' | 'card') => {
496
+ if (!n) return ABORT
497
+ if (which == 'card') return n.compose ?? ABORT
498
+ return n.send ? request(e, o, k, n.s.messages, false, c, p?.state) : B === sink && n.compose !== undefined ? n.compose : ABORT
499
+ }
500
+ /** model entries for a step's chat request and its card request (one entry when they share a sink) */
501
+ const sinks = (step: (s: any, d: any, e: any, p: any) => any) => ({
502
+ [sink]: (s: any, d: any, _n: any, p: any, o: any, k: string) => { const e = E(p, k, o); return out(step(s, d, e, P(p, k)), e, o, k, p, 'chat') },
503
+ ...(B && B !== sink && {[B]: (s: any, d: any, _n: any, p: any, o: any, k: string) => out(step(s, d, E(p, k, o), P(p, k)), undefined, o, k, p, 'card')}),
504
+ })
505
+ const stateOf = (step: (s: any, d: any, e: any, p: any) => any) => (s: any, d: any, _n: any, p: any, o: any, k: string) => step(s, d, E(p, k, o), P(p, k))?.s || ABORT
506
+ /** the card request in flight's key (STOP aborts it) */
507
+ const inflight = (s: any, k: string) => { const x = openToolCalls(s).find((p: any) => composing(p) && p.ui.pending); return x && `${k}.ask:${x.toolCallId}` }
262
508
  const b = defineBehavior({
263
509
  initialState: {messages: [], prompt: '', draft: '', draftReasoning: '', status: 'ready', pending: null, error: null},
264
510
  intent: (so: any) => {
265
- const {DOM} = so
511
+ const {DOM, CHILD} = so
266
512
  // SYG442: an app made before the first chat() call has no layer: no tools, no loop
267
513
  checkLinked(so, 'chat', 'it sends no tools and runs no tool calls')
514
+ // rule 12: a slice restored busy (persist) or with a card composing; a microtask later, so
515
+ // the requests it sends reach drivers that are wired after the instance is made
516
+ const restored = so.STATE?.stream?.take?.(1).filter((s: any) => s && (busy(s) || openToolCalls(s).some(composing)))
517
+ .map((s: any) => xs.fromPromise(Promise.resolve(s))).flatten()
518
+ const results = c.ask?.results || []
268
519
  return {
269
520
  ...(prompt && {PROMPT: on(DOM, prompt, 'input').map((ev: any) => ev.target.value)}),
270
521
  ...(form && {SEND: on(DOM, form, 'submit', {preventDefault: true})}),
@@ -272,70 +523,92 @@ export const chat = (options: any = {}): any => {
272
523
  ...(approve && {APPROVE: on(DOM, approve)}),
273
524
  ...(deny && {DENY: on(DOM, deny)}),
274
525
  ...(regenerate && {REGENERATE: on(DOM, regenerate)}),
526
+ ...(restored && {RESUME: restored}),
527
+ // H-2: a result part's PARENT value (compose.answer): cards render in the host's view
528
+ ...(results.length && CHILD && {ANSWER: xs.merge(...results.map((C: any) => CHILD.select(C))).filter((v: any) => v && typeof v == 'object' && v.scope !== undefined)}),
529
+ ...(c.ask?.controls && DOM && {CONTROL: DOM.select('[data-compose-control]').events('click').map(controlOf).filter((x: any) => !!x)}),
275
530
  }
276
531
  },
277
532
  model: {
278
533
  PROMPT: (s: any, p: any) => typeof p == 'string' && p !== s.prompt ? {...s, prompt: p} : ABORT,
279
534
  SEND: {
280
535
  STATE: (s: any, d: any) => sendStep(s, d) || ABORT,
281
- [sink]: (s: any, d: any, _n: any, p: any, o: any, k: string) => { const n = sendStep(s, d); return n ? request(E(p, k, o), o, k, n.messages, true) : ABORT },
536
+ [sink]: (s: any, d: any, _n: any, p: any, o: any, k: string) => { const n = sendStep(s, d); return n ? request(E(p, k, o), o, k, n.messages, true, c, p?.state) : ABORT },
282
537
  },
283
538
  REGENERATE: {
284
539
  STATE: (s: any) => regenStep(s, 0) || ABORT,
285
- [sink]: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { const n = regenStep(s, 0); return n ? request(E(p, k, o), o, k, n.messages, true) : ABORT },
540
+ [sink]: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { const n = regenStep(s, 0); return n ? request(E(p, k, o), o, k, n.messages, true, c, p?.state) : ABORT },
286
541
  },
287
542
  // G-627: the reply's text and reasoning so far
288
543
  DELTA: (s: any, d: any) => ({...s, draft: d?.text ?? s.draft, draftReasoning: d?.reasoning ?? s.draftReasoning, status: 'streaming'}),
289
544
  REPLY: {
290
- STATE: (s: any, d: any, _n: any, p: any, o: any, k: string) => replyStep(s, d, E(p, k, o))?.s || ABORT,
291
- // the turn is over: DONE in the same flush (a host entry 'assistant.DONE' runs after it)
545
+ STATE: stateOf(replyStep),
546
+ ...sinks(replyStep),
547
+ // the turn is over: DONE in the same flush (a host entry 'assistant.DONE' runs after it).
548
+ // H-1: each client call that opened fires TOOL_CALL; the turn waits for their answers
292
549
  EFFECT: (s: any, d: any, next: any, p: any, o: any, k: string) => {
293
- const e = E(p, k, o), r = replyStep(s, d, e)
294
- if (!r || r.ask) return
550
+ const e = E(p, k, o), r = replyStep(s, d, e, P(p, k))
551
+ if (!r) return
552
+ for (const call of r.opened) e ? dispatch(e, 'TOOL_CALL', call, 'next') : next('TOOL_CALL', call, 0)
553
+ if (r.ask) return
295
554
  const done = {message: r.msg, text: d.text, finishReason: d.finishReason, usage: d.usage, steps: e?.steps ?? 1}
296
555
  if (r.calls) void runTools(e!, r.calls)
556
+ else if (r.wait || r.send) return
297
557
  else if (e) dispatch(e, 'DONE', done, 'next')
298
558
  else next('DONE', done, 0)
299
559
  },
300
560
  },
301
561
  RESULTS: {
302
- STATE: (s: any, d: any, _n: any, p: any, o: any, k: string) => resultsStep(s, d, E(p, k, o))?.s || ABORT,
303
- [sink]: (s: any, d: any, _n: any, p: any, o: any, k: string) => { const e = E(p, k, o), n = resultsStep(s, d, e); return n?.send ? request(e, o, k, n.s.messages, false) : ABORT },
562
+ STATE: stateOf(resultsStep),
563
+ [sink]: (s: any, d: any, _n: any, p: any, o: any, k: string) => { const e = E(p, k, o), n = resultsStep(s, d, e); return n?.send ? request(e, o, k, n.s.messages, false, c, p?.state) : ABORT },
304
564
  },
305
565
  ASK: (s: any, info: any) => ({...s, pending: info}),
306
566
  // a consequential app call (A-1's confirm) or a server approval (G-628: answerStep)
307
567
  APPROVE: {
308
568
  STATE: (s: any) => answerStep(s, true)?.s || (s.pending ? {...s, pending: null} : ABORT),
309
- [sink]: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { const n = answerStep(s, true); return n?.send ? request(E(p, k, o), o, k, n.s.messages, false) : ABORT },
569
+ [sink]: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { const n = answerStep(s, true); return n?.send ? request(E(p, k, o), o, k, n.s.messages, false, c, p?.state) : ABORT },
310
570
  EFFECT: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { if (s.pending?.approvalId === undefined) E(p, k, o)?.answer?.(true) },
311
571
  },
312
572
  DENY: {
313
573
  STATE: (s: any) => answerStep(s, false)?.s || (s.pending ? {...s, pending: null} : ABORT),
314
- [sink]: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { const n = answerStep(s, false); return n?.send ? request(E(p, k, o), o, k, n.s.messages, false) : ABORT },
574
+ [sink]: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { const n = answerStep(s, false); return n?.send ? request(E(p, k, o), o, k, n.s.messages, false, c, p?.state) : ABORT },
315
575
  EFFECT: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { if (s.pending?.approvalId === undefined) E(p, k, o)?.answer?.(false) },
316
576
  },
577
+ // H-1: the app answers a client call (rule 5)
578
+ TOOL_OUTPUT: {STATE: stateOf(S.outputStep), ...sinks(S.outputStep)},
579
+ // H-1: a client call opened (a host entry 'assistant.TOOL_CALL' runs after it)
580
+ TOOL_CALL: () => ABORT,
317
581
  STOP: {
318
- // the calls that ran keep their results (G-626); the partial reply is kept (its reasoning too)
582
+ // the calls that ran keep their results (G-626); the partial reply is kept (its reasoning
583
+ // too); H-1: open client calls are closed, a card being composed is cancelled
319
584
  STATE: (s: any, _d: any, _n: any, p: any, o: any, k: string) => {
320
- if (!busy(s)) return ABORT
321
- const e = E(p, k, o), msgs = closeOpen(s.messages, 'not run: stopped by the user', e?.ran?.turn === e?.turn ? e?.ran?.out : undefined)
322
- const partial = [...(s.draftReasoning ? [{type: 'reasoning', text: s.draftReasoning}] : []), ...(s.draft ? [{type: 'text', text: s.draft}] : [])]
323
- const last = lastOf(msgs)
324
- return {
325
- ...s, draft: '', draftReasoning: '', status: 'ready', pending: null,
326
- messages: !partial.length ? msgs : last?.role == 'assistant' ? [...msgs.slice(0, -1), {...last, parts: [...last.parts, {type: 'step-start'}, ...partial]}] : [...msgs, {id: messageId(), role: 'assistant', parts: partial}],
327
- }
585
+ if (!busy(s) && !openToolCalls(s).length) return ABORT
586
+ const e = E(p, k, o), msgs = closeOpen(s.messages, 'not run: stopped by the user', e?.ran?.turn === e?.turn ? e?.ran?.out : undefined, c, 'not answered: stopped by the user')
587
+ return {...s, draft: '', draftReasoning: '', status: 'ready', pending: null, messages: S.withPartial(s, msgs)}
588
+ },
589
+ [sink]: (s: any, _d: any, _n: any, _p: any, _o: any, k: string) => {
590
+ const keys = [...(busy(s) ? [k] : []), ...(B === sink && inflight(s, k) ? [inflight(s, k)] : [])]
591
+ return !keys.length ? ABORT : {abort: keys.length == 1 ? keys[0] : keys}
328
592
  },
329
- [sink]: (s: any, _d: any, _n: any, _p: any, _o: any, k: string) => busy(s) ? {abort: k} : ABORT,
593
+ ...(B && B !== sink && {[B]: (s: any, _d: any, _n: any, _p: any, _o: any, k: string) => inflight(s, k) ? {abort: inflight(s, k)} : ABORT}),
330
594
  // the turn moves on: a waiting confirm is declined, tool results still to come are dropped
331
595
  EFFECT: (s: any, _d: any, _n: any, p: any, o: any, k: string) => { const e = busy(s) && E(p, k, o); if (e) { e.turn++; e.answer?.(false); e.tools?.cancel() } },
332
596
  },
333
597
  FAILED: {
334
- STATE: (s: any, d: any) => ({...s, messages: closeOpen(s.messages, 'not run: the request failed'), draft: '', draftReasoning: '', status: 'error', pending: null, error: d?.error?.message ?? String(d?.error ?? 'error')}),
598
+ STATE: (s: any, d: any) => ({...s, messages: closeOpen(s.messages, 'not run: the request failed', undefined, c), draft: '', draftReasoning: '', status: 'error', pending: null, error: d?.error?.message ?? String(d?.error ?? 'error')}),
335
599
  EFFECT: (_s: any, _d: any, _n: any, p: any, o: any, k: string) => { const e = E(p, k, o); if (e) { e.turn++; e.answer?.(false); e.tools?.cancel() } },
336
600
  },
337
601
  // (REPLY already set ready: a host entry gets the finished state)
338
602
  DONE: (s: any) => s.status == 'ready' ? ABORT : {...s, status: 'ready'},
603
+ // rule 12: once at start, for a slice restored busy
604
+ RESUME: {STATE: stateOf(S.resumeStep), ...sinks(S.resumeStep)},
605
+ ...(c.ask && {
606
+ // H-2: the card backend's replies, a result part's answer, a card's container control
607
+ CARD: {STATE: stateOf(S.cardOk), ...sinks(S.cardOk)},
608
+ CARD_FAILED: {STATE: stateOf(S.cardFailed), ...sinks(S.cardFailed)},
609
+ ANSWER: {STATE: stateOf(S.answerCard), ...sinks(S.answerCard)},
610
+ CONTROL: (s: any, d: any) => S.controlStep(s, d) || ABORT,
611
+ }),
339
612
  },
340
613
  })(options)
341
614
  // the `prompt` option is a selector, not the slice's start value (defineBehavior's option override)