@tanstack-ai-browser/afm 0.0.0-stage → 0.2.1

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +33 -0
  3. package/README.md +62 -2
  4. package/dist/adapter.d.ts +96 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +490 -0
  7. package/dist/electron.d.ts +22 -0
  8. package/dist/electron.d.ts.map +1 -0
  9. package/dist/electron.js +131 -0
  10. package/dist/index.d.ts +17 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +12 -0
  13. package/dist/messages.d.ts +12 -0
  14. package/dist/messages.d.ts.map +1 -0
  15. package/dist/messages.js +29 -0
  16. package/dist/model-meta.d.ts +23 -0
  17. package/dist/model-meta.d.ts.map +1 -0
  18. package/dist/model-meta.js +20 -0
  19. package/dist/native.d.ts +163 -0
  20. package/dist/native.d.ts.map +1 -0
  21. package/dist/native.js +21 -0
  22. package/dist/schema.d.ts +17 -0
  23. package/dist/schema.d.ts.map +1 -0
  24. package/dist/schema.js +133 -0
  25. package/electron/AfmNode.swift +81 -0
  26. package/electron/afm.c +94 -0
  27. package/electron/afm.node +0 -0
  28. package/electron/afm.node.sha256 +1 -0
  29. package/electron/build.mjs +54 -0
  30. package/electron/preload.cjs +35 -0
  31. package/electron/sources-hash.mjs +40 -0
  32. package/ios/Sources/AfmCore/Bridge.swift +315 -0
  33. package/ios/Sources/AfmCore/Conversation.swift +49 -0
  34. package/ios/Sources/AfmCore/Schema.swift +90 -0
  35. package/ios/Sources/AfmPlugin/AfmPlugin.swift +89 -0
  36. package/package.json +98 -4
  37. package/src/adapter.test.ts +380 -0
  38. package/src/adapter.ts +574 -0
  39. package/src/electron.ts +180 -0
  40. package/src/index.ts +27 -0
  41. package/src/messages.ts +37 -0
  42. package/src/model-meta.ts +37 -0
  43. package/src/native-build.test.ts +13 -0
  44. package/src/native.ts +119 -0
  45. package/src/schema.test.ts +146 -0
  46. package/src/schema.ts +153 -0
