@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.
@@ -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: () => ({ providers: [] }) } }));
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,
@@ -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
- // Generated server-side: {routine-slug}-{8 hex}@{inbound_domain}.
459
- // Real UNIQUE column (not JSON) because the webhook resolves
460
- // triggers by recipient address.
461
- name: "address",
462
- type: "text",
463
- required: true,
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 type { ExuluProvider } from "@SRC/exulu/provider.ts";
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, providers);
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, providers);
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, providers);
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 an email intake job.", bullmqJob.name);
1089
-
1090
- if (!data.inputs?.s3Key) {
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
- recipient: data.inputs.recipient,
1101
+ triggerId: data.inputs.triggerId,
1102
+ format: data.inputs.format === "json" ? "json" : "eml",
1098
1103
  },
1099
- { config, providers },
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
- const providerapikey = resolved.apiKey;
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 provider.generateStream({
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "3.1.0",
4
+ "version": "3.2.0",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -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
- });