backend-manager 5.2.7 → 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 +18 -0
- package/package.json +1 -1
- package/src/manager/libraries/ai/index.js +1 -1
- package/src/manager/libraries/ai/providers/openai.js +92 -25
- package/src/manager/libraries/email/data/disposable-domains.json +6 -0
- package/src/manager/libraries/infer-contact.js +2 -2
- package/src/manager/routes/user/signup/post.js +1 -1
- package/test/helpers/ai-request-payload.js +300 -0
- package/TODO-CANCEL-EMAIL-MISSING-ORDER-ID.md +0 -159
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,24 @@ 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
|
+
|
|
29
|
+
# [5.2.8] - 2026-05-25
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`/user/signup` precedence flip for `activity.client`.** `routes/user/signup/post.js` now spreads `assistant.request.client` FIRST and `settings.context.client` (the browser's `getContext()` payload) LAST, so the browser-supplied values win for the `client` block. `activity.client.language` is now `navigator.language` (e.g. `en-US`) instead of the raw `Accept-Language` header list (e.g. `en-US,en;q=0.9,fr;q=0.8`); falls back to the header when no browser context was sent (bots, non-browser clients). `activity.geolocation` precedence is unchanged — Cloudflare headers (`cf-ipcountry`, etc.) still win, since the browser doesn't know its own geo. Final shape mirrors `assistant.request`: geolocation is header-authoritative, client is browser-authoritative.
|
|
34
|
+
|
|
17
35
|
# [5.2.7] - 2026-05-24
|
|
18
36
|
|
|
19
37
|
### Fixed
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
+
};
|
|
@@ -417,6 +417,7 @@
|
|
|
417
417
|
"amberwe.us",
|
|
418
418
|
"ambiancewe.us",
|
|
419
419
|
"ambitiouswe.us",
|
|
420
|
+
"ameady.com",
|
|
420
421
|
"amelabs.com",
|
|
421
422
|
"americanawe.us",
|
|
422
423
|
"americancivichub.com",
|
|
@@ -656,6 +657,7 @@
|
|
|
656
657
|
"bipochub.com",
|
|
657
658
|
"bitmah.com",
|
|
658
659
|
"bitmens.com",
|
|
660
|
+
"bittnex.com",
|
|
659
661
|
"bitwhites.top",
|
|
660
662
|
"bitymails.us",
|
|
661
663
|
"biz.st",
|
|
@@ -1914,6 +1916,7 @@
|
|
|
1914
1916
|
"gddp2018.edu.vn",
|
|
1915
1917
|
"gdmail.top",
|
|
1916
1918
|
"gdqoe.net",
|
|
1919
|
+
"gebrauchtwarencenter.com",
|
|
1917
1920
|
"gedmail.win",
|
|
1918
1921
|
"geekforex.com",
|
|
1919
1922
|
"geew.ru",
|
|
@@ -2102,6 +2105,7 @@
|
|
|
2102
2105
|
"gynzi.co.uk",
|
|
2103
2106
|
"gynzi.es",
|
|
2104
2107
|
"gzb.ro",
|
|
2108
|
+
"gzeos.com",
|
|
2105
2109
|
"h0tmaii.com",
|
|
2106
2110
|
"h2beta.com",
|
|
2107
2111
|
"h8s.org",
|
|
@@ -3506,10 +3510,12 @@
|
|
|
3506
3510
|
"nowhere.org",
|
|
3507
3511
|
"nowmymail.com",
|
|
3508
3512
|
"nowmymail.net",
|
|
3513
|
+
"noyavip.com",
|
|
3509
3514
|
"noyp.fr.nf",
|
|
3510
3515
|
"nproxi.com",
|
|
3511
3516
|
"nqmo.com",
|
|
3512
3517
|
"nrehi.com",
|
|
3518
|
+
"nriza.com",
|
|
3513
3519
|
"nrlord.com",
|
|
3514
3520
|
"ns01.biz",
|
|
3515
3521
|
"nsvpn.com",
|
|
@@ -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: {
|
|
@@ -137,8 +137,8 @@ function buildUserRecord(assistant, settings, inferred) {
|
|
|
137
137
|
...assistant.request.geolocation,
|
|
138
138
|
},
|
|
139
139
|
client: {
|
|
140
|
-
...(settings.context?.client || {}),
|
|
141
140
|
...assistant.request.client,
|
|
141
|
+
...(settings.context?.client || {}),
|
|
142
142
|
},
|
|
143
143
|
},
|
|
144
144
|
attribution: attribution || {},
|
|
@@ -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
|
+
};
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
# TODO: Cancel email missing Order # on non-trial cancels
|
|
2
|
-
|
|
3
|
-
## Symptom
|
|
4
|
-
|
|
5
|
-
Observed 2026-05-23 in `_test.journey-payments-cancel@somiibo.com`'s inbox:
|
|
6
|
-
|
|
7
|
-
```
|
|
8
|
-
Subject: Your subscription has been cancelled #
|
|
9
|
-
Body: Order #
|
|
10
|
-
Your subscription has been cancelled.
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
The `#` after both `Subject` and `Order #` is bare — `order.id` rendered as empty string.
|
|
14
|
-
|
|
15
|
-
The trial-cancel sibling (`_test.journey-payments-trial-cancel@somiibo.com`) on the SAME run got the order ID correctly:
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
Subject: Your subscription has been cancelled #3794-5306-7041
|
|
19
|
-
Body: Order #3794-5306-7041
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
So this is path-specific, not a template bug.
|
|
23
|
-
|
|
24
|
-
## Root cause (narrowed but not yet proven)
|
|
25
|
-
|
|
26
|
-
Two cancel paths feed the same `subscription-cancelled` transition handler ([src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js:17](src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js#L17)):
|
|
27
|
-
|
|
28
|
-
```js
|
|
29
|
-
subject: `Your subscription has been cancelled #${order?.id || ''}`,
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Subject + template both render `order.id`. When `order.id` is falsy it falls through to `''`.
|
|
33
|
-
|
|
34
|
-
`order.id` is built in [src/manager/events/firestore/payments-webhooks/on-write.js:241-242](src/manager/events/firestore/payments-webhooks/on-write.js#L241-L242):
|
|
35
|
-
|
|
36
|
-
```js
|
|
37
|
-
const order = {
|
|
38
|
-
id: orderId,
|
|
39
|
-
...
|
|
40
|
-
};
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Where `orderId` (line 118) is:
|
|
44
|
-
|
|
45
|
-
```js
|
|
46
|
-
orderId = library.getOrderId(resource) || passThruOrderId;
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
`library.getOrderId(resource)` reads the order ID off the processor resource's `meta_data` field. `passThruOrderId` is a PayPal-only fallback from the hosted-page `pass_thru_content`.
|
|
50
|
-
|
|
51
|
-
**Working path** (`journey-payments-trial-cancel`): the trial cancel goes through the `/payments/cancel` endpoint, which already has the `orderId` from `users/{uid}.subscription.payment.orderId`. By the time Stripe/PayPal fires the cancellation webhook back, `meta_data.orderId` on the subscription resource is set, so `library.getOrderId(resource)` returns the order ID. Email shows the ID.
|
|
52
|
-
|
|
53
|
-
**Broken path** (`journey-payments-cancel`): this is the webhook-driven cancel (no `/payments/cancel` endpoint call — `orderDoc.requests.cancellation` is null in the test assertions). The webhook arrives with a subscription resource whose `meta_data.orderId` is empty (or `library.getOrderId(resource)` returns null/empty string). `passThruOrderId` is null because this is Stripe, not PayPal. `orderId` ends up null. `order.id = null`. Email renders `Order #`.
|
|
54
|
-
|
|
55
|
-
## What's still unknown
|
|
56
|
-
|
|
57
|
-
1. **Why is `meta_data.orderId` missing on the Stripe subscription resource at cancel time?** Possibilities:
|
|
58
|
-
- The journey test never set `meta_data` on the subscription (only on the customer or the initial intent).
|
|
59
|
-
- `library.setMetaData` was called during the new-subscription transition but Stripe's subscription resource didn't persist it (Stripe quirk where `metadata` on subscription vs. customer vs. invoice diverge).
|
|
60
|
-
- The `customer.subscription.deleted` event payload doesn't include `metadata` even when it was set — this would be a Stripe-side gotcha.
|
|
61
|
-
2. **Does this affect production?** Possibly — depends on whether real users' Stripe subscription resources have `meta_data.orderId` set. Worth checking one real cancelled subscription in the Stripe dashboard.
|
|
62
|
-
|
|
63
|
-
## Investigation steps
|
|
64
|
-
|
|
65
|
-
1. **Re-run the cancel journey with `TEST_EXTENDED_MODE=true`** and watch the BEM logs:
|
|
66
|
-
```bash
|
|
67
|
-
cd /Users/ian/Developer/Repositories/Somiibo/somiibo-backend/functions
|
|
68
|
-
TEST_EXTENDED_MODE=true npx mgr test events/payments/journey-payments-cancel
|
|
69
|
-
```
|
|
70
|
-
Search the logs around the cancellation webhook for the resolved `orderId` value:
|
|
71
|
-
```bash
|
|
72
|
-
npx mgr logs:read --filter "journey-payments-cancel" --limit 200 | grep -i "orderid\|order id"
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
2. **Inspect the raw webhook payload** stored in `payments-webhooks/{eventId}` for that test run:
|
|
76
|
-
```bash
|
|
77
|
-
npx mgr firestore:query "payments-webhooks" --limit 20 # find the deleted-subscription event
|
|
78
|
-
npx mgr firestore:get "payments-webhooks/<eventId>" # inspect raw.data.object.metadata
|
|
79
|
-
```
|
|
80
|
-
If `raw.data.object.metadata.orderId` is empty → confirms Stripe didn't include it in the event.
|
|
81
|
-
|
|
82
|
-
3. **Check `library.getOrderId(resource)` implementation** for the Stripe library:
|
|
83
|
-
```bash
|
|
84
|
-
grep -n "getOrderId" /Users/ian/Developer/Repositories/ITW-Creative-Works/backend-manager/src/manager/libraries/payment/stripe.js
|
|
85
|
-
```
|
|
86
|
-
Confirm what field path it reads. If it reads only `resource.metadata.orderId`, that's the bug — should also check `resource.metadata.bm_orderId` (some processors have different conventions) or fall through to a Firestore lookup.
|
|
87
|
-
|
|
88
|
-
## Fix options (in order of preference)
|
|
89
|
-
|
|
90
|
-
### Option A — Fall back to userDoc on the transition side (safest)
|
|
91
|
-
|
|
92
|
-
In [subscription-cancelled.js:17](src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js#L17), if `order.id` is missing, fall back to `userDoc.subscription.payment.orderId`:
|
|
93
|
-
|
|
94
|
-
```js
|
|
95
|
-
const orderId = order?.id || userDoc?.subscription?.payment?.orderId || '';
|
|
96
|
-
|
|
97
|
-
sendOrderEmail({
|
|
98
|
-
template: 'core/order/cancelled',
|
|
99
|
-
subject: `Your subscription has been cancelled${orderId ? ` #${orderId}` : ''}`,
|
|
100
|
-
...
|
|
101
|
-
data: {
|
|
102
|
-
order: {
|
|
103
|
-
...order,
|
|
104
|
-
id: orderId,
|
|
105
|
-
...
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
This is the **safest** fix because it works regardless of whether the bug is in `library.getOrderId`, in Stripe's event payload, or in how `meta_data` was set originally. `userDoc.subscription.payment.orderId` is written when the subscription first activates and reliably has the ID by cancel time.
|
|
112
|
-
|
|
113
|
-
The subject also degrades gracefully — `Your subscription has been cancelled` (no bare `#`) when the ID truly can't be found.
|
|
114
|
-
|
|
115
|
-
### Option B — Fix at the source (`on-write.js`)
|
|
116
|
-
|
|
117
|
-
In [on-write.js:118](src/manager/events/firestore/payments-webhooks/on-write.js#L118), add another fallback after `library.getOrderId`:
|
|
118
|
-
|
|
119
|
-
```js
|
|
120
|
-
orderId = library.getOrderId(resource)
|
|
121
|
-
|| passThruOrderId
|
|
122
|
-
|| userDoc?.subscription?.payment?.orderId
|
|
123
|
-
|| null;
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
This fixes it for ALL handlers (not just cancel), and at the cost of a Firestore read of the user doc earlier in `on-write.js` than today. May not need a new read if userDoc is already fetched by this point — confirm by reading the function top-to-bottom.
|
|
127
|
-
|
|
128
|
-
### Option C — Fix `library.getOrderId(resource)` (Stripe library)
|
|
129
|
-
|
|
130
|
-
If investigation step 3 shows the Stripe library is reading the wrong field path, fix it there. Smallest blast radius if the bug is genuinely in one processor's resolver and not a Stripe-event-payload gotcha.
|
|
131
|
-
|
|
132
|
-
## Recommendation
|
|
133
|
-
|
|
134
|
-
Do **A + B together** — A makes the email correct today regardless of root cause; B prevents the same gap from biting any other handler (refund, suspend, etc.). C only if investigation step 3 reveals an actual mis-read.
|
|
135
|
-
|
|
136
|
-
## Regression test
|
|
137
|
-
|
|
138
|
-
Add an assertion to `test/events/payments/journey-payments-cancel.js` that the post-cancellation userDoc still has `subscription.payment.orderId` set AND that the cancel email's `order.id` would render non-empty. Without a regression test, the same bug will silently come back the next time someone refactors `library.getOrderId`.
|
|
139
|
-
|
|
140
|
-
Pseudo-assertion:
|
|
141
|
-
|
|
142
|
-
```js
|
|
143
|
-
assert.ok(userDoc.subscription?.payment?.orderId, 'userDoc.subscription.payment.orderId should be set after cancellation');
|
|
144
|
-
// And if we capture the email payload (via a test-mode hook in sendOrderEmail), assert order.id !== '' in the rendered data.
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
If `sendOrderEmail` doesn't already have a test-mode capture, that's a small enhancement worth doing once — it would let every email-emitting transition assert its rendered payload, not just cancel.
|
|
148
|
-
|
|
149
|
-
## Affected versions
|
|
150
|
-
|
|
151
|
-
Confirmed broken: BEM 5.2.5 (current live on Somiibo as of 2026-05-23). Likely broken in earlier versions too — the `order?.id || ''` template defensive fallback was added explicitly to handle missing IDs, suggesting this gap has been latent for a while. The trial-cancel path masked it because trial cancels go through the endpoint and have `orderId` set in a different code path.
|
|
152
|
-
|
|
153
|
-
## Related files
|
|
154
|
-
|
|
155
|
-
- [src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js](src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js) — handler with `order?.id || ''` fallback
|
|
156
|
-
- [src/manager/events/firestore/payments-webhooks/on-write.js:241](src/manager/events/firestore/payments-webhooks/on-write.js#L241) — `order` object construction
|
|
157
|
-
- [src/manager/events/firestore/payments-webhooks/on-write.js:118](src/manager/events/firestore/payments-webhooks/on-write.js#L118) — `orderId` resolution from `library.getOrderId` + pass-through fallback
|
|
158
|
-
- [test/events/payments/journey-payments-cancel.js](test/events/payments/journey-payments-cancel.js) — broken-path test (currently passes because it doesn't assert the email payload)
|
|
159
|
-
- [test/events/payments/journey-payments-trial-cancel.js](test/events/payments/journey-payments-trial-cancel.js) — working-path test
|