backend-manager 5.2.8 → 5.2.9

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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.2.9] - 2026-05-25
18
+
19
+ ### Added
20
+
21
+ - **OpenAI provider: multi-role prompt array support.** `options.prompt` now accepts either the legacy object form (`{ path|content, settings }`, auto-wrapped as a single `system`-role segment per the OpenAI Model Spec) OR an array form (`[{ role, path|content, settings }, ...]`) where each segment becomes its own message with its declared role. Valid roles: `system`, `developer`, `user`, `assistant`; order is preserved; role defaults to `system` if omitted; invalid roles throw. New internal helpers `normalizePrompt()` + `VALID_PROMPT_ROLES` canonicalize input; the request pipeline threads `promptSegments` end-to-end (per-segment load, per-role logging, per-segment error surfacing, `formatHistory` unshifts segments in declared order). `module.exports._internals` exposes `normalizePrompt`, `formatHistory`, `VALID_PROMPT_ROLES` for unit tests — not part of the public API. Backwards compatible: existing callers passing `prompt: { content: '...' }` are auto-wrapped as a single `system` segment with no consumer changes.
22
+ - **`test/helpers/ai-request-payload.js`** — 16 standalone tests covering the BEM → OpenAI payload transformation with no network and no assistant. Exercises `normalizePrompt` (undefined/null/empty handling, legacy object → single system segment, array-form role preservation, role-defaulting, invalid-role throw, full OpenAI Model Spec role coverage) and `formatHistory` (single-system emit, multi-segment order, empty-array → user-only, prompt+history+new-user interleaving, assistant `output_text` typing, history limit, `dedupeConsecutiveRoles` trailing-user drop, content trim/strip).
23
+
24
+ ### Changed
25
+
26
+ - **Default OpenAI model bumped to `gpt-5.4-mini`** (was `gpt-5-mini`). Updated in `src/manager/libraries/ai/providers/openai.js` (`DEFAULT_MODEL`), `src/manager/libraries/ai/index.js` (usage example in JSDoc), and `src/manager/libraries/infer-contact.js` (`inferContactWithAI`). The pricing table in the OpenAI provider already includes `gpt-5.4-mini`, so no further config changes are required.
27
+ - **`inferContactWithAI` maxTokens doubled to 2048** (was 1024). Gives the model headroom for the richer multi-field contact response without truncation.
28
+
17
29
  # [5.2.8] - 2026-05-25
18
30
 
19
31
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.8",
3
+ "version": "5.2.9",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Usage:
5
5
  * const ai = Manager.AI(assistant);
6
- * const result = await ai.request({ provider: 'openai', model: 'gpt-5-mini', ... });
6
+ * const result = await ai.request({ provider: 'openai', model: 'gpt-5.4-mini', ... });
7
7
  * const result = await ai.request({ provider: 'anthropic', model: 'claude-sonnet-4-6', ... });
8
8
  *
9
9
  * Each provider returns { content, output, tokens, raw } with a consistent shape.
@@ -7,7 +7,7 @@ const path = require('path');
7
7
  const mimeTypes = require('mime-types');
8
8
 
9
9
  // Constants
10
- const DEFAULT_MODEL = 'gpt-5-mini';
10
+ const DEFAULT_MODEL = 'gpt-5.4-mini';
11
11
  const MODERATION_MODEL = 'omni-moderation-latest';
12
12
 
13
13
  // OpenAI model pricing table (per 1M tokens)
