@exulu/backend 2.3.0 → 3.1.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/dist/{chunk-2242AI5L.js → chunk-ZDH5S2WF.js} +875 -277
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-C7L4PY6P.js → convert-exulu-tools-to-ai-sdk-tools-FN6WZSIQ.js} +1 -1
- package/dist/index.cjs +9337 -5830
- package/dist/index.d.cts +45 -9
- package/dist/index.d.ts +45 -9
- package/dist/index.js +8080 -5291
- package/ee/agentic-retrieval/pipeline/hyde.test.ts +1 -1
- package/ee/agentic-retrieval/pipeline/hyde.ts +6 -3
- package/ee/agentic-retrieval/pipeline/memory.test.ts +5 -1
- package/ee/agentic-retrieval/pipeline/memory.ts +71 -104
- package/ee/agentic-retrieval/pipeline/micro-call.test.ts +112 -0
- package/ee/agentic-retrieval/pipeline/micro-call.ts +98 -0
- package/ee/agentic-retrieval/pipeline/prefilter.test.ts +1 -0
- package/ee/agentic-retrieval/pipeline/prefilter.ts +11 -20
- package/ee/agentic-retrieval/pipeline/routing.test.ts +44 -1
- package/ee/agentic-retrieval/pipeline/routing.ts +31 -56
- package/ee/queues/decorator.ts +11 -0
- package/ee/queues/prune-job-results.test.ts +41 -0
- package/ee/queues/prune-job-results.ts +5 -4
- package/ee/schemas.ts +96 -1
- package/ee/workers.flow.test.ts +236 -0
- package/ee/workers.ts +409 -168
- package/package.json +6 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { generateText, Output } from "ai";
|
|
2
1
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
2
|
+
import { microCall } from "./micro-call";
|
|
4
3
|
import { fuzzyPrefilter } from "./prefilter";
|
|
5
4
|
import { normalizeFileName } from "./text-utils";
|
|
6
5
|
import type { RoutingRule } from "./config";
|
|
@@ -103,25 +102,17 @@ export async function runRoutingPhase(opts: {
|
|
|
103
102
|
const [docPageRaw, explicitKBRaw] = await Promise.all([
|
|
104
103
|
(async () => {
|
|
105
104
|
try {
|
|
106
|
-
return await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
hasPageHint: z.boolean(),
|
|
118
|
-
pageNumber: z.number().int().nullable().optional(),
|
|
119
|
-
}),
|
|
120
|
-
}),
|
|
121
|
-
maxOutputTokens: 300,
|
|
122
|
-
}),
|
|
123
|
-
3,
|
|
124
|
-
);
|
|
105
|
+
return await microCall({
|
|
106
|
+
model,
|
|
107
|
+
system: buildDocPagePrompt(knownIdentifiers),
|
|
108
|
+
messages: [{ role: "user", content: question }],
|
|
109
|
+
schema: z.object({
|
|
110
|
+
hasFilenameHint: z.boolean(),
|
|
111
|
+
filenameHints: z.array(z.string()).optional(),
|
|
112
|
+
hasPageHint: z.boolean(),
|
|
113
|
+
pageNumber: z.number().int().nullable().optional(),
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
125
116
|
} catch (err) {
|
|
126
117
|
steps.push({ text: "Doc/page detection failed — skipping filename and page hints." });
|
|
127
118
|
return {
|
|
@@ -136,24 +127,16 @@ export async function runRoutingPhase(opts: {
|
|
|
136
127
|
})(),
|
|
137
128
|
(async () => {
|
|
138
129
|
try {
|
|
139
|
-
return await
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
),
|
|
150
|
-
}),
|
|
151
|
-
}),
|
|
152
|
-
messages: [{ role: "user", content: question }],
|
|
153
|
-
maxOutputTokens: 200,
|
|
154
|
-
}),
|
|
155
|
-
3,
|
|
156
|
-
);
|
|
130
|
+
return await microCall({
|
|
131
|
+
model,
|
|
132
|
+
system: kbSystemPrompt,
|
|
133
|
+
schema: z.object({
|
|
134
|
+
explicitlyRequestedKnowledgeBases: z.array(
|
|
135
|
+
z.enum(enabledContexts.map((c) => c.id) as [string, ...string[]]),
|
|
136
|
+
),
|
|
137
|
+
}),
|
|
138
|
+
messages: [{ role: "user", content: question }],
|
|
139
|
+
});
|
|
157
140
|
} catch (err) {
|
|
158
141
|
return { output: { explicitlyRequestedKnowledgeBases: [] as string[] } };
|
|
159
142
|
}
|
|
@@ -267,23 +250,15 @@ export async function runRoutingPhase(opts: {
|
|
|
267
250
|
}
|
|
268
251
|
|
|
269
252
|
try {
|
|
270
|
-
const { output: classified } = await
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
ruleId: z.enum(ruleIds as [string, ...string[]]),
|
|
280
|
-
reason: z.string(),
|
|
281
|
-
}),
|
|
282
|
-
}),
|
|
283
|
-
maxOutputTokens: 200,
|
|
284
|
-
}),
|
|
285
|
-
3,
|
|
286
|
-
);
|
|
253
|
+
const { output: classified } = await microCall({
|
|
254
|
+
model,
|
|
255
|
+
system: classifyPrompt,
|
|
256
|
+
messages: [{ role: "user", content: question }],
|
|
257
|
+
schema: z.object({
|
|
258
|
+
ruleId: z.enum(ruleIds as [string, ...string[]]),
|
|
259
|
+
reason: z.string(),
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
287
262
|
|
|
288
263
|
const matchedRule = routingRules.find((r) => r.id === classified.ruleId);
|
|
289
264
|
if (matchedRule) {
|
package/ee/queues/decorator.ts
CHANGED
|
@@ -60,6 +60,17 @@ export type BullMqJobData = {
|
|
|
60
60
|
evaluation?: string;
|
|
61
61
|
item?: string;
|
|
62
62
|
context?: string;
|
|
63
|
+
// Email-triggered routines (spec 2026-07-15): session-backed workflow runs.
|
|
64
|
+
/** agent_sessions id to run in; when absent the worker creates one. */
|
|
65
|
+
session?: string;
|
|
66
|
+
/** Existing job_results row to UPDATE instead of INSERT (continuation/retry/email intake). */
|
|
67
|
+
jobResultId?: string;
|
|
68
|
+
/** Skip steps before this index (resume after approval pause / retry-from-step). */
|
|
69
|
+
resumeFromIndex?: number;
|
|
70
|
+
/** Persisted to job_results.trigger — run provenance for the runs views. */
|
|
71
|
+
triggerSource?: "email" | "schedule" | "manual" | "api";
|
|
72
|
+
/** Persisted to job_results.trigger_metadata (email: from/subject/message_id; schedule: cron). */
|
|
73
|
+
triggerMetadata?: Record<string, unknown>;
|
|
63
74
|
};
|
|
64
75
|
|
|
65
76
|
export const bullmqDecorator = async ({
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { TERMINAL_JOB_STATES } from "@EXULU_TYPES/enums/jobs";
|
|
2
|
+
import { maybePruneJobResults } from "./prune-job-results";
|
|
3
|
+
|
|
4
|
+
describe("TERMINAL_JOB_STATES", () => {
|
|
5
|
+
it("is the single source of truth for prunable terminal states", () => {
|
|
6
|
+
expect(TERMINAL_JOB_STATES).toEqual(["completed", "failed", "filtered", "cancelled"]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("never contains live or paused states", () => {
|
|
10
|
+
for (const state of ["waiting", "active", "delayed", "paused", "waiting_approval", "stuck"]) {
|
|
11
|
+
expect(TERMINAL_JOB_STATES).not.toContain(state);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("maybePruneJobResults", () => {
|
|
17
|
+
it("prunes only TERMINAL_JOB_STATES rows (every 100th call)", async () => {
|
|
18
|
+
const whereInCalls: any[][] = [];
|
|
19
|
+
const builder: any = {
|
|
20
|
+
whereIn: (...args: any[]) => {
|
|
21
|
+
whereInCalls.push(args);
|
|
22
|
+
return builder;
|
|
23
|
+
},
|
|
24
|
+
orderBy: () => builder,
|
|
25
|
+
offset: () => builder,
|
|
26
|
+
limit: () => builder,
|
|
27
|
+
first: async () => undefined, // under cap: nothing to delete
|
|
28
|
+
where: () => builder,
|
|
29
|
+
del: async () => 0,
|
|
30
|
+
};
|
|
31
|
+
const db: any = jest.fn(() => builder);
|
|
32
|
+
|
|
33
|
+
// The module-level counter only reaches the prune body every 100th call.
|
|
34
|
+
for (let i = 0; i < 100; i++) {
|
|
35
|
+
await maybePruneJobResults(db);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
expect(whereInCalls.length).toBeGreaterThanOrEqual(1);
|
|
39
|
+
expect(whereInCalls[0]).toEqual(["state", TERMINAL_JOB_STATES]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* We now write a job_results row at enqueue time, so the table grows faster.
|
|
5
5
|
* To bound it, every PRUNE_EVERY-th call we delete the oldest terminal rows
|
|
6
|
-
* (
|
|
6
|
+
* (states in TERMINAL_JOB_STATES) beyond the newest MAX_TERMINAL — keeping a rolling
|
|
7
7
|
* window of recent finished jobs. Waiting/active/delayed rows are never
|
|
8
8
|
* pruned (they're still live).
|
|
9
9
|
*
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* overlapping runs.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { TERMINAL_JOB_STATES } from "@EXULU_TYPES/enums/jobs";
|
|
17
|
+
|
|
16
18
|
const MAX_TERMINAL = 10_000;
|
|
17
19
|
const PRUNE_EVERY = 100;
|
|
18
|
-
const TERMINAL_STATES = ["failed", "completed"];
|
|
19
20
|
|
|
20
21
|
let sinceLastPrune = 0;
|
|
21
22
|
let pruning = false;
|
|
@@ -30,7 +31,7 @@ export async function maybePruneJobResults(db: any): Promise<void> {
|
|
|
30
31
|
// and everything older. Dialect-agnostic (knex offset/limit) so it works on
|
|
31
32
|
// both Postgres and MySQL.
|
|
32
33
|
const boundary = await db("job_results")
|
|
33
|
-
.whereIn("state",
|
|
34
|
+
.whereIn("state", TERMINAL_JOB_STATES)
|
|
34
35
|
.orderBy("createdAt", "desc")
|
|
35
36
|
.offset(MAX_TERMINAL)
|
|
36
37
|
.limit(1)
|
|
@@ -38,7 +39,7 @@ export async function maybePruneJobResults(db: any): Promise<void> {
|
|
|
38
39
|
|
|
39
40
|
if (boundary?.createdAt) {
|
|
40
41
|
const deleted = await db("job_results")
|
|
41
|
-
.whereIn("state",
|
|
42
|
+
.whereIn("state", TERMINAL_JOB_STATES)
|
|
42
43
|
.where("createdAt", "<=", boundary.createdAt)
|
|
43
44
|
.del();
|
|
44
45
|
if (deleted) {
|
package/ee/schemas.ts
CHANGED
|
@@ -260,6 +260,26 @@ export const jobResultsSchema: ExuluTableDefinition = {
|
|
|
260
260
|
name: "type",
|
|
261
261
|
type: "text",
|
|
262
262
|
},
|
|
263
|
+
// Email-triggered routines (spec 2026-07-15 §3.3): run provenance +
|
|
264
|
+
// session cross-link. `workflow` replaces label-substring filtering
|
|
265
|
+
// (indexed via the composite index created in init-exulu-db.ts).
|
|
266
|
+
// Pre-migration rows keep trigger = NULL (displayed as "—").
|
|
267
|
+
{
|
|
268
|
+
name: "trigger",
|
|
269
|
+
type: "text",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "trigger_metadata",
|
|
273
|
+
type: "json",
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: "session",
|
|
277
|
+
type: "text",
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: "workflow",
|
|
281
|
+
type: "text",
|
|
282
|
+
},
|
|
263
283
|
],
|
|
264
284
|
};
|
|
265
285
|
|
|
@@ -392,5 +412,80 @@ export const workflowTemplatesSchema: ExuluTableDefinition = {
|
|
|
392
412
|
type: "json",
|
|
393
413
|
required: true,
|
|
394
414
|
},
|
|
415
|
+
// Escape hatch for the approval behavior change (spec §5.2): when true
|
|
416
|
+
// the run keeps the legacy blanket tool pre-approval and never pauses.
|
|
417
|
+
{
|
|
418
|
+
name: "auto_approve_tools",
|
|
419
|
+
type: "boolean",
|
|
420
|
+
default: false,
|
|
421
|
+
},
|
|
395
422
|
],
|
|
396
|
-
};
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Email-triggered routines (spec §3.1): one inbound trigger per routine.
|
|
426
|
+
// RBAC is false — access is checked via the parent workflow_templates row
|
|
427
|
+
// (routine read for listing, routine write + workflows:write role for CRUD),
|
|
428
|
+
// resolved explicitly in the custom GraphQL resolvers. graphql: false keeps
|
|
429
|
+
// the auto-CRUD generator away from this table; the API surface is the
|
|
430
|
+
// custom workflowTriggers / upsertWorkflowEmailTrigger / deleteWorkflowTrigger
|
|
431
|
+
// resolvers only.
|
|
432
|
+
export const workflowTriggersSchema: ExuluTableDefinition = {
|
|
433
|
+
type: "workflow_triggers",
|
|
434
|
+
name: {
|
|
435
|
+
plural: "workflow_triggers",
|
|
436
|
+
singular: "workflow_trigger",
|
|
437
|
+
},
|
|
438
|
+
RBAC: false,
|
|
439
|
+
graphql: false,
|
|
440
|
+
fields: [
|
|
441
|
+
{
|
|
442
|
+
name: "workflow",
|
|
443
|
+
type: "uuid",
|
|
444
|
+
required: true,
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
// 'email' for now; extensible ('webhook' later).
|
|
448
|
+
name: "type",
|
|
449
|
+
type: "text",
|
|
450
|
+
required: true,
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
name: "enabled",
|
|
454
|
+
type: "boolean",
|
|
455
|
+
default: false,
|
|
456
|
+
},
|
|
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
|
+
},
|
|
467
|
+
{
|
|
468
|
+
// allowed_senders / filters / filtered_run_retention /
|
|
469
|
+
// rate_limit_per_hour / sender_rate_limit_per_hour (spec §3.1).
|
|
470
|
+
name: "config",
|
|
471
|
+
type: "json",
|
|
472
|
+
required: true,
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
// Captured from the admin who saves the trigger; email runs execute
|
|
476
|
+
// under this identity (same principle as cron).
|
|
477
|
+
name: "run_as_user",
|
|
478
|
+
type: "number",
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
name: "run_as_role",
|
|
482
|
+
type: "uuid",
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
// RBAC:false means addCoreFields does not add created_by; add it
|
|
486
|
+
// explicitly (audit trail, spec §3.1 core fields).
|
|
487
|
+
name: "created_by",
|
|
488
|
+
type: "number",
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
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
|
+
});
|