package/package.json CHANGED
@@ -1,6 +1,100 @@
1
1
  {
2
2
  "name": "@tanstack-ai-browser/afm",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "TanStack AI adapter for Apple's on-device Foundation Model (AFM), through Apple's framework itself, with native tool calling: a Capacitor plugin for iOS, and serveAfm() for Electron on macOS",
5
+ "license": "MIT",
6
+ "author": "daveycodez",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/daveycodez/tanstack-ai-browser.git",
10
+ "directory": "packages/afm"
11
+ },
12
+ "homepage": "https://github.com/daveycodez/tanstack-ai-browser/tree/main/packages/afm#readme",
13
+ "bugs": "https://github.com/daveycodez/tanstack-ai-browser/issues",
14
+ "keywords": [
15
+ "tanstack-ai",
16
+ "tanstack",
17
+ "ai",
18
+ "llm",
19
+ "apple",
20
+ "afm",
21
+ "foundation-models",
22
+ "apple-intelligence",
23
+ "capacitor",
24
+ "capacitor-plugin",
25
+ "electron",
26
+ "on-device",
27
+ "local-ai"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "main": "./dist/index.js",
32
+ "module": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ "./package.json": "./package.json",
36
+ ".": {
37
+ "@tanstack-ai-browser/source": "./src/index.ts",
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js",
40
+ "default": "./dist/index.js"
41
+ }
42
+ },
43
+ "files": [
44
+ "README.md",
45
+ "src",
46
+ "dist",
47
+ "ios/Sources",
48
+ "Package.swift",
49
+ "electron/afm.node",
50
+ "electron/preload.cjs",
51
+ "electron/afm.c",
52
+ "electron/AfmNode.swift",
53
+ "electron/build.mjs",
54
+ "electron/sources-hash.mjs",
55
+ "electron/afm.node.sha256",
56
+ "!**/*.tsbuildinfo"
57
+ ],
58
+ "capacitor": {
59
+ "ios": {
60
+ "src": "ios"
61
+ }
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
65
+ },
66
+ "nx": {
67
+ "name": "afm",
68
+ "targets": {
69
+ "build-native": {
70
+ "command": "node electron/build.mjs",
71
+ "options": {
72
+ "cwd": "{projectRoot}"
73
+ },
74
+ "cache": true,
75
+ "inputs": [
76
+ "{projectRoot}/electron/afm.c",
77
+ "{projectRoot}/electron/AfmNode.swift",
78
+ "{projectRoot}/electron/build.mjs",
79
+ "{projectRoot}/ios/Sources/AfmCore/**/*"
80
+ ],
81
+ "outputs": [
82
+ "{projectRoot}/electron/afm.node"
83
+ ]
84
+ }
85
+ }
86
+ },
87
+ "scripts": {
88
+ "test": "node --import @swc-node/register/esm-register --test \"src/**/*.test.ts\""
89
+ },
90
+ "peerDependencies": {
91
+ "@capacitor/core": "^8.0.0",
92
+ "@tanstack/ai": "^1.2.0"
93
+ },
94
+ "devDependencies": {
95
+ "@capacitor/core": "^8.6.2",
96
+ "@tanstack/ai": "1.4.0",
97
+ "electron": "^45.3.1",
98
+ "node-api-headers": "1.9.0"
99
+ }
100
+ }
@@ -0,0 +1,380 @@
1
+ import assert from "node:assert/strict"
2
+ import { beforeEach, test } from "node:test"
3
+
4
+ import type { TextOptions } from "@tanstack/ai"
5
+
6
+ import { afmText } from "./adapter.js"
7
+ import type { AfmNative, NativeAvailability, NativeEvent } from "./native.js"
8
+
9
+ type Chunk = { type: string; [key: string]: unknown }
10
+ type Event = NativeEvent extends infer E ? (E extends unknown ? Omit<E, "sessionId" | "turn"> : never) : never
11
+
12
+ const noop = () => undefined
13
+ const logger = { request: noop, provider: noop, errors: noop, warn: noop } as unknown as TextOptions["logger"]
14
+
15
+ /** A native side that answers each `respond`, and each set of tool results, with scripted events. */
16
+ class FakeNative implements AfmNative {
17
+ calls: Array<[string, Record<string, unknown>]> = []
18
+ answer: NativeAvailability = { available: true, contextSize: 4096 }
19
+ /** Events for each `respond`, in turn. */
20
+ replies: Array<Array<Event>> = []
21
+ /** Events once a response's waiting tool calls all have results, in turn. */
22
+ resumes: Array<Array<Event>> = []
23
+ private listener: ((event: NativeEvent) => void) | undefined
24
+ private turns = new Map<string, number>()
25
+ private waiting = new Map<string, Set<string>>()
26
+
27
+ async availability() {
28
+ return this.answer
29
+ }
30
+ async addListener(_name: "event", listener: (event: NativeEvent) => void) {
31
+ this.listener = listener
32
+ return { remove: async () => undefined }
33
+ }
34
+ async createSession(options: Record<string, unknown>) {
35
+ this.calls.push(["createSession", options])
36
+ }
37
+ async prewarm(options: Record<string, unknown>) {
38
+ this.calls.push(["prewarm", options])
39
+ }
40
+ async respond(options: { sessionId: string; turn: number } & Record<string, unknown>) {
41
+ this.calls.push(["respond", options])
42
+ this.turns.set(options.sessionId, options.turn)
43
+ this.play(options.sessionId, this.replies.shift() ?? [{ type: "done", text: "" }])
44
+ }
45
+ async submitToolResult(options: { sessionId: string; callId: string; output: string }) {
46
+ this.calls.push(["submitToolResult", options])
47
+ const waiting = this.waiting.get(options.sessionId)
48
+ waiting?.delete(options.callId)
49
+ if (waiting?.size === 0) this.play(options.sessionId, this.resumes.shift() ?? [{ type: "done", text: "" }])
50
+ }
51
+ async cancel(options: Record<string, unknown>) {
52
+ this.calls.push(["cancel", options])
53
+ }
54
+ async deleteSession(options: Record<string, unknown>) {
55
+ this.calls.push(["deleteSession", options])
56
+ }
57
+ private play(sessionId: string, events: Array<Event>) {
58
+ const turn = this.turns.get(sessionId) ?? 0
59
+ setTimeout(() => {
60
+ for (const event of events) {
61
+ if (event.type === "toolCalls") this.waiting.set(sessionId, new Set(event.calls.map((c) => c.id)))
62
+ this.listener?.({ ...event, sessionId, turn } as NativeEvent)
63
+ }
64
+ }, 1)
65
+ }
66
+ named(name: string) {
67
+ return this.calls.filter(([n]) => n === name).map(([, options]) => options)
68
+ }
69
+ }
70
+
71
+ let native: FakeNative
72
+ beforeEach(() => {
73
+ native = new FakeNative()
74
+ })
75
+
76
+ const weather = {
77
+ name: "get_weather",
78
+ description: "Weather for a city",
79
+ inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }
80
+ }
81
+ const user = (content: string) => ({ role: "user", content })
82
+
83
+ const chatOptions = (extra: Record<string, unknown> = {}) =>
84
+ ({ model: "system", logger, threadId: "thread", messages: [user("Weather in Paris?")], ...extra }) as unknown as TextOptions
85
+
86
+ async function run(adapter: ReturnType<typeof afmText>, extra: Record<string, unknown> = {}) {
87
+ const chunks: Array<Chunk> = []
88
+ for await (const chunk of adapter.chatStream(chatOptions(extra))) chunks.push(chunk as Chunk)
89
+ return chunks
90
+ }
91
+ const typesOf = (chunks: Array<Chunk>) => chunks.map((c) => c.type)
92
+ const textIn = (chunks: Array<Chunk>) =>
93
+ chunks.filter((c) => c.type === "TEXT_MESSAGE_CONTENT").map((c) => c.delta).join("")
94
+
95
+ test("cached tokens pass through where the bridge counts them, none included", async () => {
96
+ const adapter = afmText({ native })
97
+ for (const cachedTokens of [0, 2890]) {
98
+ native.replies = [[{ type: "done", text: "Hi", usage: { inputTokens: 2907, cachedTokens, outputTokens: 2 } }]]
99
+ const chunks = await run(adapter, { threadId: `cached-${cachedTokens}` })
100
+ assert.deepEqual((chunks.at(-1)?.usage as Record<string, unknown>).promptTokensDetails, { cachedTokens })
101
+ }
102
+ })
103
+
104
+ test("a reply streams as text from a new session holding the instructions and tools", async () => {
105
+ native.replies = [[{ type: "delta", text: "Hello " }, { type: "delta", text: "there" }, { type: "done", text: "Hello there", usage: { inputTokens: 120, outputTokens: 4 } }]]
106
+ const adapter = afmText({ native })
107
+ const chunks = await run(adapter, { systemPrompts: ["Be brief."], tools: [weather], modelOptions: { temperature: 0.2 } })
108
+ assert.deepEqual(typesOf(chunks), ["RUN_STARTED", "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END", "RUN_FINISHED"])
109
+ assert.equal(textIn(chunks), "Hello there")
110
+ assert.equal(chunks.at(-1)?.finishReason, "stop")
111
+ assert.deepEqual((chunks.at(-1)?.usage as Record<string, unknown>).promptTokens, 120)
112
+ // With tools, the framework's cached count is wrong, and the bridge sends none.
113
+ assert.equal((chunks.at(-1)?.usage as Record<string, unknown>).promptTokensDetails, undefined)
114
+ const [session] = native.named("createSession")
115
+ // The system prompt, then when to call tools (the session has one).
116
+ assert.match(session.instructions as string, /^Be brief\.\n\nMost requests need no tool/)
117
+ assert.deepEqual(session.messages, [])
118
+ assert.deepEqual(session.tools, [
119
+ {
120
+ name: "get_weather",
121
+ description: "Weather for a city",
122
+ parameters: {
123
+ root: { kind: "object", name: "get_weather", properties: [{ name: "city", optional: false, schema: { kind: "string" } }] },
124
+ definitions: []
125
+ }
126
+ }
127
+ ])
128
+ const [respond] = native.named("respond")
129
+ assert.equal(respond.prompt, "Weather in Paris?")
130
+ assert.deepEqual(respond.options, { temperature: 0.2 })
131
+ assert.equal(native.named("deleteSession").length, 0, "kept for the thread's next step")
132
+ })
133
+
134
+ test("a tool call ends the step with tool_calls while the response waits, and the next step resumes it with the result", async () => {
135
+ native.replies = [[{ type: "toolCalls", calls: [{ id: "call-1", name: "get_weather", arguments: '{"city": "Paris"}' }] }]]
136
+ native.resumes = [[{ type: "delta", text: "Sunny in Paris." }, { type: "done", text: "Sunny in Paris." }]]
137
+ const adapter = afmText({ native })
138
+ const first = await run(adapter, { tools: [weather] })
139
+ assert.deepEqual(typesOf(first), ["RUN_STARTED", "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "RUN_FINISHED"])
140
+ const end = first.find((c) => c.type === "TOOL_CALL_END")
141
+ assert.equal(end?.toolCallId, "call-1")
142
+ assert.deepEqual(end?.input, { city: "Paris" })
143
+ assert.equal(first.at(-1)?.finishReason, "tool_calls")
144
+
145
+ const messages = [
146
+ user("Weather in Paris?"),
147
+ { role: "assistant", content: null, toolCalls: [{ id: "call-1", type: "function", function: { name: "get_weather", arguments: '{"city": "Paris"}' } }] },
148
+ { role: "tool", toolCallId: "call-1", content: '{"temperature": 21}' }
149
+ ]
150
+ const second = await run(adapter, { tools: [weather], messages })
151
+ assert.equal(textIn(second), "Sunny in Paris.")
152
+ assert.equal(second.at(-1)?.finishReason, "stop")
153
+ assert.deepEqual(native.named("submitToolResult"), [{ sessionId: native.named("createSession")[0].sessionId, callId: "call-1", output: '{"temperature": 21}' }])
154
+ assert.equal(native.named("createSession").length, 1, "the same session")
155
+ assert.equal(native.named("respond").length, 1, "the same response")
156
+ })
157
+
158
+ test("parallel calls are one step; results for only some of them start a new session from the messages", async () => {
159
+ const calls = [
160
+ { id: "a", name: "get_weather", arguments: '{"city": "Tokyo"}' },
161
+ { id: "b", name: "get_weather", arguments: '{"city": "London"}' }
162
+ ]
163
+ native.replies = [[{ type: "toolCalls", calls }], [{ type: "done", text: "Partial." }]]
164
+ const adapter = afmText({ native })
165
+ const first = await run(adapter, { tools: [weather] })
166
+ assert.equal(first.filter((c) => c.type === "TOOL_CALL_END").length, 2)
167
+ const messages = [
168
+ user("Compare Tokyo and London."),
169
+ { role: "assistant", content: null, toolCalls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.arguments } })) },
170
+ { role: "tool", toolCallId: "a", content: "20" }
171
+ ]
172
+ await run(adapter, { tools: [weather], messages })
173
+ assert.equal(native.named("submitToolResult").length, 0)
174
+ const sessions = native.named("createSession")
175
+ assert.equal(sessions.length, 2)
176
+ assert.deepEqual(sessions[1].messages, [
177
+ { role: "user", text: "Compare Tokyo and London." },
178
+ { role: "assistant", text: "", toolCalls: calls },
179
+ { role: "tool", toolCallId: "a", name: "get_weather", text: "20" }
180
+ ])
181
+ assert.equal(native.named("respond")[1].prompt, "")
182
+ })
183
+
184
+ test("a thread's next message prompts its kept session with only that message", async () => {
185
+ native.replies = [[{ type: "done", text: "It's sunny." }], [{ type: "done", text: "Yes." }]]
186
+ const adapter = afmText({ native })
187
+ await run(adapter)
188
+ await run(adapter, { messages: [user("Weather in Paris?"), { role: "assistant", content: "It's sunny." }, user("Warm?")] })
189
+ assert.equal(native.named("createSession").length, 1)
190
+ const responds = native.named("respond")
191
+ assert.equal(responds[1].prompt, "Warm?")
192
+ assert.equal(responds[1].sessionId, responds[0].sessionId)
193
+ assert.equal(responds[1].turn, 2)
194
+ })
195
+
196
+ test("an edited conversation starts a new session holding the messages, and ends the old one", async () => {
197
+ native.replies = [[{ type: "done", text: "It's sunny." }], [{ type: "done", text: "Rainy." }]]
198
+ const adapter = afmText({ native })
199
+ await run(adapter)
200
+ const old = native.named("createSession")[0].sessionId
201
+ await run(adapter, { messages: [user("Weather in Oslo?"), { role: "assistant", content: "Snowy." }, user("And Paris?")] })
202
+ const sessions = native.named("createSession")
203
+ assert.equal(sessions.length, 2)
204
+ assert.deepEqual(sessions[1].messages, [{ role: "user", text: "Weather in Oslo?" }, { role: "assistant", text: "Snowy." }])
205
+ assert.equal(native.named("respond")[1].prompt, "And Paris?")
206
+ assert.deepEqual(native.named("deleteSession"), [{ sessionId: old }])
207
+ })
208
+
209
+ test("a native error ends with RUN_ERROR and its code, and ends the session", async () => {
210
+ native.replies = [[{ type: "error", code: "guardrailViolation", message: "Blocked" }]]
211
+ const chunks = await run(afmText({ native }))
212
+ const error = chunks.at(-1)
213
+ assert.equal(error?.type, "RUN_ERROR")
214
+ assert.equal(error?.message, "Blocked")
215
+ assert.deepEqual(error?.error, { message: "Blocked", code: "guardrailViolation" })
216
+ assert.equal(native.named("deleteSession").length, 1)
217
+ })
218
+
219
+ test("an abort cancels the response and ends with RUN_ERROR (code aborted)", async () => {
220
+ native.replies = [[]]
221
+ const abortController = new AbortController()
222
+ setTimeout(() => abortController.abort(), 5)
223
+ const chunks = await run(afmText({ native }), { abortController })
224
+ assert.equal(chunks.at(-1)?.type, "RUN_ERROR")
225
+ assert.equal((chunks.at(-1)?.error as Record<string, unknown>).code, "aborted")
226
+ assert.equal(native.named("cancel").length, 1)
227
+ assert.equal(native.named("deleteSession").length, 1)
228
+ })
229
+
230
+ test("structured output: a session answers the last message held to the schema", async () => {
231
+ native.replies = [[{ type: "done", text: '{"city":"Tokyo"}' }]]
232
+ const result = await afmText({ native }).structuredOutput({
233
+ chatOptions: chatOptions(),
234
+ outputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }
235
+ } as never)
236
+ assert.deepEqual(result.data, { city: "Tokyo" })
237
+ const [respond] = native.named("respond")
238
+ assert.equal(respond.prompt, "Weather in Paris?")
239
+ assert.deepEqual((respond.schema as Record<string, unknown>).root, {
240
+ kind: "object",
241
+ name: "Answer",
242
+ properties: [{ name: "city", optional: false, schema: { kind: "string" } }]
243
+ })
244
+ assert.equal(native.named("deleteSession").length, 1)
245
+ })
246
+
247
+ test("availability: available, downloading while the model isn't ready, else unavailable", async () => {
248
+ const adapter = afmText({ native })
249
+ assert.equal(await adapter.availability(), "available")
250
+ assert.equal(await adapter.contextSize(), 4096)
251
+ native.answer = { available: false, reason: "modelNotReady" }
252
+ assert.equal(await adapter.availability(), "downloading")
253
+ native.answer = { available: false, reason: "appleIntelligenceNotEnabled" }
254
+ assert.equal(await adapter.availability(), "unavailable")
255
+ await assert.rejects(adapter.create(), /appleIntelligenceNotEnabled/)
256
+ native.availability = async () => {
257
+ throw new Error("not implemented on web")
258
+ }
259
+ assert.equal(await adapter.availability(), "unavailable")
260
+ })
261
+
262
+ test("without a native side given, window.afm (an Electron preload's) is used", async () => {
263
+ native.answer = { available: true, contextSize: 2048 }
264
+ ;(globalThis as { afm?: unknown }).afm = native
265
+ try {
266
+ assert.equal(await afmText().contextSize(), 2048)
267
+ } finally {
268
+ delete (globalThis as { afm?: unknown }).afm
269
+ }
270
+ })
271
+
272
+ const pausedCall = [{ type: "toolCalls", calls: [{ id: "call_1", name: "get_weather", arguments: '{"city":"Paris"}' }] }] as Array<Event>
273
+ const afterCall = (city = "Paris") => [
274
+ user("Weather in Paris?"),
275
+ { role: "assistant", content: "", toolCalls: [{ id: "call_1", type: "function", function: { name: "get_weather", arguments: JSON.stringify({ city }) } }] },
276
+ { role: "tool", toolCallId: "call_1", content: "Sunny" }
277
+ ]
278
+
279
+ test("with prompt cache retention none, the step with the tool's result resumes the paused response, and the session ends after", async () => {
280
+ const adapter = afmText({ native })
281
+ native.replies = [pausedCall]
282
+ native.resumes = [[{ type: "done", text: "Sunny." }]]
283
+ const first = await run(adapter, { tools: [weather], promptCache: { retention: "none" } })
284
+ assert.equal(first.at(-1)?.finishReason, "tool_calls")
285
+ const second = await run(adapter, { tools: [weather], promptCache: { retention: "none" }, messages: afterCall() })
286
+ assert.equal(textIn(second), "Sunny.")
287
+ const [session] = native.named("createSession")
288
+ assert.equal(native.named("createSession").length, 1)
289
+ assert.deepEqual(native.named("submitToolResult").map((o) => o.sessionId), [session.sessionId])
290
+ assert.ok(native.named("deleteSession").some((o) => o.sessionId === session.sessionId))
291
+ })
292
+
293
+ test("destroy() ends a paused response no thread keeps", async () => {
294
+ const adapter = afmText({ native })
295
+ native.replies = [pausedCall]
296
+ await run(adapter, { tools: [weather], promptCache: { retention: "none" } })
297
+ const [session] = native.named("createSession")
298
+ await adapter.destroy()
299
+ assert.ok(native.named("deleteSession").some((o) => o.sessionId === session.sessionId))
300
+ })
301
+
302
+ test("destroy() during a response ends its stream with an error", async () => {
303
+ const adapter = afmText({ native })
304
+ native.replies = [[]] // a response that doesn't answer on its own
305
+ const stream = adapter.chatStream(chatOptions())[Symbol.asyncIterator]()
306
+ await stream.next()
307
+ const next = stream.next()
308
+ await new Promise((resolve) => setTimeout(resolve, 10))
309
+ await adapter.destroy()
310
+ const chunk = (await next).value as Chunk
311
+ assert.equal(chunk.type, "RUN_ERROR")
312
+ assert.equal((chunk.error as { code?: string }).code, "closed")
313
+ })
314
+
315
+ test("an edited tool call in the conversation starts a new session", async () => {
316
+ const adapter = afmText({ native })
317
+ native.replies = [[{ type: "done", text: "It's sunny." }]]
318
+ await run(adapter, { tools: [weather], messages: afterCall("Paris") })
319
+ native.replies = [[{ type: "done", text: "Yes." }]]
320
+ await run(adapter, { tools: [weather], messages: [...afterCall("Tokyo"), { role: "assistant", content: "It's sunny." }, user("Sure?")] })
321
+ assert.equal(native.named("createSession").length, 2)
322
+ // The same conversation, unedited, keeps its session.
323
+ native.replies = [[{ type: "done", text: "Yes." }]]
324
+ await run(adapter, { tools: [weather], messages: [...afterCall("Tokyo"), { role: "assistant", content: "It's sunny." }, user("Sure?"), { role: "assistant", content: "Yes." }, user("Thanks")] })
325
+ assert.equal(native.named("createSession").length, 2)
326
+ })
327
+
328
+ test("a session without tools gets the system prompt alone; tool choice sets the framework's tool-calling mode", async () => {
329
+ const adapter = afmText({ native })
330
+ await run(adapter, { systemPrompts: ["Be brief."], threadId: "no-tools" })
331
+ assert.equal(native.named("createSession").at(-1)?.instructions, "Be brief.")
332
+ const respondOptions = () => (native.named("respond").at(-1)?.options ?? {}) as Record<string, unknown>
333
+ assert.equal(respondOptions().toolCallingMode, undefined)
334
+ await run(adapter, { tools: [weather], toolChoice: "required", threadId: "required" })
335
+ assert.equal(respondOptions().toolCallingMode, "required")
336
+ await run(adapter, { tools: [weather], threadId: "auto" })
337
+ assert.equal(respondOptions().toolCallingMode, undefined)
338
+ })
339
+
340
+ test("destroy() ends responses streaming in sessions no thread keeps, and structured output", async () => {
341
+ const adapter = afmText({ native })
342
+ native.replies = [[], []] // responses that don't answer on their own
343
+ const stream = adapter.chatStream(chatOptions({ promptCache: { retention: "none" } }))[Symbol.asyncIterator]()
344
+ await stream.next()
345
+ const next = stream.next()
346
+ const structured = adapter
347
+ .structuredOutput({ chatOptions: chatOptions({ threadId: "structured" }), outputSchema: { type: "object", properties: { a: { type: "string" } } } } as never)
348
+ .then(() => "resolved", (e: Error) => `rejected: ${e.message}`)
349
+ await new Promise((resolve) => setTimeout(resolve, 10))
350
+ const sessions = native.named("createSession").map((o) => o.sessionId)
351
+ assert.equal(sessions.length, 2)
352
+ await adapter.destroy()
353
+ assert.equal(((await next).value as Chunk).type, "RUN_ERROR")
354
+ assert.match(await structured, /^rejected: The session was closed/)
355
+ for (const id of sessions) assert.ok(native.named("deleteSession").some((o) => o.sessionId === id))
356
+ })
357
+
358
+ test("a step whose tool choice differs from its paused response's starts a new session instead of resuming it", async () => {
359
+ const adapter = afmText({ native })
360
+ native.replies = [pausedCall, [{ type: "done", text: "Sunny." }]]
361
+ await run(adapter, { tools: [weather], toolChoice: "required" })
362
+ await run(adapter, { tools: [weather], toolChoice: "auto", messages: afterCall() })
363
+ assert.equal(native.named("createSession").length, 2)
364
+ assert.equal(native.named("submitToolResult").length, 0)
365
+ const [first, second] = native.named("respond").map((o) => (o.options as Record<string, unknown>).toolCallingMode)
366
+ assert.equal(first, "required")
367
+ assert.equal(second, undefined)
368
+ })
369
+
370
+ test("a request already stopped starts no native work, even when the bridge would answer at once", async () => {
371
+ const adapter = afmText({ native })
372
+ native.replies = [[{ type: "done", text: "Finished anyway." }]]
373
+ const stopped = new AbortController()
374
+ stopped.abort()
375
+ const chunks = await run(adapter, { abortController: stopped })
376
+ assert.equal(chunks.at(-1)?.type, "RUN_ERROR")
377
+ assert.equal(native.named("createSession").length, 0)
378
+ assert.equal(native.named("respond").length, 0)
379
+ assert.equal(textIn(chunks), "")
380
+ })