@exulu/backend 3.0.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.
@@ -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
- });