symposium 2.4.3 → 3.0.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.
@@ -0,0 +1,52 @@
1
+ // Helpers that build canned async iterables mirroring the SDK shapes used by each provider.
2
+
3
+ export function asyncIterable(events) {
4
+ return {
5
+ [Symbol.asyncIterator]() {
6
+ let i = 0;
7
+ return {
8
+ async next() {
9
+ if (i >= events.length)
10
+ return {done: true, value: undefined};
11
+ return {done: false, value: events[i++]};
12
+ },
13
+ };
14
+ },
15
+ };
16
+ }
17
+
18
+ // OpenAI Responses API streaming object: async iterable + .finalResponse()
19
+ export function openAiResponsesStream(events, finalResponse) {
20
+ return {
21
+ ...asyncIterable(events),
22
+ async finalResponse() {
23
+ return finalResponse;
24
+ },
25
+ };
26
+ }
27
+
28
+ // Anthropic messages.stream() object: async iterable + .finalMessage()
29
+ export function anthropicMessagesStream(events, finalMessage) {
30
+ return {
31
+ ...asyncIterable(events),
32
+ async finalMessage() {
33
+ return finalMessage;
34
+ },
35
+ };
36
+ }
37
+
38
+ // A fake thread that exposes whatever Model.generate() looks at.
39
+ export function fakeThread({messages = [], state = {model: 'fake'}} = {}) {
40
+ return {messages, state};
41
+ }
42
+
43
+ // Drain a generator: collect yielded deltas and the final return value.
44
+ export async function drain(it) {
45
+ const deltas = [];
46
+ let step = await it.next();
47
+ while (!step.done) {
48
+ deltas.push(step.value);
49
+ step = await it.next();
50
+ }
51
+ return {deltas, value: step.value};
52
+ }
@@ -0,0 +1,216 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import Agent from '../Agent.js';
5
+ import Symposium from '../Symposium.js';
6
+ import Model from '../Model.js';
7
+ import Message from '../Message.js';
8
+ import Thread from '../Thread.js';
9
+ import MCPServer from '../MCPServer.js';
10
+
11
+ class FakeMCPClient {
12
+ constructor({tools = [], resources = [], resourceContents = {}} = {}) {
13
+ this._tools = tools;
14
+ this._resources = resources;
15
+ this._resourceContents = resourceContents;
16
+ this.callToolCalls = [];
17
+ this.closed = false;
18
+ }
19
+
20
+ async listTools() {
21
+ return {tools: this._tools};
22
+ }
23
+
24
+ async listResources() {
25
+ return {resources: this._resources};
26
+ }
27
+
28
+ async callTool(args) {
29
+ this.callToolCalls.push(args);
30
+ const text = this._resourceContents[args.name] ?? ('ok:' + args.name);
31
+ return {content: [{type: 'text', text}]};
32
+ }
33
+
34
+ async readResource({uri}) {
35
+ const text = this._resourceContents[uri] ?? ('contents of ' + uri);
36
+ return {contents: [{uri, text}]};
37
+ }
38
+
39
+ async close() {
40
+ this.closed = true;
41
+ }
42
+ }
43
+
44
+ class FakeMCPServer extends MCPServer {
45
+ constructor(config, fakeClient) {
46
+ super(config);
47
+ this._fakeClient = fakeClient;
48
+ }
49
+
50
+ async _connect() {
51
+ return this._fakeClient;
52
+ }
53
+ }
54
+
55
+ // Minimal scripted model so the agent can be instantiated and getTools() probed
56
+ // without needing real provider SDKs.
57
+ class ScriptedModel extends Model {
58
+ constructor(label, script = []) {
59
+ super();
60
+ this.label = label;
61
+ this.script = script;
62
+ this.calls = 0;
63
+ }
64
+ async getModels() {
65
+ return new Map([[this.label, {name: this.label, tokens: 1000, tools: true, structured_output: false}]]);
66
+ }
67
+ async *generate(_model, _thread, _functions, _options) {
68
+ const turn = this.script[this.calls++];
69
+ if (!turn) throw new Error('No more scripted turns');
70
+ for (const delta of turn.deltas || []) yield delta;
71
+ return turn.messages;
72
+ }
73
+ }
74
+
75
+ async function makeAgent(label = 'fake-mcp') {
76
+ await Symposium.loadModel(new ScriptedModel(label, [{
77
+ deltas: [],
78
+ messages: [new Message('assistant', [{type: 'text', content: 'noop'}])],
79
+ }]));
80
+ const agent = new Agent();
81
+ agent.default_model = label;
82
+ await agent.init();
83
+ return agent;
84
+ }
85
+
86
+ test('MCPServer prefixes tool names with the server name', async () => {
87
+ const agent = await makeAgent('fake-mcp-prefix');
88
+ const client = new FakeMCPClient({
89
+ tools: [
90
+ {name: 'search', description: 'search the repo', inputSchema: {type: 'object', properties: {q: {type: 'string'}}}},
91
+ {name: 'read_file', description: 'read a file', inputSchema: {type: 'object', properties: {path: {type: 'string'}}}},
92
+ ],
93
+ });
94
+ const server = new FakeMCPServer({name: 'github', transport: 'stdio', command: 'noop'}, client);
95
+ await agent.addToolkit(server);
96
+
97
+ const fns = await server.getTools();
98
+ const names = fns.map(f => f.name).sort();
99
+ assert.deepEqual(names, ['github__read_file', 'github__search']);
100
+
101
+ const search = fns.find(f => f.name === 'github__search');
102
+ assert.equal(search.description, 'search the repo');
103
+ assert.deepEqual(search.parameters, {type: 'object', properties: {q: {type: 'string'}}});
104
+ });
105
+
106
+ test('two MCPServers with colliding raw tool names coexist via prefixing', async () => {
107
+ const agent = await makeAgent('fake-mcp-collide');
108
+
109
+ const ghClient = new FakeMCPClient({tools: [{name: 'search', description: 'gh search'}]});
110
+ const fsClient = new FakeMCPClient({tools: [{name: 'search', description: 'fs search'}]});
111
+
112
+ const gh = new FakeMCPServer({name: 'github', transport: 'stdio', command: 'noop'}, ghClient);
113
+ const fs = new FakeMCPServer({name: 'fs', transport: 'stdio', command: 'noop'}, fsClient);
114
+
115
+ await agent.addToolkit(gh);
116
+ await agent.addToolkit(fs);
117
+
118
+ const tools = await agent.getTools(false);
119
+ assert.ok(tools.has('github__search'));
120
+ assert.ok(tools.has('fs__search'));
121
+
122
+ const thread = new Thread('test-collide', agent);
123
+ thread.state = {model: 'fake-mcp-collide'};
124
+
125
+ await tools.get('github__search').toolkit.callTool(thread, 'github__search', {q: 'hi'});
126
+ await tools.get('fs__search').toolkit.callTool(thread, 'fs__search', {q: 'hi'});
127
+
128
+ assert.deepEqual(ghClient.callToolCalls, [{name: 'search', arguments: {q: 'hi'}}]);
129
+ assert.deepEqual(fsClient.callToolCalls, [{name: 'search', arguments: {q: 'hi'}}]);
130
+ });
131
+
132
+ test('MCPServer.callTool strips the prefix and forwards to client.callTool', async () => {
133
+ const agent = await makeAgent('fake-mcp-forward');
134
+ const client = new FakeMCPClient({tools: [{name: 'do_thing', description: '', inputSchema: {type: 'object', properties: {}}}]});
135
+ const server = new FakeMCPServer({name: 'svc', transport: 'stdio', command: 'noop'}, client);
136
+ await agent.addToolkit(server);
137
+
138
+ const thread = new Thread('test-forward', agent);
139
+ thread.state = {model: 'fake-mcp-forward'};
140
+
141
+ const result = await server.callTool(thread, 'svc__do_thing', {x: 1});
142
+
143
+ assert.deepEqual(client.callToolCalls, [{name: 'do_thing', arguments: {x: 1}}]);
144
+ assert.ok(result.content);
145
+ assert.equal(result.content[0].text, 'ok:do_thing');
146
+ });
147
+
148
+ test('MCPServer.callTool throws when MCP result has isError', async () => {
149
+ const agent = await makeAgent('fake-mcp-error');
150
+ const client = new FakeMCPClient({tools: [{name: 'broken'}]});
151
+ client.callTool = async () => ({isError: true, content: [{type: 'text', text: 'boom'}]});
152
+
153
+ const server = new FakeMCPServer({name: 'svc', transport: 'stdio', command: 'noop'}, client);
154
+ await agent.addToolkit(server);
155
+
156
+ const thread = new Thread('test-error', agent);
157
+ thread.state = {model: 'fake-mcp-error'};
158
+
159
+ await assert.rejects(
160
+ () => server.callTool(thread, 'svc__broken', {}),
161
+ /boom/,
162
+ );
163
+ });
164
+
165
+ test('addMCPServer with resources:true registers each resource as an on_request context and auto-injects get_context', async () => {
166
+ const agent = await makeAgent('fake-mcp-res');
167
+ const client = new FakeMCPClient({
168
+ tools: [{name: 'noop_tool', description: '', inputSchema: {type: 'object', properties: {}}}],
169
+ resources: [
170
+ {uri: 'mem://a', name: 'doc-a', description: 'first doc'},
171
+ {uri: 'mem://b', name: 'doc-b', description: 'second doc'},
172
+ ],
173
+ resourceContents: {'mem://a': 'AAA', 'mem://b': 'BBB'},
174
+ });
175
+
176
+ // Replicate addMCPServer manually but with the test subclass so we can inject the fake client.
177
+ const server = new FakeMCPServer({name: 'svc', transport: 'stdio', command: 'noop', resources: true}, client);
178
+ await agent.addToolkit(server);
179
+ const {default: MCPResource} = await import('../Contexts/MCPResource.js');
180
+ for (const res of await server.listResources()) {
181
+ await agent.addContext(new MCPResource(server, res), {
182
+ type: 'on_request',
183
+ description: res.description || '',
184
+ });
185
+ }
186
+
187
+ const thread = new Thread('test-res', agent);
188
+ thread.state = {model: 'fake-mcp-res'};
189
+ await agent.initThread(thread);
190
+
191
+ assert.ok(agent.toolkits.has('get_context'), 'get_context toolkit should be auto-injected');
192
+
193
+ const titles = agent.context.map(c => c.title).sort();
194
+ assert.deepEqual(titles, ['doc-a', 'doc-b']);
195
+
196
+ const docA = agent.context.find(c => c.title === 'doc-a');
197
+ assert.equal(docA.options.type, 'on_request');
198
+ assert.equal(docA.options.description, 'first doc');
199
+ assert.equal(await docA.context.getText(), 'AAA');
200
+ });
201
+
202
+ test('MCPServer.close() shuts the client down', async () => {
203
+ const agent = await makeAgent('fake-mcp-close');
204
+ const client = new FakeMCPClient({tools: [{name: 'x'}]});
205
+ const server = new FakeMCPServer({name: 'svc', transport: 'stdio', command: 'noop'}, client);
206
+ await agent.addToolkit(server);
207
+
208
+ await server.close();
209
+ assert.equal(client.closed, true);
210
+ assert.equal(server.client, null);
211
+ });
212
+
213
+ test('MCPServer constructor validates config', () => {
214
+ assert.throws(() => new MCPServer({}), /config\.name is required/);
215
+ assert.throws(() => new MCPServer(null), /config must be an object/);
216
+ });
@@ -0,0 +1,135 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import AnthropicModel from '../../Models/AnthropicModel.js';
5
+ import Message from '../../Message.js';
6
+ import {anthropicMessagesStream, fakeThread, drain} from '../helpers/mockSdk.js';
7
+
8
+ function buildModelDef(overrides = {}) {
9
+ return {
10
+ name: 'claude-haiku-4-5-20251001',
11
+ tokens: 200000,
12
+ tools: true,
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ function installFakeAnthropic(modelInstance, stream) {
18
+ modelInstance.getAnthropic = () => ({
19
+ beta: {
20
+ messages: {
21
+ stream() {
22
+ return stream;
23
+ },
24
+ },
25
+ },
26
+ });
27
+ }
28
+
29
+ test('AnthropicModel streams text deltas and assembles a text Message[]', async () => {
30
+ const m = new AnthropicModel();
31
+ const finalMessage = {
32
+ content: [{type: 'text', text: 'Hello world'}],
33
+ };
34
+ const stream = anthropicMessagesStream(
35
+ [
36
+ {type: 'content_block_start', index: 0, content_block: {type: 'text'}},
37
+ {type: 'content_block_delta', index: 0, delta: {type: 'text_delta', text: 'Hello'}},
38
+ {type: 'content_block_delta', index: 0, delta: {type: 'text_delta', text: ' world'}},
39
+ {type: 'content_block_stop', index: 0},
40
+ ],
41
+ finalMessage,
42
+ );
43
+ installFakeAnthropic(m, stream);
44
+
45
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
46
+
47
+ assert.deepEqual(deltas, [
48
+ {type: 'text_delta', content: 'Hello'},
49
+ {type: 'text_delta', content: ' world'},
50
+ ]);
51
+
52
+ assert.equal(value.length, 1);
53
+ assert.ok(value[0] instanceof Message);
54
+ assert.deepEqual(value[0].content, [{type: 'text', content: 'Hello world'}]);
55
+ });
56
+
57
+ test('AnthropicModel buffers input_json_delta and yields a complete tool_call', async () => {
58
+ const m = new AnthropicModel();
59
+ const finalMessage = {
60
+ content: [
61
+ {
62
+ type: 'tool_use',
63
+ id: 'tool_1',
64
+ name: 'do_thing',
65
+ input: {a: 1, b: 'x'},
66
+ },
67
+ ],
68
+ };
69
+ const stream = anthropicMessagesStream(
70
+ [
71
+ {
72
+ type: 'content_block_start',
73
+ index: 0,
74
+ content_block: {type: 'tool_use', id: 'tool_1', name: 'do_thing'},
75
+ },
76
+ {type: 'content_block_delta', index: 0, delta: {type: 'input_json_delta', partial_json: '{"a":1,'}},
77
+ {type: 'content_block_delta', index: 0, delta: {type: 'input_json_delta', partial_json: '"b":"x"}'}},
78
+ {type: 'content_block_stop', index: 0},
79
+ ],
80
+ finalMessage,
81
+ );
82
+ installFakeAnthropic(m, stream);
83
+
84
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
85
+
86
+ assert.deepEqual(deltas, [
87
+ {
88
+ type: 'tool_call',
89
+ content: {id: 'tool_1', name: 'do_thing', arguments: {a: 1, b: 'x'}},
90
+ },
91
+ ]);
92
+
93
+ assert.deepEqual(value[0].content, [
94
+ {
95
+ type: 'tool_call',
96
+ content: [{id: 'tool_1', name: 'do_thing', arguments: {a: 1, b: 'x'}}],
97
+ },
98
+ ]);
99
+ });
100
+
101
+ test('AnthropicModel emits reasoning_delta from thinking_delta', async () => {
102
+ const m = new AnthropicModel();
103
+ const thinkingBlock = {type: 'thinking', thinking: 'reasoning text'};
104
+ const finalMessage = {
105
+ content: [thinkingBlock, {type: 'text', text: 'done'}],
106
+ };
107
+ const stream = anthropicMessagesStream(
108
+ [
109
+ {type: 'content_block_start', index: 0, content_block: {type: 'thinking'}},
110
+ {type: 'content_block_delta', index: 0, delta: {type: 'thinking_delta', thinking: 'reasoning '}},
111
+ {type: 'content_block_delta', index: 0, delta: {type: 'thinking_delta', thinking: 'text'}},
112
+ {type: 'content_block_stop', index: 0},
113
+ {type: 'content_block_start', index: 1, content_block: {type: 'text'}},
114
+ {type: 'content_block_delta', index: 1, delta: {type: 'text_delta', text: 'done'}},
115
+ {type: 'content_block_stop', index: 1},
116
+ ],
117
+ finalMessage,
118
+ );
119
+ installFakeAnthropic(m, stream);
120
+
121
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
122
+
123
+ assert.deepEqual(deltas, [
124
+ {type: 'reasoning_delta', content: 'reasoning '},
125
+ {type: 'reasoning_delta', content: 'text'},
126
+ {type: 'text_delta', content: 'done'},
127
+ ]);
128
+
129
+ assert.deepEqual(value[0].content[0], {
130
+ type: 'reasoning',
131
+ content: 'reasoning text',
132
+ original: thinkingBlock,
133
+ });
134
+ assert.deepEqual(value[0].content[1], {type: 'text', content: 'done'});
135
+ });
@@ -0,0 +1,71 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import GroqModel from '../../Models/GroqModel.js';
5
+ import Message from '../../Message.js';
6
+ import {asyncIterable, fakeThread, drain} from '../helpers/mockSdk.js';
7
+
8
+ function buildModelDef() {
9
+ return {
10
+ name: 'llama-3.3-70b-versatile',
11
+ tokens: 128000,
12
+ tools: true,
13
+ };
14
+ }
15
+
16
+ function installFakeGroq(modelInstance, chunks) {
17
+ modelInstance.getGroq = () => ({
18
+ chat: {
19
+ completions: {
20
+ async create(_payload) {
21
+ return asyncIterable(chunks);
22
+ },
23
+ },
24
+ },
25
+ });
26
+ }
27
+
28
+ test('GroqModel streams text chunks and assembles a text Message[]', async () => {
29
+ const m = new GroqModel();
30
+ installFakeGroq(m, [
31
+ {choices: [{delta: {content: 'Hi'}}]},
32
+ {choices: [{delta: {content: ' there'}}]},
33
+ {choices: [{delta: {}, finish_reason: 'stop'}]},
34
+ ]);
35
+
36
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
37
+
38
+ assert.deepEqual(deltas, [
39
+ {type: 'text_delta', content: 'Hi'},
40
+ {type: 'text_delta', content: ' there'},
41
+ ]);
42
+
43
+ assert.equal(value.length, 1);
44
+ assert.ok(value[0] instanceof Message);
45
+ assert.deepEqual(value[0].content, [{type: 'text', content: 'Hi there'}]);
46
+ });
47
+
48
+ test('GroqModel accumulates tool_call deltas across chunks', async () => {
49
+ const m = new GroqModel();
50
+ installFakeGroq(m, [
51
+ {choices: [{delta: {tool_calls: [{index: 0, id: 'c1', type: 'function', function: {name: 'sum', arguments: '{"x":'}}]}}]},
52
+ {choices: [{delta: {tool_calls: [{index: 0, function: {arguments: '5}'}}]}}]},
53
+ {choices: [{delta: {}, finish_reason: 'tool_calls'}]},
54
+ ]);
55
+
56
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
57
+
58
+ assert.deepEqual(deltas, [
59
+ {
60
+ type: 'tool_call',
61
+ content: {id: 'c1', name: 'sum', arguments: {x: 5}},
62
+ },
63
+ ]);
64
+
65
+ assert.deepEqual(value[0].content, [
66
+ {
67
+ type: 'tool_call',
68
+ content: [{id: 'c1', name: 'sum', arguments: {x: 5}}],
69
+ },
70
+ ]);
71
+ });
@@ -0,0 +1,87 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import LegacyOpenAIModel from '../../Models/LegacyOpenAIModel.js';
5
+ import Message from '../../Message.js';
6
+ import {asyncIterable, fakeThread, drain} from '../helpers/mockSdk.js';
7
+
8
+ function buildModelDef() {
9
+ return {
10
+ name: 'gpt-3.5-turbo',
11
+ tokens: 16000,
12
+ tools: true,
13
+ };
14
+ }
15
+
16
+ function installFakeOpenAi(modelInstance, chunks) {
17
+ modelInstance.getOpenAi = () => ({
18
+ chat: {
19
+ completions: {
20
+ async create(_payload) {
21
+ return asyncIterable(chunks);
22
+ },
23
+ },
24
+ },
25
+ });
26
+ }
27
+
28
+ test('LegacyOpenAIModel streams text chunks and assembles a text Message[]', async () => {
29
+ const m = new LegacyOpenAIModel();
30
+ installFakeOpenAi(m, [
31
+ {choices: [{delta: {content: 'Hello'}}]},
32
+ {choices: [{delta: {content: ' world'}}]},
33
+ {choices: [{delta: {}, finish_reason: 'stop'}]},
34
+ ]);
35
+
36
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
37
+
38
+ assert.deepEqual(deltas, [
39
+ {type: 'text_delta', content: 'Hello'},
40
+ {type: 'text_delta', content: ' world'},
41
+ ]);
42
+
43
+ assert.equal(value.length, 1);
44
+ assert.ok(value[0] instanceof Message);
45
+ assert.deepEqual(value[0].content, [{type: 'text', content: 'Hello world'}]);
46
+ });
47
+
48
+ test('LegacyOpenAIModel accumulates tool_call deltas across chunks and yields a complete tool_call', async () => {
49
+ const m = new LegacyOpenAIModel();
50
+ installFakeOpenAi(m, [
51
+ {choices: [{delta: {tool_calls: [{index: 0, id: 'call_xyz', type: 'function', function: {name: 'do_thing', arguments: '{"a":'}}]}}]},
52
+ {choices: [{delta: {tool_calls: [{index: 0, function: {arguments: '1}'}}]}}]},
53
+ {choices: [{delta: {}, finish_reason: 'tool_calls'}]},
54
+ ]);
55
+
56
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
57
+
58
+ assert.deepEqual(deltas, [
59
+ {
60
+ type: 'tool_call',
61
+ content: {id: 'call_xyz', name: 'do_thing', arguments: {a: 1}},
62
+ },
63
+ ]);
64
+
65
+ assert.deepEqual(value[0].content, [
66
+ {
67
+ type: 'tool_call',
68
+ content: [{id: 'call_xyz', name: 'do_thing', arguments: {a: 1}}],
69
+ },
70
+ ]);
71
+ });
72
+
73
+ test('LegacyOpenAIModel combines text + tool_calls in one Message', async () => {
74
+ const m = new LegacyOpenAIModel();
75
+ installFakeOpenAi(m, [
76
+ {choices: [{delta: {content: 'Calling now'}}]},
77
+ {choices: [{delta: {tool_calls: [{index: 0, id: 'c1', type: 'function', function: {name: 'f', arguments: '{}'}}]}}]},
78
+ {choices: [{delta: {}, finish_reason: 'tool_calls'}]},
79
+ ]);
80
+
81
+ const {value} = await drain(m.generate(buildModelDef(), fakeThread()));
82
+
83
+ assert.equal(value[0].content.length, 2);
84
+ assert.deepEqual(value[0].content[0], {type: 'text', content: 'Calling now'});
85
+ assert.equal(value[0].content[1].type, 'tool_call');
86
+ assert.equal(value[0].content[1].content[0].name, 'f');
87
+ });
@@ -0,0 +1,90 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import OllamaModel from '../../Models/OllamaModel.js';
5
+ import Message from '../../Message.js';
6
+ import {asyncIterable, fakeThread, drain} from '../helpers/mockSdk.js';
7
+
8
+ function buildModelDef() {
9
+ return {
10
+ name: 'llama3',
11
+ tools: true,
12
+ structured_output: true,
13
+ };
14
+ }
15
+
16
+ function installFakeOllama(modelInstance, chunks) {
17
+ modelInstance.getOllama = () => ({
18
+ async chat(_payload) {
19
+ return asyncIterable(chunks);
20
+ },
21
+ async list() {
22
+ return {models: []};
23
+ },
24
+ });
25
+ }
26
+
27
+ test('OllamaModel streams text + thinking chunks and assembles a Message[]', async () => {
28
+ const m = new OllamaModel();
29
+ installFakeOllama(m, [
30
+ {message: {thinking: 'reasoning '}},
31
+ {message: {thinking: 'step'}},
32
+ {message: {content: 'Hello'}},
33
+ {message: {content: ' world'}, done: true},
34
+ ]);
35
+
36
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
37
+
38
+ assert.deepEqual(deltas, [
39
+ {type: 'reasoning_delta', content: 'reasoning '},
40
+ {type: 'reasoning_delta', content: 'step'},
41
+ {type: 'text_delta', content: 'Hello'},
42
+ {type: 'text_delta', content: ' world'},
43
+ ]);
44
+
45
+ assert.equal(value.length, 1);
46
+ assert.ok(value[0] instanceof Message);
47
+ assert.deepEqual(value[0].content, [
48
+ {type: 'reasoning', content: 'reasoning step'},
49
+ {type: 'text', content: 'Hello world'},
50
+ ]);
51
+ });
52
+
53
+ test('OllamaModel yields tool_call from final chunk', async () => {
54
+ const m = new OllamaModel();
55
+ installFakeOllama(m, [
56
+ {
57
+ message: {
58
+ tool_calls: [{function: {name: 'do_thing', arguments: {a: 1}}}],
59
+ },
60
+ done: true,
61
+ },
62
+ ]);
63
+
64
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
65
+
66
+ assert.deepEqual(deltas, [
67
+ {type: 'tool_call', content: {name: 'do_thing', arguments: {a: 1}}},
68
+ ]);
69
+
70
+ assert.deepEqual(value[0].content, [
71
+ {
72
+ type: 'tool_call',
73
+ content: [{name: 'do_thing', arguments: {a: 1}}],
74
+ },
75
+ ]);
76
+ });
77
+
78
+ test('OllamaModel emulates streaming when only a single full chunk arrives', async () => {
79
+ const m = new OllamaModel();
80
+ installFakeOllama(m, [
81
+ {message: {content: 'One shot response'}, done: true},
82
+ ]);
83
+
84
+ const {deltas, value} = await drain(m.generate(buildModelDef(), fakeThread()));
85
+
86
+ assert.deepEqual(deltas, [
87
+ {type: 'text_delta', content: 'One shot response'},
88
+ ]);
89
+ assert.deepEqual(value[0].content, [{type: 'text', content: 'One shot response'}]);
90
+ });