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.
Files changed (92) hide show
  1. package/CHANGELOG.md +100 -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 +174 -6
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +174 -6
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/adapters.md +2 -1
  15. package/dist/guide/agent.md +119 -7
  16. package/dist/guide/ai-cards.md +319 -0
  17. package/dist/guide/ai-chat.md +64 -16
  18. package/dist/guide/ai-compose.md +881 -0
  19. package/dist/guide/ai-decisions.md +17 -1
  20. package/dist/guide/behaviors.md +1 -1
  21. package/dist/guide/error-boundaries.md +2 -1
  22. package/dist/guide/forms.md +1 -1
  23. package/dist/guide/http.md +5 -4
  24. package/dist/guide/mcp-apps.md +4 -2
  25. package/dist/guide/persistence.md +1 -1
  26. package/dist/guide/recipes/ai-form-fill.md +1 -1
  27. package/dist/guide/recipes/ai-summarize.md +1 -1
  28. package/dist/guide/recipes/ai-support-inbox.md +1 -1
  29. package/dist/guide/resources.md +11 -10
  30. package/dist/guide/ui/combobox.md +1 -1
  31. package/dist/guide/undo.md +1 -1
  32. package/dist/guide/web-components.md +1 -0
  33. package/dist/guide/webmcp.md +1 -2
  34. package/dist/index.cjs.js +5842 -370
  35. package/dist/index.cjs.js.map +1 -1
  36. package/dist/index.d.ts +679 -13
  37. package/dist/index.esm.js +5832 -370
  38. package/dist/index.esm.js.map +1 -1
  39. package/dist/sygnal.min.js +1 -1
  40. package/dist/sygnal.min.js.map +1 -1
  41. package/dist/vike/config/package.json +1 -1
  42. package/dist/vike/onRenderClient.cjs.js +3 -2
  43. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  44. package/dist/vike/onRenderClient.mjs +3 -2
  45. package/dist/vike/onRenderClient.mjs.map +1 -1
  46. package/llms.txt +8 -6
  47. package/package.json +2 -1
  48. package/src/ai.d.ts +683 -12
  49. package/src/ai.ts +6 -0
  50. package/src/collection.ts +2 -1
  51. package/src/core/instance.ts +6 -3
  52. package/src/core/markers/lazy.ts +7 -3
  53. package/src/core/markers/portal.ts +5 -2
  54. package/src/core/markers/suspense.ts +3 -2
  55. package/src/core/markers/transition.ts +6 -3
  56. package/src/core/registry.ts +9 -5
  57. package/src/cycle/dom/IsolateModule.ts +25 -22
  58. package/src/extra/ai/agent/index.ts +111 -26
  59. package/src/extra/ai/chat/behavior.ts +368 -87
  60. package/src/extra/ai/chat/driver.ts +7 -4
  61. package/src/extra/ai/compose/agent.ts +55 -0
  62. package/src/extra/ai/compose/ask.ts +147 -0
  63. package/src/extra/ai/compose/behavior.ts +183 -0
  64. package/src/extra/ai/compose/catalog.ts +211 -0
  65. package/src/extra/ai/compose/composed.ts +61 -0
  66. package/src/extra/ai/compose/decisions.ts +1649 -0
  67. package/src/extra/ai/compose/diag.ts +33 -0
  68. package/src/extra/ai/compose/index.ts +23 -0
  69. package/src/extra/ai/compose/layout.ts +441 -0
  70. package/src/extra/ai/compose/llm.ts +1120 -0
  71. package/src/extra/ai/compose/render.ts +159 -0
  72. package/src/extra/ai/compose/testing.ts +71 -0
  73. package/src/extra/ai/compose/types.ts +159 -0
  74. package/src/extra/ai/decide.ts +97 -1
  75. package/src/extra/ai/index.ts +6 -1
  76. package/src/extra/ai/webmcp.ts +5 -3
  77. package/src/extra/diagnostics/checks/next.ts +86 -2
  78. package/src/extra/diagnostics/codes.ts +64 -3
  79. package/src/extra/fetchDriver.ts +58 -27
  80. package/src/extra/lens.ts +15 -0
  81. package/src/extra/ssr.ts +3 -3
  82. package/src/extra/testing.ts +2 -0
  83. package/src/extra/widget.ts +3 -3
  84. package/src/index.d.ts +42 -1
  85. package/src/index.ts +8 -0
  86. package/src/lazy.ts +3 -2
  87. package/src/portal.ts +5 -2
  88. package/src/pragma/index.ts +2 -1
  89. package/src/suspense.ts +5 -2
  90. package/src/switchable.ts +2 -1
  91. package/src/transition.ts +5 -2
  92. package/src/vike/onRenderClient.ts +3 -2
