@exulu/backend 3.1.0 → 3.2.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.
- package/README.md +37 -0
- package/dist/{chunk-ZDH5S2WF.js → chunk-CVQTDG37.js} +832 -471
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-FN6WZSIQ.js → convert-exulu-tools-to-ai-sdk-tools-QG7E6UX5.js} +1 -1
- package/dist/index.cjs +3879 -5160
- package/dist/index.d.cts +312 -389
- package/dist/index.d.ts +312 -389
- package/dist/index.js +1815 -3507
- package/ee/agentic-retrieval/pipeline/index.test.ts +1 -1
- package/ee/agentic-retrieval/pipeline/index.ts +0 -1
- package/ee/python/documents/processing/doc_processor.ts +0 -1
- package/ee/queues/queues.ts +13 -0
- package/ee/schemas.ts +11 -10
- package/ee/workers.ts +42 -56
- package/package.json +1 -1
- package/ee/workers.flow.test.ts +0 -236
|
@@ -4,7 +4,7 @@ import { createAgenticRetrievalTool, parsePreselectedItems } from "./index";
|
|
|
4
4
|
jest.mock("@EE/entitlements", () => ({ checkLicense: () => ({ "agentic-retrieval": true }) }));
|
|
5
5
|
jest.mock("@SRC/exulu/resolve-reranker", () => ({ resolveReranker: jest.fn(async () => ({ model: "m", rerank: async (_q: any, c: any) => c })) }));
|
|
6
6
|
jest.mock("@SRC/exulu/resolve-model", () => ({ resolveModel: jest.fn() }));
|
|
7
|
-
jest.mock("@SRC/exulu/app/singleton", () => ({ exuluApp: { get: () => (
|
|
7
|
+
jest.mock("@SRC/exulu/app/singleton", () => ({ exuluApp: { get: () => () } }));
|
|
8
8
|
jest.mock("./routing", () => ({ runRoutingPhase: jest.fn(async () => ({
|
|
9
9
|
mainContexts: ["docs"], fallbackContexts: [], userPinnedItemIdsByContext: new Map(),
|
|
10
10
|
userRequestedPage: null, hasExplicitDocAndPage: false, steps: [{ text: "routed" }] })) }));
|
|
@@ -274,7 +274,6 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
274
274
|
const resolved = await resolveModel({
|
|
275
275
|
modelId: cfg.utilityModel,
|
|
276
276
|
user,
|
|
277
|
-
providers: exuluApp.get().providers,
|
|
278
277
|
rbacBypass: true,
|
|
279
278
|
});
|
|
280
279
|
utilityModel = resolved.languageModel ?? model;
|
|
@@ -146,7 +146,6 @@ async function resolveVlmModel(
|
|
|
146
146
|
|
|
147
147
|
const { languageModel } = await resolveModel({
|
|
148
148
|
modelId,
|
|
149
|
-
providers: [], // unused in LiteLLM mode; resolveModel ignores it there
|
|
150
149
|
user: config?.attribution?.user,
|
|
151
150
|
project: config?.attribution?.project,
|
|
152
151
|
agent: config?.attribution?.agent,
|
package/ee/queues/queues.ts
CHANGED
|
@@ -5,6 +5,19 @@ import { BullMQOtel } from "bullmq-otel";
|
|
|
5
5
|
import type { ExuluQueueConfig } from "@EXULU_TYPES/queue-config";
|
|
6
6
|
import { checkLicense } from "@EE/entitlements";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Built-in/system queues that ExuluApp registers automatically. They back
|
|
10
|
+
* internal processing (eval runs, inbound email intake) and must never be
|
|
11
|
+
* offered to users as a routine's run queue — the `queues` GraphQL query
|
|
12
|
+
* excludes them (see resolveAvailableQueues). Underscore, not hyphen:
|
|
13
|
+
* registered queue names are interpolated verbatim into the GraphQL QueueEnum,
|
|
14
|
+
* where "-" is illegal.
|
|
15
|
+
*/
|
|
16
|
+
export const global_queues = {
|
|
17
|
+
eval_runs: "eval_runs",
|
|
18
|
+
email_intake: "email_intake",
|
|
19
|
+
};
|
|
20
|
+
|
|
8
21
|
// Used for workflows and embedders
|
|
9
22
|
class ExuluQueues {
|
|
10
23
|
queues: {
|
package/ee/schemas.ts
CHANGED
|
@@ -419,6 +419,10 @@ export const workflowTemplatesSchema: ExuluTableDefinition = {
|
|
|
419
419
|
type: "boolean",
|
|
420
420
|
default: false,
|
|
421
421
|
},
|
|
422
|
+
{
|
|
423
|
+
name: "queue",
|
|
424
|
+
type: "text"
|
|
425
|
+
}
|
|
422
426
|
],
|
|
423
427
|
};
|
|
424
428
|
|
|
@@ -454,16 +458,13 @@ export const workflowTemplatesSchema: ExuluTableDefinition = {
|
|
|
454
458
|
type: "boolean",
|
|
455
459
|
default: false,
|
|
456
460
|
},
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
unique: true,
|
|
465
|
-
index: true,
|
|
466
|
-
},
|
|
461
|
+
// Secret capability URL key: base64url randomBytes(32). Both routes
|
|
462
|
+
// and authorizes the webhook (POST /webhooks/routine/:secret).
|
|
463
|
+
{ name: "secret", type: "text", required: true, unique: true, index: true },
|
|
464
|
+
// Optional per-trigger HMAC shared secret, AES-encrypted at rest.
|
|
465
|
+
{ name: "signing_secret", type: "text" },
|
|
466
|
+
// Stamped on every verified webhook hit; per-trigger setup aid.
|
|
467
|
+
{ name: "last_fired_at", type: "date" },
|
|
467
468
|
{
|
|
468
469
|
// allowed_senders / filters / filtered_run_retention /
|
|
469
470
|
// rate_limit_per_hour / sender_rate_limit_per_hour (spec §3.1).
|
package/ee/workers.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { BullMqJobData } from "@EE/queues/decorator.ts";
|
|
|
16
16
|
import { maybePruneJobResults } from "@EE/queues/prune-job-results.ts";
|
|
17
17
|
import { type Tracer } from "@opentelemetry/api";
|
|
18
18
|
import { v4 as uuidv4 } from "uuid";
|
|
19
|
-
import { type UIMessage } from "ai";
|
|
19
|
+
import { createIdGenerator, type UIMessage } from "ai";
|
|
20
20
|
import CryptoJS from "crypto-js";
|
|
21
21
|
import { STATISTICS_TYPE_ENUM, type STATISTICS_TYPE } from "@EXULU_TYPES/enums/statistics";
|
|
22
22
|
import type { User } from "@EXULU_TYPES/models/user";
|
|
@@ -29,14 +29,15 @@ import type { STATISTICS_LABELS } from "@EXULU_TYPES/statistics.ts";
|
|
|
29
29
|
import { sanitizeToolName } from "@SRC/utils/sanitize-tool-name.ts";
|
|
30
30
|
import type { ExuluConfig } from "@SRC/exulu/app/index.ts";
|
|
31
31
|
import { updateStatistic } from "@SRC/exulu/statistics";
|
|
32
|
-
import
|
|
33
|
-
import { saveChat, getAgentMessages } from "@SRC/exulu/provider.ts";
|
|
32
|
+
import { saveChat, getAgentMessages, generateStream } from "@SRC/exulu/generate-stream";
|
|
34
33
|
import { exuluApp } from "@SRC/exulu/app/singleton";
|
|
35
34
|
import { handleEmailIntake } from "@SRC/exulu/email-inbound/intake";
|
|
36
35
|
import { markStreamActive, clearStreamActive } from "@SRC/exulu/active-streams.ts";
|
|
37
36
|
import { messageHasPendingApproval, substituteVariablesInMessage } from "@SRC/exulu/routines/flow-steps.ts";
|
|
38
37
|
import { createRunSession } from "@SRC/exulu/routines/run-session.ts";
|
|
39
38
|
import { casJobResultState, parseRunMetadata, upsertWorkflowRunStart } from "@SRC/exulu/routines/run-state.ts";
|
|
39
|
+
import { findLiteLLMModel } from "@SRC/exulu/litellm/catalog.ts";
|
|
40
|
+
import { computeRunCostUsd } from "@SRC/exulu/routines/run-cost.ts";
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Session-backed runs persist messages at each step boundary, so retries must
|
|
@@ -132,7 +133,6 @@ const installGlobalErrorHandlers = () => {
|
|
|
132
133
|
let isShuttingDown = false;
|
|
133
134
|
|
|
134
135
|
export const createWorkers = async (
|
|
135
|
-
providers: ExuluProvider[],
|
|
136
136
|
queues: ExuluQueueConfig[],
|
|
137
137
|
config: ExuluConfig,
|
|
138
138
|
contexts: ExuluContext[],
|
|
@@ -525,11 +525,10 @@ export const createWorkers = async (
|
|
|
525
525
|
|
|
526
526
|
const {
|
|
527
527
|
agent,
|
|
528
|
-
provider,
|
|
529
528
|
user,
|
|
530
529
|
workflow,
|
|
531
530
|
messages: inputMessages,
|
|
532
|
-
} = await validateWorkflowPayload(data
|
|
531
|
+
} = await validateWorkflowPayload(data);
|
|
533
532
|
|
|
534
533
|
// Session-backed runs (spec §3.4): reuse the session provided by
|
|
535
534
|
// the enqueuer (email intake / continuation / retry / previous
|
|
@@ -576,9 +575,7 @@ export const createWorkers = async (
|
|
|
576
575
|
// + substituted text) — pass a fresh deep copy each attempt
|
|
577
576
|
// so a retry/resume never reuses the mutated array.
|
|
578
577
|
const messages = await processUiMessagesFlow({
|
|
579
|
-
providers,
|
|
580
578
|
agent,
|
|
581
|
-
provider,
|
|
582
579
|
inputMessages: structuredClone(inputMessages),
|
|
583
580
|
contexts,
|
|
584
581
|
user,
|
|
@@ -658,6 +655,22 @@ export const createWorkers = async (
|
|
|
658
655
|
(priorTokens?.cachedInputTokens ?? 0) + metadata.tokens.cachedInputTokens,
|
|
659
656
|
};
|
|
660
657
|
|
|
658
|
+
// Approximate per-run $ cost (spec 2026-07-29): recompute from the
|
|
659
|
+
// cumulative token totals × the run model's catalog list price on every
|
|
660
|
+
// persist, so it stays correct across pause/resume. Null when the model
|
|
661
|
+
// has no catalog price — the UI shows "—", not a fabricated $0.
|
|
662
|
+
const modelPrice = await findLiteLLMModel(agent.model ?? "");
|
|
663
|
+
(tokens as Record<string, number | null>).costUsd = computeRunCostUsd(
|
|
664
|
+
tokens.inputTokens,
|
|
665
|
+
tokens.outputTokens,
|
|
666
|
+
modelPrice
|
|
667
|
+
? {
|
|
668
|
+
input_cost_per_million_tokens: modelPrice.input_cost_per_million_tokens,
|
|
669
|
+
output_cost_per_million_tokens: modelPrice.output_cost_per_million_tokens,
|
|
670
|
+
}
|
|
671
|
+
: null,
|
|
672
|
+
);
|
|
673
|
+
|
|
661
674
|
if (result.pausedAtStepIndex !== undefined) {
|
|
662
675
|
// Pause is success (spec §5.3): persist progress and flip to
|
|
663
676
|
// waiting_approval synchronously BEFORE returning — the
|
|
@@ -741,17 +754,15 @@ export const createWorkers = async (
|
|
|
741
754
|
|
|
742
755
|
const {
|
|
743
756
|
agent,
|
|
744
|
-
provider,
|
|
745
757
|
user,
|
|
746
758
|
evalRun,
|
|
747
759
|
testCase,
|
|
748
760
|
messages: inputMessages,
|
|
749
|
-
} = await validateEvalPayload(data
|
|
761
|
+
} = await validateEvalPayload(data);
|
|
750
762
|
|
|
751
763
|
const retries = 3;
|
|
752
764
|
let attempts = 0;
|
|
753
765
|
|
|
754
|
-
// todo allow setting queue on agent Provider and then create a job with type "agent"
|
|
755
766
|
const promise = new Promise<{
|
|
756
767
|
messages: UIMessage[];
|
|
757
768
|
metadata: {
|
|
@@ -768,9 +779,7 @@ export const createWorkers = async (
|
|
|
768
779
|
while (attempts < retries) {
|
|
769
780
|
try {
|
|
770
781
|
const messages = await processUiMessagesFlow({
|
|
771
|
-
providers,
|
|
772
782
|
agent,
|
|
773
|
-
provider,
|
|
774
783
|
inputMessages,
|
|
775
784
|
contexts,
|
|
776
785
|
user,
|
|
@@ -877,7 +886,6 @@ export const createWorkers = async (
|
|
|
877
886
|
} else {
|
|
878
887
|
result = await evalMethod.run(
|
|
879
888
|
agent,
|
|
880
|
-
provider,
|
|
881
889
|
testCase,
|
|
882
890
|
messages,
|
|
883
891
|
evalFunction.config || {},
|
|
@@ -971,10 +979,9 @@ export const createWorkers = async (
|
|
|
971
979
|
const {
|
|
972
980
|
evalRun,
|
|
973
981
|
agent,
|
|
974
|
-
provider,
|
|
975
982
|
testCase,
|
|
976
983
|
messages: inputMessages,
|
|
977
|
-
} = await validateEvalPayload(data
|
|
984
|
+
} = await validateEvalPayload(data);
|
|
978
985
|
|
|
979
986
|
const evalFunctions: {
|
|
980
987
|
id: string;
|
|
@@ -995,7 +1002,6 @@ export const createWorkers = async (
|
|
|
995
1002
|
|
|
996
1003
|
result = await evalMethod.run(
|
|
997
1004
|
agent,
|
|
998
|
-
provider,
|
|
999
1005
|
testCase,
|
|
1000
1006
|
inputMessages,
|
|
1001
1007
|
evalFunction.config || {},
|
|
@@ -1085,24 +1091,19 @@ export const createWorkers = async (
|
|
|
1085
1091
|
}
|
|
1086
1092
|
|
|
1087
1093
|
if (data.type === "email_intake") {
|
|
1088
|
-
console.log("[EXULU] running
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
throw new Error(`No s3Key set for email intake job.`);
|
|
1094
|
+
console.log("[EXULU] running a routine webhook intake job.", bullmqJob.name);
|
|
1095
|
+
if (!data.inputs?.s3Key || !data.inputs?.triggerId) {
|
|
1096
|
+
throw new Error(`Missing s3Key/triggerId for email intake job.`);
|
|
1092
1097
|
}
|
|
1093
|
-
|
|
1094
1098
|
const result = await handleEmailIntake(
|
|
1095
1099
|
{
|
|
1096
1100
|
s3Key: data.inputs.s3Key,
|
|
1097
|
-
|
|
1101
|
+
triggerId: data.inputs.triggerId,
|
|
1102
|
+
format: data.inputs.format === "json" ? "json" : "eml",
|
|
1098
1103
|
},
|
|
1099
|
-
{ config
|
|
1104
|
+
{ config },
|
|
1100
1105
|
);
|
|
1101
|
-
|
|
1102
|
-
return {
|
|
1103
|
-
result,
|
|
1104
|
-
metadata: {},
|
|
1105
|
-
};
|
|
1106
|
+
return { result, metadata: {} };
|
|
1106
1107
|
}
|
|
1107
1108
|
|
|
1108
1109
|
throw new Error(`Invalid job type: ${data.type} for job ${bullmqJob.name}.`);
|
|
@@ -1313,10 +1314,8 @@ export const createWorkers = async (
|
|
|
1313
1314
|
|
|
1314
1315
|
export const validateWorkflowPayload = async (
|
|
1315
1316
|
data: BullMqJobData,
|
|
1316
|
-
providers: ExuluProvider[],
|
|
1317
1317
|
): Promise<{
|
|
1318
1318
|
agent: ExuluAgent;
|
|
1319
|
-
provider: ExuluProvider;
|
|
1320
1319
|
user: User;
|
|
1321
1320
|
workflow: ExuluWorkflow;
|
|
1322
1321
|
variables: Record<string, any>;
|
|
@@ -1348,12 +1347,6 @@ export const validateWorkflowPayload = async (
|
|
|
1348
1347
|
throw new Error(`Agent ${workflow.agent} not found in the database.`);
|
|
1349
1348
|
}
|
|
1350
1349
|
|
|
1351
|
-
const provider = providers.find((a) => a.id === agent.provider);
|
|
1352
|
-
|
|
1353
|
-
if (!provider) {
|
|
1354
|
-
throw new Error(`Provider ${agent.provider} not found in the database.`);
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
1350
|
const user = await db.from("users").where({ id: data.user }).first();
|
|
1358
1351
|
|
|
1359
1352
|
if (!user) {
|
|
@@ -1362,7 +1355,6 @@ export const validateWorkflowPayload = async (
|
|
|
1362
1355
|
|
|
1363
1356
|
return {
|
|
1364
1357
|
agent,
|
|
1365
|
-
provider,
|
|
1366
1358
|
user,
|
|
1367
1359
|
workflow,
|
|
1368
1360
|
variables: data.inputs,
|
|
@@ -1372,10 +1364,8 @@ export const validateWorkflowPayload = async (
|
|
|
1372
1364
|
|
|
1373
1365
|
const validateEvalPayload = async (
|
|
1374
1366
|
data: BullMqJobData,
|
|
1375
|
-
providers: ExuluProvider[],
|
|
1376
1367
|
): Promise<{
|
|
1377
1368
|
agent: ExuluAgent;
|
|
1378
|
-
provider: ExuluProvider;
|
|
1379
1369
|
user: User;
|
|
1380
1370
|
testCase: TestCase;
|
|
1381
1371
|
evalRun: EvalRun;
|
|
@@ -1419,12 +1409,6 @@ const validateEvalPayload = async (
|
|
|
1419
1409
|
throw new Error(`Agent ${evalRun.agent_id} not found in the database.`);
|
|
1420
1410
|
}
|
|
1421
1411
|
|
|
1422
|
-
const provider = providers.find((a) => a.id === agent.provider);
|
|
1423
|
-
|
|
1424
|
-
if (!provider) {
|
|
1425
|
-
throw new Error(`Provider ${agent.provider} not found in the database.`);
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
1412
|
const user = await db.from("users").where({ id: data.user }).first();
|
|
1429
1413
|
|
|
1430
1414
|
if (!user) {
|
|
@@ -1439,7 +1423,6 @@ const validateEvalPayload = async (
|
|
|
1439
1423
|
|
|
1440
1424
|
return {
|
|
1441
1425
|
agent,
|
|
1442
|
-
provider,
|
|
1443
1426
|
user,
|
|
1444
1427
|
testCase,
|
|
1445
1428
|
evalRun,
|
|
@@ -1503,9 +1486,7 @@ const pollJobResult = async ({
|
|
|
1503
1486
|
};
|
|
1504
1487
|
|
|
1505
1488
|
export const processUiMessagesFlow = async ({
|
|
1506
|
-
providers,
|
|
1507
1489
|
agent,
|
|
1508
|
-
provider,
|
|
1509
1490
|
inputMessages,
|
|
1510
1491
|
contexts,
|
|
1511
1492
|
user,
|
|
@@ -1517,9 +1498,7 @@ export const processUiMessagesFlow = async ({
|
|
|
1517
1498
|
resumeFromIndex,
|
|
1518
1499
|
respectToolApprovals,
|
|
1519
1500
|
}: {
|
|
1520
|
-
providers: ExuluProvider[];
|
|
1521
1501
|
agent: ExuluAgent;
|
|
1522
|
-
provider: ExuluProvider;
|
|
1523
1502
|
inputMessages: UIMessage[];
|
|
1524
1503
|
contexts: ExuluContext[];
|
|
1525
1504
|
user: User;
|
|
@@ -1579,7 +1558,6 @@ export const processUiMessagesFlow = async ({
|
|
|
1579
1558
|
tools,
|
|
1580
1559
|
contexts,
|
|
1581
1560
|
disabledTools,
|
|
1582
|
-
providers,
|
|
1583
1561
|
user,
|
|
1584
1562
|
);
|
|
1585
1563
|
|
|
@@ -1597,11 +1575,10 @@ export const processUiMessagesFlow = async ({
|
|
|
1597
1575
|
const resolved = await resolveModel({
|
|
1598
1576
|
modelId: agent.model,
|
|
1599
1577
|
user,
|
|
1600
|
-
providers,
|
|
1601
1578
|
agent: agent,
|
|
1602
1579
|
routine,
|
|
1603
1580
|
});
|
|
1604
|
-
|
|
1581
|
+
|
|
1605
1582
|
const resolvedLanguageModel = resolved.languageModel;
|
|
1606
1583
|
|
|
1607
1584
|
// Remove placeholder agent response before sending
|
|
@@ -1698,7 +1675,7 @@ export const processUiMessagesFlow = async ({
|
|
|
1698
1675
|
const startTime = Date.now();
|
|
1699
1676
|
|
|
1700
1677
|
try {
|
|
1701
|
-
const result = await
|
|
1678
|
+
const result = await generateStream({
|
|
1702
1679
|
contexts,
|
|
1703
1680
|
agent: agent,
|
|
1704
1681
|
user,
|
|
@@ -1714,7 +1691,6 @@ export const processUiMessagesFlow = async ({
|
|
|
1714
1691
|
currentTools: enabledTools,
|
|
1715
1692
|
allExuluTools: tools,
|
|
1716
1693
|
languageModel: resolvedLanguageModel,
|
|
1717
|
-
providerapikey,
|
|
1718
1694
|
toolConfigs: agent.tools,
|
|
1719
1695
|
exuluConfig: config,
|
|
1720
1696
|
});
|
|
@@ -1737,6 +1713,16 @@ export const processUiMessagesFlow = async ({
|
|
|
1737
1713
|
originalMessages: result.originalMessages,
|
|
1738
1714
|
sendReasoning: true,
|
|
1739
1715
|
sendSources: true,
|
|
1716
|
+
// Give each assistant message a real unique id (matches the live
|
|
1717
|
+
// chat path in routes.ts). Without this the SDK assigns id "",
|
|
1718
|
+
// and saveChat's global message_id upsert collapses every empty-id
|
|
1719
|
+
// message onto one frozen-createdAt row — which sorts the tool
|
|
1720
|
+
// approval to the top of the transcript and leaves it out of the
|
|
1721
|
+
// last-message slot the approval handler acts on (buttons inert).
|
|
1722
|
+
generateMessageId: createIdGenerator({
|
|
1723
|
+
prefix: "msg_",
|
|
1724
|
+
size: 16,
|
|
1725
|
+
}),
|
|
1740
1726
|
onError: (error) => {
|
|
1741
1727
|
console.error("[EXULU] Ui message stream error.", error);
|
|
1742
1728
|
reject(new Error(error instanceof Error ? error.message : String(error)));
|
package/package.json
CHANGED
package/ee/workers.flow.test.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import type { UIMessage } from "ai";
|
|
2
|
-
|
|
3
|
-
// ee/workers.ts pulls in the whole worker runtime; mock everything with
|
|
4
|
-
// side effects / heavy transitive imports. Specifiers match workers.ts's
|
|
5
|
-
// own import strings (moduleNameMapper resolves both aliased forms).
|
|
6
|
-
jest.mock("@SRC/postgres/client", () => ({
|
|
7
|
-
postgresClient: jest.fn(async () => ({ db: jest.fn() })),
|
|
8
|
-
}));
|
|
9
|
-
jest.mock("@SRC/utils/enabled-tools.ts", () => ({
|
|
10
|
-
getEnabledTools: jest.fn(async () => []),
|
|
11
|
-
}));
|
|
12
|
-
jest.mock("@SRC/exulu/resolve-model.ts", () => ({
|
|
13
|
-
resolveModel: jest.fn(async () => ({ apiKey: undefined, languageModel: {} })),
|
|
14
|
-
}));
|
|
15
|
-
jest.mock("@SRC/exulu/statistics", () => ({
|
|
16
|
-
updateStatistic: jest.fn(async () => undefined),
|
|
17
|
-
}));
|
|
18
|
-
jest.mock("@SRC/exulu/storage.ts", () => ({ ExuluStorage: class {} }));
|
|
19
|
-
jest.mock("@SRC/exulu/context.ts", () => ({ getTableName: jest.fn() }));
|
|
20
|
-
jest.mock("@SRC/exulu/app/singleton", () => ({ exuluApp: { get: jest.fn() } }));
|
|
21
|
-
jest.mock("@SRC/exulu/provider.ts", () => ({
|
|
22
|
-
saveChat: jest.fn(async () => undefined),
|
|
23
|
-
getAgentMessages: jest.fn(async () => []),
|
|
24
|
-
}));
|
|
25
|
-
|
|
26
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
27
|
-
const providerModule = require("@SRC/exulu/provider.ts") as {
|
|
28
|
-
saveChat: jest.Mock;
|
|
29
|
-
getAgentMessages: jest.Mock;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
import { FlowStepError, processUiMessagesFlow } from "./workers";
|
|
33
|
-
|
|
34
|
-
const step = (id: string, text: string): UIMessage =>
|
|
35
|
-
({ id, role: "user", parts: [{ type: "text", text }] }) as UIMessage;
|
|
36
|
-
|
|
37
|
-
const assistant = (id: string, parts: any[]): UIMessage =>
|
|
38
|
-
({ id, role: "assistant", parts }) as UIMessage;
|
|
39
|
-
|
|
40
|
-
const approvalPart = {
|
|
41
|
-
type: "tool-create_offer",
|
|
42
|
-
state: "approval-requested",
|
|
43
|
-
approval: { id: "appr-1" },
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Stub ExuluProvider: generateStream returns a fake AI-SDK stream whose
|
|
48
|
-
* toUIMessageStream immediately finishes with [history + step + response].
|
|
49
|
-
* `responses[n]` = assistant messages appended by the n-th generateStream call.
|
|
50
|
-
* A response of `null` makes that call's stream error (onError + reject).
|
|
51
|
-
*/
|
|
52
|
-
const makeStubProvider = (responses: (UIMessage[] | null)[]) => {
|
|
53
|
-
let call = 0;
|
|
54
|
-
const generateStream = jest.fn(async (opts: any) => {
|
|
55
|
-
const index = call++;
|
|
56
|
-
const original: UIMessage[] = [...(opts.previousMessages ?? []), opts.message];
|
|
57
|
-
return {
|
|
58
|
-
originalMessages: original,
|
|
59
|
-
previousMessages: opts.previousMessages ?? [],
|
|
60
|
-
stream: {
|
|
61
|
-
toUIMessageStream: (streamOpts: any) => ({
|
|
62
|
-
async *[Symbol.asyncIterator]() {
|
|
63
|
-
const response = responses[index];
|
|
64
|
-
if (response === null) {
|
|
65
|
-
streamOpts.onError(new Error("provider exploded"));
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
await streamOpts.onFinish({ messages: [...original, ...(response ?? [])] });
|
|
69
|
-
},
|
|
70
|
-
}),
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
});
|
|
74
|
-
return { provider: { generateStream } as any, generateStream };
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const baseArgs = (provider: any) => ({
|
|
78
|
-
providers: [] as any[],
|
|
79
|
-
agent: { id: "agent-1", name: "Agent", model: "model-1", tools: [], instructions: "do" } as any,
|
|
80
|
-
provider,
|
|
81
|
-
contexts: [] as any[],
|
|
82
|
-
user: { id: 7, role: { id: "role-1" } } as any,
|
|
83
|
-
tools: [{ name: "Create Offer" }] as any[],
|
|
84
|
-
config: {} as any,
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
afterEach(() => jest.clearAllMocks());
|
|
88
|
-
|
|
89
|
-
describe("processUiMessagesFlow (headless — unchanged legacy behavior)", () => {
|
|
90
|
-
it("passes session undefined + blanket approvedTools and never persists", async () => {
|
|
91
|
-
const { provider, generateStream } = makeStubProvider([[assistant("a1", [{ type: "text", text: "ok" }])]]);
|
|
92
|
-
const result = await processUiMessagesFlow({
|
|
93
|
-
...baseArgs(provider),
|
|
94
|
-
inputMessages: [step("s1", "hello")],
|
|
95
|
-
});
|
|
96
|
-
expect(generateStream).toHaveBeenCalledTimes(1);
|
|
97
|
-
const opts = generateStream.mock.calls[0][0];
|
|
98
|
-
expect(opts.session).toBeUndefined();
|
|
99
|
-
expect(Array.isArray(opts.approvedTools)).toBe(true);
|
|
100
|
-
expect(providerModule.saveChat).not.toHaveBeenCalled();
|
|
101
|
-
expect(result.pausedAtStepIndex).toBeUndefined();
|
|
102
|
-
expect(result.messages.map((m) => m.id)).toContain("a1");
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe("processUiMessagesFlow (session-backed)", () => {
|
|
107
|
-
it("passes the session, rewrites step ids, and persists at each step boundary", async () => {
|
|
108
|
-
const { provider, generateStream } = makeStubProvider([
|
|
109
|
-
[assistant("a1", [{ type: "text", text: "one" }])],
|
|
110
|
-
[assistant("a2", [{ type: "text", text: "two" }])],
|
|
111
|
-
]);
|
|
112
|
-
await processUiMessagesFlow({
|
|
113
|
-
...baseArgs(provider),
|
|
114
|
-
inputMessages: [step("s1", "first"), step("s2", "second")],
|
|
115
|
-
sessionId: "sess-1",
|
|
116
|
-
});
|
|
117
|
-
expect(generateStream).toHaveBeenCalledTimes(2);
|
|
118
|
-
for (const call of generateStream.mock.calls) {
|
|
119
|
-
expect(call[0].session).toBe("sess-1");
|
|
120
|
-
// steps_json ids repeat across runs — persisted ids must be fresh:
|
|
121
|
-
expect(call[0].message.id).toMatch(/^wfmsg-/);
|
|
122
|
-
}
|
|
123
|
-
expect(providerModule.saveChat).toHaveBeenCalledTimes(2);
|
|
124
|
-
expect(providerModule.saveChat.mock.calls[0][0]).toMatchObject({ session: "sess-1", user: 7 });
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("drops the blanket approvedTools when respectToolApprovals is set", async () => {
|
|
128
|
-
const { provider, generateStream } = makeStubProvider([[assistant("a1", [{ type: "text", text: "ok" }])]]);
|
|
129
|
-
await processUiMessagesFlow({
|
|
130
|
-
...baseArgs(provider),
|
|
131
|
-
inputMessages: [step("s1", "x")],
|
|
132
|
-
sessionId: "sess-1",
|
|
133
|
-
respectToolApprovals: true,
|
|
134
|
-
});
|
|
135
|
-
expect(generateStream.mock.calls[0][0].approvedTools).toBeUndefined();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("pauses at the step whose final message requests approval and skips later steps", async () => {
|
|
139
|
-
const { provider, generateStream } = makeStubProvider([
|
|
140
|
-
[assistant("a1", [approvalPart])],
|
|
141
|
-
[assistant("a2", [{ type: "text", text: "never reached" }])],
|
|
142
|
-
]);
|
|
143
|
-
const result = await processUiMessagesFlow({
|
|
144
|
-
...baseArgs(provider),
|
|
145
|
-
inputMessages: [step("s1", "gated"), step("s2", "after")],
|
|
146
|
-
sessionId: "sess-1",
|
|
147
|
-
respectToolApprovals: true,
|
|
148
|
-
});
|
|
149
|
-
expect(result.pausedAtStepIndex).toBe(0);
|
|
150
|
-
expect(generateStream).toHaveBeenCalledTimes(1);
|
|
151
|
-
// The paused transcript was persisted before returning:
|
|
152
|
-
expect(providerModule.saveChat).toHaveBeenCalledTimes(1);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("resumeFromIndex skips completed steps and reloads history from agent_messages", async () => {
|
|
156
|
-
providerModule.getAgentMessages.mockResolvedValueOnce([
|
|
157
|
-
{ content: JSON.stringify(step("old-1", "first")) },
|
|
158
|
-
{ content: JSON.stringify(assistant("old-a1", [{ type: "text", text: "done" }])) },
|
|
159
|
-
]);
|
|
160
|
-
const { provider, generateStream } = makeStubProvider([
|
|
161
|
-
[assistant("a2", [{ type: "text", text: "resumed" }])],
|
|
162
|
-
]);
|
|
163
|
-
const result = await processUiMessagesFlow({
|
|
164
|
-
...baseArgs(provider),
|
|
165
|
-
inputMessages: [step("s1", "first"), step("s2", "second")],
|
|
166
|
-
sessionId: "sess-1",
|
|
167
|
-
resumeFromIndex: 1,
|
|
168
|
-
});
|
|
169
|
-
expect(providerModule.getAgentMessages).toHaveBeenCalledWith({
|
|
170
|
-
session: "sess-1",
|
|
171
|
-
includeAllUsers: true,
|
|
172
|
-
});
|
|
173
|
-
expect(generateStream).toHaveBeenCalledTimes(1); // only step index 1
|
|
174
|
-
expect(generateStream.mock.calls[0][0].previousMessages.map((m: UIMessage) => m.id)).toEqual([
|
|
175
|
-
"old-1",
|
|
176
|
-
"old-a1",
|
|
177
|
-
]);
|
|
178
|
-
expect(result.messages.map((m) => m.id)).toContain("a2");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("wraps step failures in FlowStepError carrying the failing step index", async () => {
|
|
182
|
-
const { provider } = makeStubProvider([
|
|
183
|
-
[assistant("a1", [{ type: "text", text: "ok" }])],
|
|
184
|
-
null, // step 1 explodes
|
|
185
|
-
]);
|
|
186
|
-
const promise = processUiMessagesFlow({
|
|
187
|
-
...baseArgs(provider),
|
|
188
|
-
inputMessages: [step("s1", "one"), step("s2", "two")],
|
|
189
|
-
sessionId: "sess-1",
|
|
190
|
-
});
|
|
191
|
-
await expect(promise).rejects.toThrow("provider exploded");
|
|
192
|
-
await promise.catch((error: unknown) => {
|
|
193
|
-
expect(error).toBeInstanceOf(FlowStepError);
|
|
194
|
-
expect((error as FlowStepError).stepIndex).toBe(1);
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("a rerun after a step-1 failure persists only steps >= 1 — no duplicate messages (spec §5.4/§9)", async () => {
|
|
199
|
-
// First run: step 0 succeeds (one boundary persist), step 1 explodes.
|
|
200
|
-
const first = makeStubProvider([
|
|
201
|
-
[assistant("a1", [{ type: "text", text: "one" }])],
|
|
202
|
-
null, // step 1 explodes
|
|
203
|
-
]);
|
|
204
|
-
await expect(
|
|
205
|
-
processUiMessagesFlow({
|
|
206
|
-
...baseArgs(first.provider),
|
|
207
|
-
inputMessages: [step("s1", "one"), step("s2", "two")],
|
|
208
|
-
sessionId: "sess-1",
|
|
209
|
-
}),
|
|
210
|
-
).rejects.toThrow("provider exploded");
|
|
211
|
-
expect(providerModule.saveChat).toHaveBeenCalledTimes(1); // step 0 only
|
|
212
|
-
|
|
213
|
-
// Rerun from the failed step (what the worker's retry loop does with
|
|
214
|
-
// FlowStepError.stepIndex): prior history reloads from agent_messages;
|
|
215
|
-
// step 0 must NOT run or persist again.
|
|
216
|
-
providerModule.saveChat.mockClear();
|
|
217
|
-
providerModule.getAgentMessages.mockResolvedValueOnce([
|
|
218
|
-
{ content: JSON.stringify(step("old-s1", "one")) },
|
|
219
|
-
{ content: JSON.stringify(assistant("a1", [{ type: "text", text: "one" }])) },
|
|
220
|
-
]);
|
|
221
|
-
const second = makeStubProvider([[assistant("a2", [{ type: "text", text: "two" }])]]);
|
|
222
|
-
await processUiMessagesFlow({
|
|
223
|
-
...baseArgs(second.provider),
|
|
224
|
-
inputMessages: [step("s1", "one"), step("s2", "two")],
|
|
225
|
-
sessionId: "sess-1",
|
|
226
|
-
resumeFromIndex: 1,
|
|
227
|
-
});
|
|
228
|
-
expect(second.generateStream).toHaveBeenCalledTimes(1); // only step index 1
|
|
229
|
-
expect(providerModule.saveChat).toHaveBeenCalledTimes(1); // only the step-1 boundary
|
|
230
|
-
const persisted = providerModule.saveChat.mock.calls[0][0].messages as UIMessage[];
|
|
231
|
-
expect(persisted.map((m) => m.id)).toContain("a2");
|
|
232
|
-
// Step 0's message reaches saveChat only via the reloaded history (same
|
|
233
|
-
// ids — saveChat's message_id merge keeps it a no-op), never as a re-run.
|
|
234
|
-
expect(persisted.filter((m) => m.id === "a1")).toHaveLength(1);
|
|
235
|
-
});
|
|
236
|
-
});
|