modelmix 4.7.4 → 5.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.
@@ -1,13 +1,12 @@
1
1
  const { expect } = require('chai');
2
2
  const sinon = require('sinon');
3
3
  const nock = require('nock');
4
- const fs = require('fs');
5
4
  const path = require('path');
6
5
  const { ModelMix } = require('../index.js');
7
6
 
8
- describe('Template and File Operations Tests', () => {
9
-
10
- // Setup test hooks
7
+ describe('EJS Template and File Operations Tests', () => {
8
+ const fixturesPath = path.join(__dirname, 'fixtures');
9
+
11
10
  if (global.setupTestHooks) {
12
11
  global.setupTestHooks();
13
12
  }
@@ -17,367 +16,558 @@ describe('Template and File Operations Tests', () => {
17
16
  sinon.restore();
18
17
  });
19
18
 
20
- describe('Template Replacement', () => {
21
- let model;
19
+ function mockOpenAI(assertRequest, responseText = 'Template processed successfully') {
20
+ nock('https://api.openai.com')
21
+ .post('/v1/responses')
22
+ .reply(function (uri, body) {
23
+ assertRequest(body);
24
+ return [200, testUtils.createMockResponse('openai-responses', responseText)];
25
+ });
26
+ }
22
27
 
23
- beforeEach(() => {
24
- model = ModelMix.new({
25
- config: { debug: false }
28
+ function userTexts(body) {
29
+ return body.input
30
+ .filter(message => message.role === 'user')
31
+ .flatMap(message => message.content)
32
+ .filter(content => content.type === 'input_text')
33
+ .map(content => content.text);
34
+ }
35
+
36
+ describe('EJS rendering', () => {
37
+ it('renders inline variables with plain data keys', async () => {
38
+ const model = ModelMix.new()
39
+ .gpt51()
40
+ .replace({ name: 'Alice', age: 30, city: 'New York' })
41
+ .addText('Hello <%- name %>, you are <%- age %> years old and live in <%- city %>.');
42
+
43
+ mockOpenAI(body => {
44
+ expect(userTexts(body)).to.deep.equal([
45
+ 'Hello Alice, you are 30 years old and live in New York.'
46
+ ]);
26
47
  });
48
+
49
+ await model.message();
27
50
  });
28
51
 
29
- it('should replace simple template variables', async () => {
30
- model.gpt51()
52
+ it('supports nested data, conditionals, and loops', async () => {
53
+ const model = ModelMix.new()
54
+ .gpt51()
31
55
  .replace({
32
- '{{name}}': 'Alice',
33
- '{{age}}': '30',
34
- '{{city}}': 'New York'
56
+ user: {
57
+ name: 'Charlie',
58
+ active: true,
59
+ roles: ['admin', 'reviewer']
60
+ }
35
61
  })
36
- .addText('Hello {{name}}, you are {{age}} years old and live in {{city}}.');
62
+ .addText('<% if (user.active) { %><%- user.name %>: <% user.roles.forEach((role, index) => { %><%= index ? ", " : "" %><%- role %><% }) %><% } %>');
37
63
 
38
- nock('https://api.openai.com')
39
- .post('/v1/responses')
40
- .reply(function (uri, body) {
41
- const userMsg = body.input.find(m => m.role === 'user');
42
- expect(userMsg.content[0].text).to.equal('Hello Alice, you are 30 years old and live in New York.');
43
- return [200, testUtils.createMockResponse('openai-responses', 'Template processed successfully')];
44
- });
64
+ mockOpenAI(body => {
65
+ expect(userTexts(body)).to.deep.equal(['Charlie: admin, reviewer']);
66
+ });
45
67
 
46
- const response = await model.message();
47
- expect(response).to.include('Template processed successfully');
68
+ await model.message();
48
69
  });
49
70
 
50
- it('should handle multiple template replacements', async () => {
51
- model.gpt51()
52
- .replace({ '{{greeting}}': 'Hello' })
53
- .replace({ '{{name}}': 'Bob' })
54
- .replace({ '{{action}}': 'welcome' })
55
- .addText('{{greeting}} {{name}}, {{action}} to our platform!');
56
-
57
- nock('https://api.openai.com')
58
- .post('/v1/responses')
59
- .reply(function (uri, body) {
60
- const userMsg = body.input.find(m => m.role === 'user');
61
- expect(userMsg.content[0].text).to.equal('Hello Bob, welcome to our platform!');
62
- return [200, testUtils.createMockResponse('openai-responses', 'Multiple templates replaced')];
63
- });
71
+ it('keeps raw and XML-escaped output distinct', async () => {
72
+ const value = 'Hello & "World" <test>';
73
+ const model = ModelMix.new()
74
+ .gpt51()
75
+ .replace({ value })
76
+ .addText('Escaped: <%= value %>\nRaw: <%- value %>');
77
+
78
+ mockOpenAI(body => {
79
+ expect(userTexts(body)).to.deep.equal([
80
+ 'Escaped: Hello &amp; &#34;World&#34; &lt;test&gt;\nRaw: Hello & "World" <test>'
81
+ ]);
82
+ });
64
83
 
65
- const response = await model.message();
66
- expect(response).to.include('Multiple templates replaced');
84
+ await model.message();
67
85
  });
68
86
 
69
- it('should handle nested template objects', async () => {
70
- model.gpt51()
71
- .replace({
72
- '{{user_name}}': 'Charlie',
73
- '{{user_role}}': 'admin',
74
- '{{company_name}}': 'TechCorp',
75
- '{{company_domain}}': 'techcorp.com'
76
- })
77
- .addText('User {{user_name}} with role {{user_role}} works at {{company_name}} ({{company_domain}})');
87
+ it('does not execute EJS received through template data', async () => {
88
+ const model = ModelMix.new()
89
+ .gpt51()
90
+ .replace({ payload: '<%- secret %>', secret: 'must-not-render' })
91
+ .addText('Payload: <%- payload %>');
78
92
 
79
- nock('https://api.openai.com')
80
- .post('/v1/responses')
81
- .reply(function (uri, body) {
82
- const userMsg = body.input.find(m => m.role === 'user');
83
- expect(userMsg.content[0].text).to.equal('User Charlie with role admin works at TechCorp (techcorp.com)');
84
- return [200, testUtils.createMockResponse('openai-responses', 'Nested templates working')];
85
- });
93
+ mockOpenAI(body => {
94
+ expect(userTexts(body)).to.deep.equal(['Payload: <%- secret %>']);
95
+ });
86
96
 
87
- const response = await model.message();
88
- expect(response).to.include('Nested templates working');
97
+ await model.message();
89
98
  });
90
99
 
91
- it('should preserve unreplaced templates', async () => {
92
- model.gpt51()
93
- .replace({ '{{name}}': 'David' })
94
- .addText('Hello {{name}}, your ID is {{user_id}} and status is {{status}}');
100
+ it('selects uniformly when choice options omit weights', async () => {
101
+ const model = ModelMix.new()
102
+ .gpt51()
103
+ .addText(`<% choice %>
104
+ <% option %>
105
+ Use emojis.
106
+ <% option %>
107
+ Use few emojis.
108
+ <% option %>
109
+ Do not use emojis.
110
+ <% /choice %>`);
111
+ sinon.stub(model, '_choiceRandom').returns(0.5);
112
+
113
+ mockOpenAI(body => {
114
+ expect(userTexts(body)[0].trim()).to.equal('Use few emojis.');
115
+ });
116
+
117
+ await model.message();
118
+ });
95
119
 
96
- nock('https://api.openai.com')
97
- .post('/v1/responses')
98
- .reply(function (uri, body) {
99
- const userMsg = body.input.find(m => m.role === 'user');
100
- expect(userMsg.content[0].text).to.equal('Hello David, your ID is {{user_id}} and status is {{status}}');
101
- return [200, testUtils.createMockResponse('openai-responses', 'Partial template replacement')];
102
- });
120
+ it('selects weighted options using relative weights', async () => {
121
+ const model = ModelMix.new()
122
+ .gpt51()
123
+ .replace({ language: 'Spanish' })
124
+ .addText(`<% choice %>
125
+ <% option 20 %>
126
+ Use emojis in <%- language %>.
127
+ <% option 40 %>
128
+ Use few emojis in <%- language %>.
129
+ <% option 40 %>
130
+ Do not use emojis in <%- language %>.
131
+ <% /choice %>`);
132
+ sinon.stub(model, '_choiceRandom').returns(0.2);
133
+
134
+ mockOpenAI(body => {
135
+ expect(userTexts(body)[0].trim()).to.equal('Use few emojis in Spanish.');
136
+ });
103
137
 
104
- const response = await model.message();
105
- expect(response).to.include('Partial template replacement');
138
+ await model.message();
106
139
  });
107
140
 
108
- it('should handle empty and special character replacements', async () => {
109
- model.gpt51()
110
- .replace({
111
- '{{empty}}': '',
112
- '{{special}}': 'Hello & "World" <test>',
113
- '{{number}}': '42',
114
- '{{boolean}}': 'true'
115
- })
116
- .addText('Empty: {{empty}}, Special: {{special}}, Number: {{number}}, Boolean: {{boolean}}');
141
+ it('supports nested choices', async () => {
142
+ const model = ModelMix.new()
143
+ .gpt51()
144
+ .addText(`<% choice %>
145
+ <% option %>
146
+ Tone:
147
+ <% choice %>
148
+ <% option %>
149
+ formal
150
+ <% option %>
151
+ casual
152
+ <% /choice %>
153
+ <% option %>
154
+ No tone instruction.
155
+ <% /choice %>`);
156
+ const random = sinon.stub(model, '_choiceRandom');
157
+ random.onFirstCall().returns(0.1);
158
+ random.onSecondCall().returns(0.9);
159
+
160
+ mockOpenAI(body => {
161
+ expect(userTexts(body)[0].trim()).to.equal('Tone:\ncasual');
162
+ });
117
163
 
118
- nock('https://api.openai.com')
119
- .post('/v1/responses')
120
- .reply(function (uri, body) {
121
- const userMsg = body.input.find(m => m.role === 'user');
122
- expect(userMsg.content[0].text).to.equal('Empty: , Special: Hello & "World" <test>, Number: 42, Boolean: true');
123
- return [200, testUtils.createMockResponse('openai-responses', 'Special characters handled')];
124
- });
164
+ await model.message();
165
+ expect(random.callCount).to.equal(2);
166
+ });
125
167
 
126
- const response = await model.message();
127
- expect(response).to.include('Special characters handled');
168
+ it('rerolls choices on each new request', async () => {
169
+ const template = `<% choice %>
170
+ <% option %>
171
+ first
172
+ <% option %>
173
+ second
174
+ <% /choice %>`;
175
+ const model = ModelMix.new().gpt51().addText(template);
176
+ const random = sinon.stub(model, '_choiceRandom');
177
+ random.onFirstCall().returns(0.1);
178
+ random.onSecondCall().returns(0.9);
179
+
180
+ mockOpenAI(body => {
181
+ expect(userTexts(body)[0].trim()).to.equal('first');
182
+ }, 'First response');
183
+ await model.message();
184
+
185
+ model.addText(template);
186
+ mockOpenAI(body => {
187
+ expect(userTexts(body)[0].trim()).to.equal('second');
188
+ }, 'Second response');
189
+ await model.message();
190
+
191
+ expect(random.callCount).to.equal(2);
128
192
  });
129
- });
130
193
 
131
- describe('File Operations', () => {
132
- let model;
133
- const fixturesPath = path.join(__dirname, 'fixtures');
194
+ it('fails before the request when a variable is missing', async () => {
195
+ const model = ModelMix.new()
196
+ .gpt51()
197
+ .replace({ name: 'David' })
198
+ .addText('Hello <%- name %>, status: <%- status %>');
199
+
200
+ let error;
201
+ try {
202
+ await model.message();
203
+ } catch (caught) {
204
+ error = caught;
205
+ }
206
+
207
+ expect(error).to.be.instanceOf(Error);
208
+ expect(error.message).to.include('Failed to render message template');
209
+ expect(error.message).to.include('status is not defined');
210
+ });
134
211
 
135
- beforeEach(() => {
136
- model = ModelMix.new({
137
- config: { debug: false }
138
- });
212
+ it('rejects invalid template data immediately', () => {
213
+ const model = ModelMix.new().gpt51();
214
+
215
+ expect(() => model.replace(null)).to.throw(TypeError, 'Template data must be a plain non-null object.');
216
+ expect(() => model.replace(undefined)).to.throw(TypeError, 'Template data must be a plain non-null object.');
217
+ expect(() => model.replace([])).to.throw(TypeError, 'Template data must be a plain non-null object.');
218
+ expect(() => ModelMix.new({ config: { replace: null } })).to.throw(
219
+ TypeError,
220
+ 'Template data must be a plain non-null object.'
221
+ );
222
+ expect(() => model.replace({ $mix: 'reserved' })).to.throw(
223
+ TypeError,
224
+ 'Template data key "$mix" is reserved.'
225
+ );
139
226
  });
140
227
 
141
- it('should load and replace from template file', async () => {
142
- model.gpt51()
143
- .replaceKeyFromFile('{{template}}', path.join(fixturesPath, 'template.txt'))
144
- .replace({
145
- '{{name}}': 'Eve',
146
- '{{platform}}': 'ModelMix',
147
- '{{username}}': 'eve_user',
148
- '{{role}}': 'developer',
149
- '{{created_date}}': '2023-12-01',
150
- '{{website}}': 'https://modelmix.dev',
151
- '{{company}}': 'AI Solutions'
152
- })
153
- .addText('Process this template: {{template}}');
228
+ it('reports malformed choice directives with their source line', async () => {
229
+ const cases = [
230
+ {
231
+ source: '<% choice %>\n<% option %>\none\n<% option 2 %>\ntwo\n<% /choice %>',
232
+ message: 'Choice options must either all have weights or all omit them',
233
+ line: 4
234
+ },
235
+ {
236
+ source: '<% choice %>\n<% option 0 %>\none\n<% /choice %>',
237
+ message: 'Choice weight must be a positive finite number',
238
+ line: 2
239
+ },
240
+ {
241
+ source: '<% option %>\none',
242
+ message: 'Option directive must be inside a choice',
243
+ line: 1
244
+ },
245
+ {
246
+ source: '<% choice %>\ntext\n<% option %>\none\n<% /choice %>',
247
+ message: 'Choice content must be inside an option',
248
+ line: 2
249
+ },
250
+ {
251
+ source: '<% choice %>\n<% option %>\none',
252
+ message: 'Unclosed choice directive',
253
+ line: 1
254
+ }
255
+ ];
256
+
257
+ for (const testCase of cases) {
258
+ const model = ModelMix.new().gpt51().addText(testCase.source);
259
+ let error;
260
+ try {
261
+ await model.message();
262
+ } catch (caught) {
263
+ error = caught;
264
+ }
265
+ expect(error).to.be.instanceOf(Error);
266
+ expect(error.message).to.include(testCase.message);
267
+ expect(error.message).to.include(`message template at line ${testCase.line}`);
268
+ }
269
+ });
154
270
 
155
- nock('https://api.openai.com')
156
- .post('/v1/responses')
157
- .reply(function (uri, body) {
158
- const userMsg = body.input.find(m => m.role === 'user');
159
- const content = userMsg.content[0].text;
160
- expect(content).to.include('Hello Eve, welcome to ModelMix!');
161
- expect(content).to.include('Username: eve_user');
162
- expect(content).to.include('Role: developer');
163
- expect(content).to.include('Created: 2023-12-01');
164
- expect(content).to.include('The AI Solutions Team');
165
- return [200, testUtils.createMockResponse('openai-responses', 'Template file processed')];
166
- });
271
+ it('rerolls earlier choices after a later template fails to render', async () => {
272
+ const model = ModelMix.new()
273
+ .gpt51()
274
+ .addText(`<% choice %>
275
+ <% option %>
276
+ A
277
+ <% option %>
278
+ B
279
+ <% /choice %>`)
280
+ .addText('<%- missing %>');
281
+ const random = sinon.stub(model, '_choiceRandom');
282
+ random.onFirstCall().returns(0.1);
283
+ random.onSecondCall().returns(0.9);
284
+
285
+ let error;
286
+ try {
287
+ await model.message();
288
+ } catch (caught) {
289
+ error = caught;
290
+ }
291
+ expect(error).to.be.instanceOf(Error);
292
+ expect(model.messages[0].content[0].text).to.include('<% choice %>');
293
+
294
+ model.replace({ missing: 'ready' });
295
+ mockOpenAI(body => {
296
+ const text = userTexts(body).join('\n').trim();
297
+ expect(text).to.include('B');
298
+ expect(text).to.not.include('A');
299
+ expect(text).to.include('ready');
300
+ });
301
+ await model.message();
167
302
 
168
- const response = await model.message();
169
- expect(response).to.include('Template file processed');
303
+ expect(random.callCount).to.equal(2);
170
304
  });
171
305
 
172
- it('should load and process JSON data file', async () => {
173
- model.gpt51()
174
- .replaceKeyFromFile('{{data}}', path.join(fixturesPath, 'data.json'))
175
- .addText('Process this data: {{data}}');
306
+ it('rerolls choices after a request fails', async () => {
307
+ const template = `<% choice %>
308
+ <% option %>
309
+ A
310
+ <% option %>
311
+ B
312
+ <% /choice %>`;
313
+ const model = ModelMix.new().gpt51().addText(template);
314
+ const random = sinon.stub(model, '_choiceRandom');
315
+ random.onFirstCall().returns(0.1);
316
+ random.onSecondCall().returns(0.9);
176
317
 
177
318
  nock('https://api.openai.com')
178
319
  .post('/v1/responses')
179
- .reply(function (uri, body) {
180
- const userMsg = body.input.find(m => m.role === 'user');
181
- const content = userMsg.content[0].text;
182
- expect(content).to.include('Alice Smith');
183
- expect(content).to.include('alice@example.com');
184
- expect(content).to.include('admin');
185
- expect(content).to.include('Bob Johnson');
186
- expect(content).to.include('Carol Davis');
187
- expect(content).to.include('"theme": "dark"');
188
- expect(content).to.include('"version": "1.0.0"');
189
- return [200, testUtils.createMockResponse('openai-responses', 'JSON data processed')];
190
- });
320
+ .reply(500, { error: 'temporary failure' });
321
+ let error;
322
+ try {
323
+ await model.message();
324
+ } catch (caught) {
325
+ error = caught;
326
+ }
327
+ expect(error).to.exist;
328
+ expect(model.messages[0].content[0].text).to.equal(template);
329
+
330
+ mockOpenAI(body => {
331
+ expect(userTexts(body)[0].trim()).to.equal('B');
332
+ });
333
+ await model.message();
191
334
 
192
- const response = await model.message();
193
- expect(response).to.include('JSON data processed');
335
+ expect(random.callCount).to.equal(2);
194
336
  });
195
337
 
196
- it('should handle file loading errors gracefully', async () => {
197
- model.gpt51()
198
- .replaceKeyFromFile('{{missing}}', path.join(fixturesPath, 'nonexistent.txt'))
199
- .addText('This should contain: {{missing}}');
338
+ it('does not let a failed concurrent request overwrite a successful choice', async () => {
339
+ const template = `<% choice %>
340
+ <% option %>
341
+ A
342
+ <% option %>
343
+ B
344
+ <% /choice %>`;
345
+ const model = ModelMix.new({
346
+ config: {
347
+ max_history: 10,
348
+ bottleneck: { maxConcurrent: 2, minTime: 0 }
349
+ }
350
+ }).gpt51().addText(template);
351
+ const content = model.messages[0].content[0];
352
+ const random = sinon.stub(model, '_choiceRandom');
353
+ random.onFirstCall().returns(0.1);
354
+ random.onSecondCall().returns(0.9);
200
355
 
201
356
  nock('https://api.openai.com')
202
357
  .post('/v1/responses')
203
- .reply(function (uri, body) {
204
- const userMsg = body.input.find(m => m.role === 'user');
205
- expect(userMsg.content[0].text).to.equal('This should contain: {{missing}}');
206
- return [200, testUtils.createMockResponse('openai-responses', 'File not found handled')];
207
- });
358
+ .delay(100)
359
+ .reply(500, { error: 'delayed failure' });
360
+ nock('https://api.openai.com')
361
+ .post('/v1/responses')
362
+ .reply(200, testUtils.createMockResponse('openai-responses', 'Success'));
363
+
364
+ const results = await Promise.allSettled([model.message(), model.message()]);
208
365
 
209
- const response = await model.message();
210
- expect(response).to.include('File not found handled');
366
+ expect(results.map(result => result.status)).to.deep.equal(['rejected', 'fulfilled']);
367
+ expect(content.text.trim()).to.equal('B');
368
+ expect(model.messageTemplates.has(content)).to.equal(false);
369
+ expect(random.callCount).to.equal(2);
211
370
  });
371
+ });
212
372
 
213
- it('should handle multiple file replacements', async () => {
214
- model.gpt51()
215
- .replaceKeyFromFile('{{template}}', path.join(fixturesPath, 'template.txt'))
216
- .replaceKeyFromFile('{{data}}', path.join(fixturesPath, 'data.json'))
373
+ describe('File templates and data', () => {
374
+ it('renders a file template with a relative include', async () => {
375
+ const model = ModelMix.new()
376
+ .gpt51()
217
377
  .replace({
218
- '{{name}}': 'Frank',
219
- '{{platform}}': 'TestPlatform',
220
- '{{username}}': 'frank_test',
221
- '{{role}}': 'tester',
222
- '{{created_date}}': '2023-12-15',
223
- '{{website}}': 'https://test.com',
224
- '{{company}}': 'Test Corp'
378
+ name: 'Eve',
379
+ platform: 'ModelMix',
380
+ username: 'eve_user',
381
+ role: 'developer',
382
+ createdDate: '2026-08-07',
383
+ website: 'https://modelmix.dev',
384
+ company: 'AI Solutions',
385
+ showAccount: true
225
386
  })
226
- .addText('Template: {{template}}\n\nData: {{data}}');
227
-
228
- nock('https://api.openai.com')
229
- .post('/v1/responses')
230
- .reply(function (uri, body) {
231
- const userMsg = body.input.find(m => m.role === 'user');
232
- const content = userMsg.content[0].text;
233
- expect(content).to.include('Hello Frank, welcome to TestPlatform!');
234
- expect(content).to.include('Username: frank_test');
235
- expect(content).to.include('Alice Smith');
236
- expect(content).to.include('"theme": "dark"');
237
- return [200, testUtils.createMockResponse('openai-responses', 'Multiple files processed')];
238
- });
387
+ .addTextFromFile(path.join(fixturesPath, 'template.txt'));
388
+
389
+ mockOpenAI(body => {
390
+ const content = userTexts(body)[0];
391
+ expect(content).to.include('Hello Eve, welcome to ModelMix!');
392
+ expect(content).to.include('Username: eve_user');
393
+ expect(content).to.include('Role: developer');
394
+ expect(content).to.include('Created: 2026-08-07');
395
+ expect(content).to.include('The AI Solutions Team');
396
+ });
239
397
 
240
- const response = await model.message();
241
- expect(response).to.include('Multiple files processed');
398
+ await model.message();
242
399
  });
243
400
 
244
- it('should handle relative and absolute paths', async () => {
245
- const absolutePath = path.resolve(fixturesPath, 'template.txt');
246
-
247
- model.gpt51()
248
- .replaceKeyFromFile('{{absolute}}', absolutePath)
249
- .replace({
250
- '{{name}}': 'Grace',
251
- '{{platform}}': 'AbsolutePath',
252
- '{{username}}': 'grace_abs',
253
- '{{role}}': 'admin',
254
- '{{created_date}}': '2023-12-20',
255
- '{{website}}': 'https://absolute.com',
256
- '{{company}}': 'Absolute Corp'
257
- })
258
- .addText('Absolute path content: {{absolute}}');
401
+ it('processes choice directives inside relative includes', async () => {
402
+ const model = ModelMix.new()
403
+ .gpt51()
404
+ .addTextFromFile(path.join(fixturesPath, 'choice-template.txt'));
405
+ sinon.stub(model, '_choiceRandom').returns(0.75);
259
406
 
260
- nock('https://api.openai.com')
261
- .post('/v1/responses')
262
- .reply(function (uri, body) {
263
- const userMsg = body.input.find(m => m.role === 'user');
264
- const content = userMsg.content[0].text;
265
- expect(content).to.include('Hello Grace, welcome to AbsolutePath!');
266
- expect(content).to.include('The Absolute Corp Team');
267
- return [200, testUtils.createMockResponse('openai-responses', 'Absolute path works')];
268
- });
407
+ mockOpenAI(body => {
408
+ expect(userTexts(body)[0].trim()).to.equal('Style:\nBe concise.');
409
+ });
269
410
 
270
- const response = await model.message();
271
- expect(response).to.include('Absolute path works');
411
+ await model.message();
272
412
  });
273
- });
274
413
 
275
- describe('Template and File Integration', () => {
276
- let model;
277
- const fixturesPath = path.join(__dirname, 'fixtures');
414
+ it('preserves a system template filename through new instances', async () => {
415
+ const base = ModelMix.new()
416
+ .setSystemFromFile(path.join(fixturesPath, 'system-template.txt'))
417
+ .replace({ role: 'data analyst', language: 'Spanish' });
418
+ const model = base.new().gpt51().addText('Analyze this.');
278
419
 
279
- beforeEach(() => {
280
- model = ModelMix.new({
281
- config: { debug: false }
420
+ mockOpenAI(body => {
421
+ const system = body.input.find(message => message.role === 'developer');
422
+ expect(system.content[0].text).to.include('You are a data analyst.');
423
+ expect(system.content[0].text).to.include('Always respond in Spanish.');
282
424
  });
425
+
426
+ await model.message();
283
427
  });
284
428
 
285
- it('should combine file loading with template replacement in complex scenarios', async () => {
286
- model.gpt51()
287
- .replaceKeyFromFile('{{user_data}}', path.join(fixturesPath, 'data.json'))
288
- .replace({
289
- '{{action}}': 'analyze',
290
- '{{target}}': 'user behavior patterns',
291
- '{{format}}': 'detailed report'
292
- })
293
- .addText('Please {{action}} the following {{target}} and generate a {{format}}:\n\n{{user_data}}');
429
+ it('injects file contents as raw data without recursively rendering them', async () => {
430
+ const model = ModelMix.new()
431
+ .gpt51()
432
+ .replaceKeyFromFile('templateSource', path.join(fixturesPath, 'template.txt'))
433
+ .replace({ name: 'must-not-render' })
434
+ .addText('Source:\n<%- templateSource %>');
435
+
436
+ mockOpenAI(body => {
437
+ const content = userTexts(body)[0];
438
+ expect(content).to.include('Hello <%- name %>, welcome to <%- platform %>!');
439
+ expect(content).to.not.include('Hello must-not-render');
440
+ });
294
441
 
295
- nock('https://api.openai.com')
296
- .post('/v1/responses')
297
- .reply(function (uri, body) {
298
- const userMsg = body.input.find(m => m.role === 'user');
299
- const content = userMsg.content[0].text;
300
- expect(content).to.include('Please analyze the following user behavior patterns and generate a detailed report:');
301
- expect(content).to.include('Alice Smith');
302
- expect(content).to.include('total_users');
303
- return [200, testUtils.createMockResponse('openai-responses', 'Complex template integration successful')];
304
- });
442
+ await model.message();
443
+ });
305
444
 
306
- const response = await model.message();
307
- expect(response).to.include('Complex template integration successful');
445
+ it('injects JSON file contents without XML escaping', async () => {
446
+ const model = ModelMix.new()
447
+ .gpt51()
448
+ .replaceKeyFromFile('data', path.join(fixturesPath, 'data.json'))
449
+ .addText('Process this data:\n<%- data %>');
450
+
451
+ mockOpenAI(body => {
452
+ const content = userTexts(body)[0];
453
+ expect(content).to.include('Alice Smith');
454
+ expect(content).to.include('alice@example.com');
455
+ expect(content).to.include('"theme": "dark"');
456
+ });
457
+
458
+ await model.message();
308
459
  });
309
460
 
310
- it('should handle template chains with JSON output', async () => {
311
- const schema = {
312
- summary: 'Analysis summary',
313
- user_count: 0,
314
- active_users: 0,
315
- roles: ['admin', 'user']
316
- };
461
+ it('throws immediately when a template or data file is missing', () => {
462
+ const model = ModelMix.new().gpt51();
463
+ const missingPath = path.join(fixturesPath, 'nonexistent.txt');
464
+
465
+ expect(() => model.addTextFromFile(missingPath)).to.throw(`File not found: ${missingPath}`);
466
+ expect(() => model.replaceKeyFromFile('missing', missingPath)).to.throw(`File not found: ${missingPath}`);
467
+ });
468
+ });
317
469
 
318
- model.gpt51()
319
- .replaceKeyFromFile('{{data}}', path.join(fixturesPath, 'data.json'))
320
- .replace({ '{{instruction}}': 'Count active users by role' })
321
- .addText('{{instruction}} from this data: {{data}}');
470
+ describe('Execution integration', () => {
471
+ it('renders system and message templates for JSON output', async () => {
472
+ const schema = { summary: 'Analysis summary', userCount: 0 };
473
+ const model = ModelMix.new()
474
+ .gpt51()
475
+ .setSystem('You are a <%- role %>.')
476
+ .replace({ role: 'data analyst', instruction: 'Count active users' })
477
+ .replaceKeyFromFile('data', path.join(fixturesPath, 'data.json'))
478
+ .addText('<%- instruction %> from this data: <%- data %>');
322
479
 
323
480
  nock('https://api.openai.com')
324
481
  .post('/v1/responses')
325
482
  .reply(function (uri, body) {
326
- const userMsg = body.input.find(m => m.role === 'user');
327
- expect(userMsg.content[0].text).to.include('Count active users by role');
328
- expect(userMsg.content[0].text).to.include('Alice Smith');
483
+ const system = body.input.find(message => message.role === 'developer');
484
+ expect(system.content[0].text).to.include('You are a data analyst.');
485
+ expect(system.content[0].text).to.include('Output JSON Schema');
486
+ expect(userTexts(body)[0]).to.include('Count active users');
329
487
  return [200, {
330
488
  output: [{
331
489
  type: 'message',
332
- content: [{ type: 'output_text', text: JSON.stringify({
333
- summary: 'User analysis completed',
334
- user_count: 3,
335
- active_users: 2,
336
- roles: ['admin', 'user', 'moderator']
337
- }) }]
490
+ content: [{
491
+ type: 'output_text',
492
+ text: JSON.stringify({ summary: 'Complete', userCount: 3 })
493
+ }]
338
494
  }],
339
495
  usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }
340
496
  }];
341
497
  });
342
498
 
343
499
  const result = await model.json(schema);
344
- expect(result.summary).to.equal('User analysis completed');
345
- expect(result.user_count).to.equal(3);
346
- expect(result.active_users).to.equal(2);
347
- expect(result.roles).to.deep.equal(['admin', 'user', 'moderator']);
500
+ expect(result).to.deep.equal({ summary: 'Complete', userCount: 3 });
348
501
  });
349
- });
350
502
 
351
- describe('Error Handling', () => {
352
- let model;
353
-
354
- beforeEach(() => {
355
- model = ModelMix.new({
356
- config: { debug: false }
357
- });
503
+ it('renders the system before adding block instructions', async () => {
504
+ const model = ModelMix.new()
505
+ .gpt51()
506
+ .setSystem('Act as <%- role %>.')
507
+ .replace({ role: 'reviewer' })
508
+ .addText('Review this.');
509
+
510
+ mockOpenAI(body => {
511
+ const system = body.input.find(message => message.role === 'developer');
512
+ expect(system.content[0].text).to.equal(
513
+ 'Act as reviewer.\nReturn the result of the task between triple backtick block code tags ```'
514
+ );
515
+ }, '```\napproved\n```');
516
+
517
+ expect(await model.block()).to.equal('approved');
358
518
  });
359
519
 
360
- it('should handle template replacement errors gracefully', () => {
361
- expect(() => {
362
- model.gpt51().replace(null);
363
- }).to.not.throw();
364
-
365
- expect(() => {
366
- model.gpt51().replace(undefined);
367
- }).to.not.throw();
520
+ it('keeps rendered history snapshots when template data changes', async () => {
521
+ const model = ModelMix.new({ config: { max_history: 10 } })
522
+ .gpt51()
523
+ .replace({ name: 'Alice' })
524
+ .addText('Hello <%- name %>.');
525
+
526
+ mockOpenAI(body => {
527
+ expect(userTexts(body)).to.deep.equal(['Hello Alice.']);
528
+ }, 'First response');
529
+ await model.message();
530
+
531
+ model.replace({ name: 'Bob' }).addText('Hello <%- name %>.');
532
+ mockOpenAI(body => {
533
+ expect(userTexts(body)).to.deep.equal(['Hello Alice.', 'Hello Bob.']);
534
+ }, 'Second response');
535
+ await model.message();
368
536
  });
369
537
 
370
- it('should handle file reading errors without crashing', async () => {
371
- model.gpt51()
372
- .replaceKeyFromFile('{{bad_file}}', '/path/that/does/not/exist.txt')
373
- .addText('Content: {{bad_file}}');
538
+ it('keeps a system choice stable across provider fallback', async () => {
539
+ const systems = [];
540
+ const model = ModelMix.new()
541
+ .gpt51()
542
+ .sonnet46()
543
+ .setSystem(`<% choice %>
544
+ <% option %>
545
+ First system.
546
+ <% option %>
547
+ Second system.
548
+ <% /choice %>`)
549
+ .addText('Hello');
550
+ const random = sinon.stub(model, '_choiceRandom').returns(0.9);
374
551
 
375
552
  nock('https://api.openai.com')
376
553
  .post('/v1/responses')
377
- .reply(200, testUtils.createMockResponse('openai-responses', 'Error handled gracefully'));
554
+ .reply(function (uri, body) {
555
+ systems.push(body.input.find(message => message.role === 'developer').content[0].text.trim());
556
+ return [500, { error: 'temporary failure' }];
557
+ });
558
+ nock('https://api.anthropic.com')
559
+ .post('/v1/messages')
560
+ .reply(function (uri, body) {
561
+ systems.push(body.system.trim());
562
+ return [200, {
563
+ content: [{ type: 'text', text: 'Fallback response' }],
564
+ usage: { input_tokens: 10, output_tokens: 5 }
565
+ }];
566
+ });
378
567
 
379
- const response = await model.message();
380
- expect(response).to.include('Error handled gracefully');
568
+ expect(await model.message()).to.equal('Fallback response');
569
+ expect(systems).to.deep.equal(['Second system.', 'Second system.']);
570
+ expect(random.callCount).to.equal(1);
381
571
  });
382
572
  });
383
- });
573
+ });