@@ -23,7 +23,8 @@
23
23
  *
24
24
  * Actions ('assistant.X'): SEND (the form's submit: the prompt; or a string as data), STOP (aborts
25
25
  * the stream, keeps the partial text and reasoning; calls that already ran keep their results,
26
- * G-626), REGENERATE (replays the last user turn), APPROVE, DENY,
26
+ * G-626; a call waiting on an action's `until` answers as pending: the tool set's cancel(), as on
27
+ * FAILED and a new turn), REGENERATE (replays the last user turn), APPROVE, DENY,
27
28
  * DONE (the turn is over: `{ message, text, finishReason, usage, steps }`; a host entry
28
29
  * 'assistant.DONE' runs after it, like 'form.DONE'). Internal: PROMPT (typing), DELTA / REPLY /
29
30
  * FAILED (the chat driver's reply actions), ASK (a consequential call waits), RESULTS (the tool
@@ -56,10 +57,47 @@
56
57
  * the host's dispose stops it. A host in an app made before the first chat() call is reported as
57
58
  * SYG442 and runs without tools. The engine dispatches RESULTS / ASK (cause 'reply') and DONE
58
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.
59
94
  */
60
95
  import {defineBehavior} from '../../behaviors'
61
96
  import {ABORT} from '../../../shared'
97
+ import {report} from '../../diagnostics/index'
98
+ import xs from '../../xstreamCompat'
62
99
  import {agentTools, hasUserText, labelsUntrusted, unlabel} from '../agent/index'
100
+ import {toJsonSchema, repair, isObj} from '../schema/index'
63
101
  import {linked, engineOf, checkLinked} from '../link'
64
102
  import {messageId} from '../messages'
65
103
 
@@ -83,6 +121,9 @@ const toolsOf = (e: Engine) => e.o.agent === false ? null : e.tools ||= agentToo
83
121
  }),
84
122
  })
85
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
+
86
127
  // ------------------------------------------------------------------ messages
87
128
  const isTool = (p: any) => typeof p?.type == 'string' && p.type.startsWith('tool-')
88
129
  const openCalls = (m: any): any[] => (m?.parts || []).filter((p: any) => isTool(p) && p.state == 'input-available')
@@ -93,18 +134,31 @@ const askInfo = (p: any) => {
93
134
  const name = p.type.slice(5)
94
135
  return {tool: name, component: 'server', action: name, description: p.approval?.requestReason ?? `Run the ${name} tool`, input: p.input, approvalId: p.approval?.id}
95
136
  }
96
- /** a tool part with an A-1 result (`{ output }` or `{ error }`) */
97
- const withResult = (p: any, r: any) => 'error' in r ? {...p, state: 'output-error', errorText: r.error} : {...p, state: 'output-available', output: r.output}
137
+ /**
138
+ * a tool part with an A-1 result (`{ output }` or `{ error }`); the other side's field goes (a late
139
+ * result replaces the "not run" error of a stopped turn, G-626)
140
+ */
141
+ const withResult = (p: any, r: any) => {
142
+ const {output: _o, errorText: _e, ...rest} = p
143
+ return 'error' in r ? {...rest, state: 'output-error', errorText: r.error} : {...rest, state: 'output-available', output: r.output}
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
98
150
  /**
99
151
  * The last message with its open tool parts closed (a turn stopped, failed or capped): a call
100
- * that already ran gets its result (`ran`, G-626), the others `why`; a server approval still
101
- * 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
102
154
  */
103
- 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) => {
104
156
  const m = msgs[msgs.length - 1]
105
157
  if (!openCalls(m).length && !firstAsk(m)) return msgs
106
158
  return [...msgs.slice(0, -1), {...m, parts: m.parts.map((p: any) => !isTool(p) ? p
107
- : 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})
108
162
  : p.state == 'approval-requested' ? {...p, state: 'output-denied', approval: {...p.approval, approved: false, reason: why}}
109
163
  : p)}]