@@ -396,10 +396,25 @@ OpenAI.prototype.request = function (options) {
396
396
  options.reasoning = options.reasoning || undefined;
397
397
 
398
398
  // Format prompt
399
- options.prompt = options.prompt || {};
400
- options.prompt.path = options.prompt.path || '';
401
- options.prompt.content = options.prompt.content || options.prompt.content || '';
402
- options.prompt.settings = options.prompt.settings || {};
399
+ //
400
+ // Accepts two forms:
401
+ //
402
+ // 1) Object form (legacy / single-role):
403
+ // prompt: { path|content, settings }
404
+ // Auto-wrapped to a single 'system'-role segment per the OpenAI Model
405
+ // Spec — unlabeled prompts represent the platform's authoritative
406
+ // instruction.
407
+ //
408
+ // 2) Array form (multi-role):
409
+ // prompt: [
410
+ // { role: 'system', path|content, settings },
411
+ // { role: 'developer', path|content, settings },
412
+ // ...
413
+ // ]
414
+ // Each segment becomes its own message with the declared role. Order
415
+ // is preserved. Valid roles: 'system', 'developer', 'user',
416
+ // 'assistant'. Segments default to 'system' if role is omitted.
417
+ options.prompt = normalizePrompt(options.prompt);
403
418
 
404
419
  // Format message
405
420
  options.message = options.message || {};
@@ -428,19 +443,26 @@ OpenAI.prototype.request = function (options) {
428
443
  _log('Starting', options);
429
444
 
430
445
 
431
- // Load prompt
432
- const prompt = loadContent(options.prompt, _log);
446
+ // Load prompt segments (one entry per role) and the user message
447
+ const promptSegments = options.prompt.map((segment) => ({
448
+ role: segment.role,
449
+ content: loadContent(segment, _log),
450
+ }));
433
451
  const message = loadContent(options.message, _log);
434
452
  const user = options.user?.auth?.uid || assistant.request.geolocation.ip || 'unknown';
435
453
 
436
454
  // Log
437
- _log('Prompt', prompt);
455
+ for (const segment of promptSegments) {
456
+ _log(`Prompt[${segment.role}]`, segment.content);
457
+ }
438
458
  _log('Message', message);
439
459
  _log('User', user);
440
460
 
441
461
  // Check for errors
442
- if (prompt instanceof Error) {
443
- return reject(assistant.errorify(`Error loading prompt: ${prompt}`, {code: 400}));
462
+ for (const segment of promptSegments) {
463
+ if (segment.content instanceof Error) {
464
+ return reject(assistant.errorify(`Error loading prompt[${segment.role}]: ${segment.content}`, {code: 400}));
465
+ }
444
466
  }
445
467
 
446
468
  if (message instanceof Error) {
@@ -450,7 +472,7 @@ OpenAI.prototype.request = function (options) {
450
472
  // Moderate if needed
451
473
  let moderation = null;
452
474
  if (options.moderate) {
453
- moderation = await makeRequest('moderations', options, self, prompt, message, user, _log)
475
+ moderation = await makeRequest('moderations', options, self, promptSegments, message, user, _log)
454
476
  .then(async (r) => {
455
477
  // {
456
478
  // id: 'modr-8205',
@@ -481,7 +503,7 @@ OpenAI.prototype.request = function (options) {
481
503
 
482
504
 
483
505
  // Make attempt
484
- attemptRequest(options, self, prompt, message, user, moderation, attempt, assistant, resolve, reject, _log);
506
+ attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
485
507
  });
486
508
  }
487
509
 
@@ -493,6 +515,39 @@ function tryParse(content) {
493
515
  }
494
516
  }
495
517
 
518
+ // Roles permitted in the `options.prompt` array. Order is canonical per the
519
+ // OpenAI Model Spec authority hierarchy (system > developer > user > assistant).
520
+ const VALID_PROMPT_ROLES = new Set(['system', 'developer', 'user', 'assistant']);
521
+
522
+ // Normalize the `options.prompt` input into a canonical array of segments:
523
+ // [{ role, path, content, settings }, ...]
524
+ //
525
+ // Accepts:
526
+ // - undefined/null/empty → []
527
+ // - object: { path|content, settings } → wrapped as a single 'system' segment
528
+ // - array: [{ role, path|content, settings }, ...] → role defaults to 'system'
529
+ // if omitted; invalid roles throw.
530
+ function normalizePrompt(input) {
531
+ const segments = Array.isArray(input)
532
+ ? input
533
+ : (input && (input.path || input.content || input.settings)) ? [input] : [];
534
+
535
+ return segments.map((segment) => {
536
+ const role = segment.role || 'system';
537
+
538
+ if (!VALID_PROMPT_ROLES.has(role)) {
539
+ throw new Error(`Invalid prompt role: ${role}. Valid roles: ${[...VALID_PROMPT_ROLES].join(', ')}`);
540
+ }
541
+
542
+ return {
543
+ role: role,
544
+ path: segment.path || '',
545
+ content: segment.content || '',
546
+ settings: segment.settings || {},
547
+ };
548
+ });
549
+ }
550
+
496
551
  function loadContent(input, _log) {
497
552
  // console.log('*** input!!!', input.content.slice(0, 50), input.path);
498
553
  // console.log('*** input.content', input.content.slice(0, 50));
@@ -674,16 +729,20 @@ function formatMessageContent(content, attachments, _log, mode = 'responses', ro
674
729
  }
675
730
 
676
731
 
677
- function formatHistory(options, prompt, message, _log) {
732
+ function formatHistory(options, promptSegments, message, _log) {
678
733
  // Get history with respect to the message limit
679
734
  const history = options.history.messages.slice(-options.history.limit);
680
735
 
681
- // Add prompt to beginning of history
682
- history.unshift({
683
- role: 'developer',
684
- content: prompt,
685
- attachments: [],
686
- });
736
+ // Add prompt segments to the beginning of history, in the order provided
737
+ // (each segment becomes its own message with the declared role)
738
+ for (let i = promptSegments.length - 1; i >= 0; i--) {
739
+ const segment = promptSegments[i];
740
+ history.unshift({
741
+ role: segment.role,
742
+ content: segment.content,
743
+ attachments: [],
744
+ });
745
+ }
687
746
 
688
747
  // Get last history item
689
748
  const lastHistory = history[history.length - 1];
@@ -722,7 +781,7 @@ function formatHistory(options, prompt, message, _log) {
722
781
  return formatted;
723
782
  }
724
783
 
725
- function attemptRequest(options, self, prompt, message, user, moderation, attempt, assistant, resolve, reject, _log) {
784
+ function attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log) {
726
785
  const retries = options.retries;
727
786
  const triggers = options.retryTriggers;
728
787
 
@@ -733,7 +792,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
733
792
  _log(`Request ${attempt.count}/${retries}`);
734
793
 
735
794
  // Request
736
- makeRequest('responses', options, self, prompt, message, user, _log)
795
+ makeRequest('responses', options, self, promptSegments, message, user, _log)
737
796
  .then((r) => {
738
797
  // Example
739
798
  // {
@@ -834,7 +893,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
834
893
 
835
894
  // Retry
836
895
  if (attempt.count < retries && triggers.includes('parse')) {
837
- return attemptRequest(options, self, prompt, message, user, moderation, attempt, assistant, resolve, reject, _log);
896
+ return attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
838
897
  }
839
898
 
840
899
  // Return
@@ -856,7 +915,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
856
915
 
857
916
  // Retry
858
917
  if (attempt.count < retries && triggers.includes('network')) {
859
- return attemptRequest(options, self, prompt, message, user, moderation, attempt, assistant, resolve, reject, _log);
918
+ return attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
860
919
  }
861
920
 
862
921
  // Return
@@ -864,7 +923,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
864
923
  });
865
924
  }
866
925
 
867
- function makeRequest(mode, options, self, prompt, message, user, _log) {
926
+ function makeRequest(mode, options, self, promptSegments, message, user, _log) {
868
927
  return new Promise(async function(resolve, reject) {
869
928
  const request = {
870
929
  url: '',
@@ -895,7 +954,7 @@ function makeRequest(mode, options, self, prompt, message, user, _log) {
895
954
  }
896
955
  } else if (mode === 'responses') {
897
956
  // Format history for responses API
898
- const history = formatHistory(options, prompt, message, _log);
957
+ const history = formatHistory(options, promptSegments, message, _log);
899
958
 
900
959
  // Set request
901
960
  request.url = 'https://api.openai.com/v1/responses';
@@ -1007,3 +1066,11 @@ function resolveReasoning(options) {
1007
1066
  }
1008
1067
 
1009
1068
  module.exports = OpenAI;
1069
+
1070
+ // Exposed for unit tests. Not part of the public API — do not rely on these
1071
+ // from consumer code.
1072
+ module.exports._internals = {
1073
+ normalizePrompt,
1074
+ formatHistory,
1075
+ VALID_PROMPT_ROLES,
1076
+ };
@@ -45,9 +45,9 @@ async function inferContactWithAI(email, assistant) {
45
45
  try {
46
46
  const ai = assistant.Manager.AI(assistant, process.env.BACKEND_MANAGER_OPENAI_API_KEY);
47
47
  const result = await ai.request({
48
- model: 'gpt-5-mini',
48
+ model: 'gpt-5.4-mini',
49
49
  timeout: 60000,
50
- maxTokens: 1024,
50
+ maxTokens: 1024 * 2,
51
51
  moderate: false,
52
52
  response: 'json',
53
53
  prompt: {
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Test: AI request payload shape (libraries/ai/providers/openai.js)
3
+ *
4
+ * Verifies the transformation from the BEM-facing `ai.request()` options
5
+ * (specifically `options.prompt` in either legacy object form or array form)
6
+ * into the eventual OpenAI HTTP payload (the `input: [...]` array).
7
+ *
8
+ * These tests exercise the pure helpers `normalizePrompt` and `formatHistory`
9
+ * directly — no network, no assistant required.
10
+ */
11
+ const OpenAI = require('../../src/manager/libraries/ai/providers/openai.js');
12
+ const { normalizePrompt, formatHistory, VALID_PROMPT_ROLES } = OpenAI._internals;
13
+
14
+ function noopLog() {}
15
+
16
+ function baseOptions(overrides = {}) {
17
+ return {
18
+ dedupeConsecutiveRoles: true,
19
+ history: { messages: [], limit: 5 },
20
+ message: { attachments: [] },
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ module.exports = {
26
+ description: 'AI request payload shape (system/developer/user roles)',
27
+ type: 'group',
28
+ tests: [
29
+ // ─── normalizePrompt ───
30
+
31
+ {
32
+ name: 'normalize-undefined-returns-empty-array',
33
+ async run({ assert }) {
34
+ assert.deepEqual(normalizePrompt(undefined), [], 'undefined → []');
35
+ },
36
+ },
37
+
38
+ {
39
+ name: 'normalize-null-returns-empty-array',
40
+ async run({ assert }) {
41
+ assert.deepEqual(normalizePrompt(null), [], 'null → []');
42
+ },
43
+ },
44
+
45
+ {
46
+ name: 'normalize-empty-object-returns-empty-array',
47
+ async run({ assert }) {
48
+ assert.deepEqual(normalizePrompt({}), [], 'empty object → []');
49
+ },
50
+ },
51
+
52
+ {
53
+ name: 'normalize-legacy-object-form-wraps-as-system-segment',
54
+ async run({ assert }) {
55
+ const result = normalizePrompt({ path: '/tmp/example.md', settings: { foo: 'bar' } });
56
+
57
+ assert.equal(result.length, 1, 'one segment');
58
+ assert.equal(result[0].role, 'system', 'legacy object defaults to system role');
59
+ assert.equal(result[0].path, '/tmp/example.md', 'path preserved');
60
+ assert.deepEqual(result[0].settings, { foo: 'bar' }, 'settings preserved');
61
+ },
62
+ },
63
+
64
+ {
65
+ name: 'normalize-legacy-object-with-content-only',
66
+ async run({ assert }) {
67
+ const result = normalizePrompt({ content: 'inline prompt text' });
68
+
69
+ assert.equal(result.length, 1, 'one segment');
70
+ assert.equal(result[0].role, 'system', 'defaults to system');
71
+ assert.equal(result[0].content, 'inline prompt text', 'content preserved');
72
+ assert.equal(result[0].path, '', 'no path');
73
+ },
74
+ },
75
+
76
+ {
77
+ name: 'normalize-array-form-preserves-roles-and-order',
78
+ async run({ assert }) {
79
+ const result = normalizePrompt([
80
+ { role: 'system', content: 'platform rules' },
81
+ { role: 'developer', content: 'operator config' },
82
+ ]);
83
+
84
+ assert.equal(result.length, 2, 'two segments');
85
+ assert.equal(result[0].role, 'system', 'first is system');
86
+ assert.equal(result[0].content, 'platform rules', 'first content');
87
+ assert.equal(result[1].role, 'developer', 'second is developer');
88
+ assert.equal(result[1].content, 'operator config', 'second content');
89
+ },
90
+ },
91
+
92
+ {
93
+ name: 'normalize-array-segment-without-role-defaults-to-system',
94
+ async run({ assert }) {
95
+ const result = normalizePrompt([
96
+ { content: 'rule 1' },
97
+ { role: 'developer', content: 'rule 2' },
98
+ ]);
99
+
100
+ assert.equal(result[0].role, 'system', 'missing role → system');
101
+ assert.equal(result[1].role, 'developer', 'explicit role preserved');
102
+ },
103
+ },
104
+
105
+ {
106
+ name: 'normalize-array-with-invalid-role-throws',
107
+ async run({ assert }) {
108
+ let threw = false;
109
+ try {
110
+ normalizePrompt([{ role: 'admin', content: 'bad' }]);
111
+ } catch (e) {
112
+ threw = true;
113
+ assert.equal(
114
+ String(e.message).includes('Invalid prompt role'),
115
+ true,
116
+ 'error mentions Invalid prompt role',
117
+ );
118
+ }
119
+ assert.equal(threw, true, 'should throw on invalid role');
120
+ },
121
+ },
122
+
123
+ {
124
+ name: 'normalize-valid-roles-set-matches-openai-model-spec',
125
+ async run({ assert }) {
126
+ const expected = ['system', 'developer', 'user', 'assistant'];
127
+ const actual = [...VALID_PROMPT_ROLES].sort();
128
+
129
+ assert.deepEqual(actual, expected.sort(), 'valid roles per OpenAI Model Spec');
130
+ },
131
+ },
132
+
133
+ {
134
+ name: 'normalize-all-valid-roles-accepted',
135
+ async run({ assert }) {
136
+ const segments = ['system', 'developer', 'user', 'assistant'].map((role) => ({
137
+ role,
138
+ content: `content for ${role}`,
139
+ }));
140
+
141
+ const result = normalizePrompt(segments);
142
+
143
+ assert.equal(result.length, 4, 'all four segments accepted');
144
+ result.forEach((segment, i) => {
145
+ assert.equal(segment.role, segments[i].role, `segment ${i} role preserved`);
146
+ });
147
+ },
148
+ },
149
+
150
+ // ─── formatHistory → OpenAI Responses API payload shape ───
151
+
152
+ {
153
+ name: 'format-single-system-prompt-emits-system-then-user',
154
+ async run({ assert }) {
155
+ const promptSegments = normalizePrompt({ content: 'You are a helpful assistant.' });
156
+ const formatted = formatHistory(baseOptions(), promptSegments, 'Hello!', noopLog);
157
+
158
+ assert.equal(formatted.length, 2, 'two messages: system + user');
159
+ assert.equal(formatted[0].role, 'system', 'first message is system');
160
+ assert.equal(formatted[0].content[0].type, 'input_text', 'system uses input_text');
161
+ assert.equal(formatted[0].content[0].text, 'You are a helpful assistant.', 'system text');
162
+ assert.equal(formatted[1].role, 'user', 'second message is user');
163
+ assert.equal(formatted[1].content[0].text, 'Hello!', 'user text');
164
+ },
165
+ },
166
+
167
+ {
168
+ name: 'format-system-plus-developer-emits-three-messages-in-order',
169
+ async run({ assert }) {
170
+ const promptSegments = normalizePrompt([
171
+ { role: 'system', content: 'Platform rules go here.' },
172
+ { role: 'developer', content: 'Operator config goes here.' },
173
+ ]);
174
+ const formatted = formatHistory(baseOptions(), promptSegments, 'Customer email body.', noopLog);
175
+
176
+ assert.equal(formatted.length, 3, 'three messages');
177
+ assert.equal(formatted[0].role, 'system', 'order: system');
178
+ assert.equal(formatted[1].role, 'developer', 'order: developer');
179
+ assert.equal(formatted[2].role, 'user', 'order: user');
180
+ assert.equal(formatted[0].content[0].text, 'Platform rules go here.', 'system content');
181
+ assert.equal(formatted[1].content[0].text, 'Operator config goes here.', 'developer content');
182
+ assert.equal(formatted[2].content[0].text, 'Customer email body.', 'user content');
183
+ },
184
+ },
185
+
186
+ {
187
+ name: 'format-empty-prompt-array-emits-only-user-message',
188
+ async run({ assert }) {
189
+ const formatted = formatHistory(baseOptions(), [], 'Just a user message.', noopLog);
190
+
191
+ assert.equal(formatted.length, 1, 'only the user message');
192
+ assert.equal(formatted[0].role, 'user', 'role: user');
193
+ assert.equal(formatted[0].content[0].text, 'Just a user message.', 'text preserved');
194
+ },
195
+ },
196
+
197
+ {
198
+ name: 'format-interleaves-prompt-history-and-new-user-message',
199
+ async run({ assert }) {
200
+ const promptSegments = normalizePrompt([
201
+ { role: 'system', content: 'system rules' },
202
+ { role: 'developer', content: 'developer rules' },
203
+ ]);
204
+ const options = baseOptions({
205
+ history: {
206
+ messages: [
207
+ { role: 'user', content: 'first user msg' },
208
+ { role: 'assistant', content: 'first ai reply' },
209
+ ],
210
+ limit: 5,
211
+ },
212
+ });
213
+ const formatted = formatHistory(options, promptSegments, 'second user msg', noopLog);
214
+
215
+ const roleSequence = formatted.map((m) => m.role);
216
+ assert.deepEqual(
217
+ roleSequence,
218
+ ['system', 'developer', 'user', 'assistant', 'user'],
219
+ 'prompts → history → new user message',
220
+ );
221
+ },
222
+ },
223
+
224
+ {
225
+ name: 'format-assistant-history-uses-output_text-type',
226
+ async run({ assert }) {
227
+ const options = baseOptions({
228
+ history: {
229
+ messages: [{ role: 'assistant', content: 'previous reply' }],
230
+ limit: 5,
231
+ },
232
+ });
233
+ const formatted = formatHistory(options, [], 'new message', noopLog);
234
+
235
+ const assistantMsg = formatted.find((m) => m.role === 'assistant');
236
+ assert.equal(assistantMsg.content[0].type, 'output_text', 'assistant uses output_text');
237
+ },
238
+ },
239
+
240
+ {
241
+ name: 'format-respects-history-limit',
242
+ async run({ assert }) {
243
+ const options = baseOptions({
244
+ history: {
245
+ messages: [
246
+ { role: 'user', content: 'msg 1' },
247
+ { role: 'assistant', content: 'msg 2' },
248
+ { role: 'user', content: 'msg 3' },
249
+ { role: 'assistant', content: 'msg 4' },
250
+ { role: 'user', content: 'msg 5' },
251
+ { role: 'assistant', content: 'msg 6' },
252
+ ],
253
+ limit: 2,
254
+ },
255
+ });
256
+ const formatted = formatHistory(options, normalizePrompt({ content: 'sys' }), 'now', noopLog);
257
+
258
+ // Expected: 1 system + 2 history + 1 user = 4 messages
259
+ assert.equal(formatted.length, 4, 'system + 2 history + new user');
260
+ assert.equal(formatted[1].content[0].text, 'msg 5', 'second-to-last history kept');
261
+ assert.equal(formatted[2].content[0].text, 'msg 6', 'last history kept');
262
+ assert.equal(formatted[3].content[0].text, 'now', 'new user message appended');
263
+ },
264
+ },
265
+
266
+ {
267
+ name: 'format-dedupes-trailing-user-history-when-flag-set',
268
+ async run({ assert }) {
269
+ const options = baseOptions({
270
+ dedupeConsecutiveRoles: true,
271
+ history: {
272
+ messages: [
273
+ { role: 'assistant', content: 'reply' },
274
+ { role: 'user', content: 'should be dropped' },
275
+ ],
276
+ limit: 5,
277
+ },
278
+ });
279
+ const formatted = formatHistory(options, [], 'real new message', noopLog);
280
+
281
+ // history's trailing 'user' is dropped, then the real new message is appended
282
+ assert.equal(formatted.length, 2, 'assistant + new user only');
283
+ assert.equal(formatted[0].role, 'assistant', 'kept assistant');
284
+ assert.equal(formatted[1].role, 'user', 'new user');
285
+ assert.equal(formatted[1].content[0].text, 'real new message', 'new user content');
286
+ },
287
+ },
288
+
289
+ {
290
+ name: 'format-strips-and-trims-content',
291
+ async run({ assert }) {
292
+ const promptSegments = normalizePrompt({ content: ' padded system content \n' });
293
+ const formatted = formatHistory(baseOptions(), promptSegments, ' padded user content ', noopLog);
294
+
295
+ assert.equal(formatted[0].content[0].text, 'padded system content', 'system trimmed');
296
+ assert.equal(formatted[1].content[0].text, 'padded user content', 'user trimmed');
297
+ },
298
+ },
299
+ ],
300
+ };