110
164
  }
@@ -113,61 +167,213 @@ const user = (text: string) => ({id: messageId(), role: 'user', parts: [{type: '
113
167
  const busy = (s: any) => s.status == 'submitted' || s.status == 'streaming'
114
168
  const lastUser = (msgs: any[]) => { for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role == 'user') return i; return -1 }
115
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'
116
178
 
117
- /** one step's outcome per (slice, data): STATE and the LLM sink of an action read the same one */
118
- const memo = (f: (s: any, d: any, e?: Engine) => any) => {
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
+ }
191
+
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) => {
119
211
  let l: any[] = []
120
- 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'}
121
235
  }
122
236
 
123
237
  const textOf = (d: any) => typeof d == 'string' ? d : d && typeof d == 'object' && typeof d.text == 'string' && !('target' in d) ? d.text : undefined
124
- const sendStep = /*#__PURE__*/ memo((s, d) => {
125
- const given = textOf(d), text = given ?? s.prompt
126
- if (busy(s) || typeof text != 'string' || !text.trim()) return
127
- 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}
128
- })
129
- const regenStep = /*#__PURE__*/ memo(s => {
130
- const i = lastUser(s.messages)
131
- if (busy(s) || i < 0) return
132
- return {...s, messages: s.messages.slice(0, i + 1), draft: '', draftReasoning: '', status: 'submitted', error: null, pending: null}
133
- })
134
- // a reply: run its open tool calls (status stays streaming), wait for a server approval
135
- // (`pending`), or the turn is over (DONE). Past maxSteps the open calls are closed as not run. A
136
- // reply to a request that continued the last (assistant) message replaces it (G-628: the driver
137
- // grew that message, as the AI SDK does)
138
- const replyStep = /*#__PURE__*/ memo((s, d, e) => {
139
- const m = d?.message
140
- if (!m) return
141
- const max = e?.o.maxSteps ?? 8, calls = openCalls(m), run = calls.length > 0 && !!e && e.steps < max
142
- const msg = calls.length && !run ? closeOpen([m], `not run: the step limit (maxSteps ${max}) was reached`)[0] : m
143
- const ask = !run && firstAsk(msg)
144
- const prev = lastOf(s.messages), messages = prev?.role == 'assistant' ? [...s.messages.slice(0, -1), msg] : [...s.messages, msg]
145
- return {s: {...s, messages, draft: '', draftReasoning: '', status: run || ask ? 'streaming' : 'ready', pending: ask ? askInfo(ask) : null}, calls: run ? calls : null, ask: !!ask, msg}
146
- })
147
- // a reply's tool results: the next request (or the next server approval first). `late`: a call
148
- // that was running when STOP came changed the app: its "not run" part gets the result (G-626)
149
- const resultsStep = /*#__PURE__*/ memo((s, d, e) => {
150
- if (!e || !Array.isArray(d?.results) || (d.turn !== e.turn && !d.late)) return
151
- const out = new Map(d.results.map((r: any) => [r.id, r]))
152
- const fix = (p: any) => {
153
- const r: any = isTool(p) && out.get(p.toolCallId)
154
- 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
155
315
  }
156
- 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)
157
- if (d.late) return {s: {...s, messages: msgs}}
158
- const ask = firstAsk(lastOf(msgs))
159
- return {s: {...s, messages: msgs, status: ask ? 'streaming' : 'submitted', pending: ask ? askInfo(ask) : null}, send: !ask}
160
- })
161
- // APPROVE / DENY of a server approval (G-628): the part goes to approval-responded, as the AI
162
- // SDK's addToolApprovalResponse does; once none waits, the conversation is sent again and the
163
- // server runs (or skips) the call, continuing the same message
164
- const answerStep = /*#__PURE__*/ memo((s, ok) => {
165
- const id = s.pending?.approvalId, last = lastOf(s.messages)
166
- if (id === undefined || last?.role != 'assistant') return
167
- 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)
168
- const msgs = [...s.messages.slice(0, -1), {...last, parts}], ask = firstAsk(lastOf(msgs))
169
- return {s: {...s, messages: msgs, pending: ask ? askInfo(ask) : null, status: ask ? 'streaming' : 'submitted'}, send: !ask}
170
- })
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
+ }
171
377
 
172
378
  // ------------------------------------------------------------------ the request
173
379
  /** the app-state message's id and metadata marker (G-631) */
@@ -193,15 +399,19 @@ function appState(t: any, groups: any[], labels: Map<string, any>): any {
193
399
  return {id: APP_STATE, role: 'user', metadata: {sygnal: APP_STATE, untrusted}, parts: [{type: 'text', text}]}
194
400
  }
195
401
 
402
+ const shadowed = new Set<string>()
196
403
  /**
197
404
  * The request for the conversation `messages`: tools and app state of now, the state right before
198
405
  * the last user message; `fresh`: a new turn. When the last message is the assistant's (tool
199
- * 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)
200
409
  */
201
- 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) {
202
411
  const t = e && toolsOf(e)
203
412
  if (e) {
204
- if (fresh) { e.turn++; e.steps = 0; e.answer?.(false) }
413
+ // a new turn: a call still waiting on `until` answers now, as pending
414
+ if (fresh) { e.turn++; e.steps = 0; e.answer?.(false); e.tools?.cancel() }
205
415
  e.steps++
206
416
  }
207
417
  const list = t ? t.list() : [], groups = t ? t.groups() : [], labels = new Map<string, any>(), bare = new Map<string, string>()
@@ -211,17 +421,26 @@ function request(e: Engine | undefined, o: any, k: string, messages: any[], fres
211
421
  }
212
422
  const state = t && appState(t, groups, labels), i = lastUser(messages)
213
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
+ }
214
430
  return {
215
431
  ...o.transportOptions,
216
432
  messages: !state ? messages : i < 0 ? [state, ...messages] : [...messages.slice(0, i), state, ...messages.slice(i)],
217
433
  ...(o.instructions && {instructions: o.instructions}),
218
- ...(list.length && {tools: Object.fromEntries(list.map((x: any) => [x.name, {description: x.description, inputSchema: schema(x)}]))}),
219
- ...(o.model && {model: o.model}),
434
+ ...(Object.keys(tools).length && {tools}),
435
+ ...modelOf(o.model, host),
220
436
  ...(lastOf(messages)?.role == 'assistant' && {continue: true}),
221
437
  key: k, delta: k + '.DELTA', ok: k + '.REPLY', error: k + '.FAILED',
222
438
  }
223
439
  }
224
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
+
225
444
  /**
226
445
  * run a reply's tool calls one at a time; RESULTS when all are done (dropped if the turn moved on).
227
446
  * The results so far are kept on the engine (`ran`): STOP gives the calls that ran their results
@@ -243,20 +462,60 @@ async function runTools(e: Engine, calls: any[]) {
243
462
  dispatch(e, 'RESULTS', {turn, results}, 'reply')
244
463
  }
245
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
+
246
475
  /**
247
476
  * The chat behavior (PLAN-6 L-3): `uses = { assistant: chat({ sink, form, prompt, stop, approve,
248
- * 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.
249
479
  */
250
480
  export const chat = (options: any = {}): any => {
251
- const {sink = 'LLM', form, prompt, stop, approve, deny, regenerate} = options
481
+ const {sink = 'LLM', form, prompt, stop, approve, deny, regenerate, clientTools, ask} = options
252
482
  const on = (DOM: any, sel: any, ev = 'click', o?: any) => DOM.select(sel).events(ev, o)
253
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}` }
254
508
  const b = defineBehavior({
255
509
  initialState: {messages: [], prompt: '', draft: '', draftReasoning: '', status: 'ready', pending: null, error: null},
256
510
  intent: (so: any) => {
257
- const {DOM} = so
511
+ const {DOM, CHILD} = so
258
512
  // SYG442: an app made before the first chat() call has no layer: no tools, no loop
259
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 || []
260
519
  return {
261
520
  ...(prompt && {PROMPT: on(DOM, prompt, 'input').map((ev: any) => ev.target.value)}),
262
521
  ...(form && {SEND: on(DOM, form, 'submit', {preventDefault: true})}),
@@ -264,70 +523,92 @@ export const chat = (options: any = {}): any => {
264
523
  ...(approve && {APPROVE: on(DOM, approve)}),
265
524
  ...(deny && {DENY: on(DOM, deny)}),
266
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)}),
267
530
  }
268
531
  },
269
532
  model: {
270
533
  PROMPT: (s: any, p: any) => typeof p == 'string' && p !== s.prompt ? {...s, prompt: p} : ABORT,
271
534
  SEND: {
272
535
  STATE: (s: any, d: any) => sendStep(s, d) || ABORT,
273
- [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 },
274
537
  },
275
538
  REGENERATE: {
276
539
  STATE: (s: any) => regenStep(s, 0) || ABORT,
277
- [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 },
278
541
  },
279
542
  // G-627: the reply's text and reasoning so far
280
543
  DELTA: (s: any, d: any) => ({...s, draft: d?.text ?? s.draft, draftReasoning: d?.reasoning ?? s.draftReasoning, status: 'streaming'}),
281
544
  REPLY: {
282
- STATE: (s: any, d: any, _n: any, p: any, o: any, k: string) => replyStep(s, d, E(p, k, o))?.s || ABORT,
283
- // 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
284
549
  EFFECT: (s: any, d: any, next: any, p: any, o: any, k: string) => {
285
- const e = E(p, k, o), r = replyStep(s, d, e)
286
- 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
287
554
  const done = {message: r.msg, text: d.text, finishReason: d.finishReason, usage: d.usage, steps: e?.steps ?? 1}
288
555
  if (r.calls) void runTools(e!, r.calls)
556
+ else if (r.wait || r.send) return
289
557
  else if (e) dispatch(e, 'DONE', done, 'next')
290
558
  else next('DONE', done, 0)
291
559
  },
292
560
  },
293
561
  RESULTS: {
294
- STATE: (s: any, d: any, _n: any, p: any, o: any, k: string) => resultsStep(s, d, E(p, k, o))?.s || ABORT,
295
- [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 },
296
564
  },
297
565
  ASK: (s: any, info: any) => ({...s, pending: info}),
298
566
  // a consequential app call (A-1's confirm) or a server approval (G-628: answerStep)
299
567
  APPROVE: {
300
568
  STATE: (s: any) => answerStep(s, true)?.s || (s.pending ? {...s, pending: null} : ABORT),
301
- [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 },
302
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) },
303
571
  },
304
572
  DENY: {
305
573
  STATE: (s: any) => answerStep(s, false)?.s || (s.pending ? {...s, pending: null} : ABORT),
306
- [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 },
307
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) },
308
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,
309
581
  STOP: {
310
- // 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
311
584
  STATE: (s: any, _d: any, _n: any, p: any, o: any, k: string) => {
312
- if (!busy(s)) return ABORT
313
- 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)
314
- const partial = [...(s.draftReasoning ? [{type: 'reasoning', text: s.draftReasoning}] : []), ...(s.draft ? [{type: 'text', text: s.draft}] : [])]
315
- const last = lastOf(msgs)
316
- return {
317
- ...s, draft: '', draftReasoning: '', status: 'ready', pending: null,
318
- 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}],
319
- }
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}
320
592
  },
321
- [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}),
322
594
  // the turn moves on: a waiting confirm is declined, tool results still to come are dropped
323
- 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) } },
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() } },
324
596
  },
325
597
  FAILED: {
326
- 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')}),
327
- 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) } },
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')}),
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() } },
328
600
  },
329
601
  // (REPLY already set ready: a host entry gets the finished state)
330
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
+ }),
331
612
  },
332
613
  })(options)
333
614
  // the `prompt` option is a selector, not the slice's start value (defineBehavior's option override)