@arcote.tech/arc-chat 0.7.20 → 0.7.21
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 +175 -101
- package/package.json +7 -7
- package/src/aggregates/message.ts +83 -2
- package/src/chat-builder.ts +1 -0
- package/src/listeners/ai-generation-listener.ts +18 -3
- package/src/ordering.test.ts +118 -0
- package/src/ordering.ts +88 -0
- package/src/react/chat-component.tsx +189 -879
- package/src/react/derive-timeline.test.ts +654 -0
- package/src/react/derive-timeline.ts +416 -0
- package/src/react/use-assistant-overlays.ts +269 -0
- package/src/routes/chat-stream-route.ts +19 -5
- package/src/streaming/blocks-reducer.test.ts +126 -0
- package/src/streaming/blocks-reducer.ts +88 -0
- package/src/streaming/stream-registry.test.ts +64 -0
- package/src/streaming/stream-registry.ts +21 -49
- package/src/tools/ask-questions.tsx +7 -4
- package/src/react/use-chat.ts +0 -1
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
deriveTimeline,
|
|
4
|
+
isPendingSendSettled,
|
|
5
|
+
type AssistantOverlay,
|
|
6
|
+
type DeriveTimelineInput,
|
|
7
|
+
} from "./derive-timeline";
|
|
8
|
+
|
|
9
|
+
// Server tools w testach: nazwy zaczynające się od "srv".
|
|
10
|
+
const isServerTool = (name: string) => name.startsWith("srv");
|
|
11
|
+
|
|
12
|
+
const T0 = 1_750_000_000_000;
|
|
13
|
+
const NOW = T0 + 30_000;
|
|
14
|
+
|
|
15
|
+
function input(partial: Partial<DeriveTimelineInput>): DeriveTimelineInput {
|
|
16
|
+
return {
|
|
17
|
+
history: [],
|
|
18
|
+
overlays: new Map(),
|
|
19
|
+
pendingSends: [],
|
|
20
|
+
pendingToolResults: new Map(),
|
|
21
|
+
isServerTool,
|
|
22
|
+
now: NOW,
|
|
23
|
+
...partial,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const userRow = (id: string, content: string, offset = 0, sessionId = "s1") => ({
|
|
28
|
+
_id: id,
|
|
29
|
+
role: "user",
|
|
30
|
+
content,
|
|
31
|
+
sessionId,
|
|
32
|
+
createdAt: new Date(T0 + offset),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const assistantRow = (
|
|
36
|
+
id: string,
|
|
37
|
+
opts: {
|
|
38
|
+
offset?: number;
|
|
39
|
+
sessionId?: string;
|
|
40
|
+
isGenerating?: boolean;
|
|
41
|
+
interrupted?: boolean;
|
|
42
|
+
blocks?: unknown[];
|
|
43
|
+
error?: string;
|
|
44
|
+
} = {},
|
|
45
|
+
) => ({
|
|
46
|
+
_id: id,
|
|
47
|
+
role: "assistant",
|
|
48
|
+
sessionId: opts.sessionId ?? "s1",
|
|
49
|
+
isGenerating: opts.isGenerating ?? false,
|
|
50
|
+
interrupted: opts.interrupted,
|
|
51
|
+
blocks: opts.blocks ? JSON.stringify(opts.blocks) : undefined,
|
|
52
|
+
error: opts.error,
|
|
53
|
+
createdAt: new Date(T0 + (opts.offset ?? 0)),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const toolResultRow = (
|
|
57
|
+
id: string,
|
|
58
|
+
toolCallId: string,
|
|
59
|
+
toolName: string,
|
|
60
|
+
content: string,
|
|
61
|
+
offset = 0,
|
|
62
|
+
sessionId = "s1",
|
|
63
|
+
) => ({
|
|
64
|
+
_id: id,
|
|
65
|
+
role: "tool_result",
|
|
66
|
+
toolCallId,
|
|
67
|
+
toolName,
|
|
68
|
+
content,
|
|
69
|
+
sessionId,
|
|
70
|
+
createdAt: new Date(T0 + offset),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const overlay = (
|
|
74
|
+
status: AssistantOverlay["status"],
|
|
75
|
+
blocks: any[] = [],
|
|
76
|
+
): AssistantOverlay => ({ blocks, status });
|
|
77
|
+
|
|
78
|
+
describe("deriveTimeline — podstawowy przepływ", () => {
|
|
79
|
+
it("generujący row bez overlaya → streaming placeholder, busy", () => {
|
|
80
|
+
const { items, busy } = deriveTimeline(
|
|
81
|
+
input({
|
|
82
|
+
history: [
|
|
83
|
+
// assistantTurnStarted emitowany PRZED messageSent — placeholder
|
|
84
|
+
// ma wcześniejszy timestamp; ordering musi dać user → assistant.
|
|
85
|
+
assistantRow("a1", { offset: 0, isGenerating: true }),
|
|
86
|
+
userRow("u1", "Cześć", 2),
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
expect(items.map((i) => i.id)).toEqual(["u1", "a1_t0"]);
|
|
91
|
+
expect(items[1]).toMatchObject({
|
|
92
|
+
type: "message",
|
|
93
|
+
role: "assistant",
|
|
94
|
+
content: "",
|
|
95
|
+
isStreaming: true,
|
|
96
|
+
});
|
|
97
|
+
expect(busy).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("generujący row z overlayem live → bloki overlaya, caret na ostatnim", () => {
|
|
101
|
+
const { items, busy } = deriveTimeline(
|
|
102
|
+
input({
|
|
103
|
+
history: [
|
|
104
|
+
assistantRow("a1", { offset: 0, isGenerating: true }),
|
|
105
|
+
userRow("u1", "Cześć", 2),
|
|
106
|
+
],
|
|
107
|
+
overlays: new Map([
|
|
108
|
+
[
|
|
109
|
+
"a1",
|
|
110
|
+
overlay("live", [
|
|
111
|
+
{ type: "text", text: "Już " },
|
|
112
|
+
{ type: "tool_call", id: "tc1", name: "srvSearch", arguments: {} },
|
|
113
|
+
{ type: "text", text: "sprawdzam" },
|
|
114
|
+
]),
|
|
115
|
+
],
|
|
116
|
+
]),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
expect(items.map((i) => i.id)).toEqual(["u1", "a1_t0", "tc1", "a1_t1"]);
|
|
120
|
+
expect((items[1] as any).isStreaming).toBe(false); // nie ostatni blok
|
|
121
|
+
expect((items[3] as any).isStreaming).toBe(true); // ostatni blok
|
|
122
|
+
expect(items[2]).toMatchObject({
|
|
123
|
+
type: "tool",
|
|
124
|
+
status: "executing",
|
|
125
|
+
calling: true,
|
|
126
|
+
});
|
|
127
|
+
expect(busy).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("overlay done → caret zdjęty, bubble zostaje do flipa DB", () => {
|
|
131
|
+
const { items } = deriveTimeline(
|
|
132
|
+
input({
|
|
133
|
+
history: [
|
|
134
|
+
assistantRow("a1", { offset: 0, isGenerating: true }),
|
|
135
|
+
userRow("u1", "Cześć", 2),
|
|
136
|
+
],
|
|
137
|
+
overlays: new Map([
|
|
138
|
+
["a1", overlay("done", [{ type: "text", text: "Gotowe" }])],
|
|
139
|
+
]),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
expect(items[1]).toMatchObject({
|
|
143
|
+
content: "Gotowe",
|
|
144
|
+
isStreaming: false,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("zamknięty row renderuje finalne blocks z IDENTYCZNYMI kluczami co overlay", () => {
|
|
149
|
+
const blocks = [
|
|
150
|
+
{ type: "text", text: "Odpowiedź" },
|
|
151
|
+
{ type: "tool_call", id: "tc1", name: "srvSearch", arguments: { q: 1 } },
|
|
152
|
+
];
|
|
153
|
+
const streaming = deriveTimeline(
|
|
154
|
+
input({
|
|
155
|
+
history: [
|
|
156
|
+
assistantRow("a1", { offset: 0, isGenerating: true }),
|
|
157
|
+
userRow("u1", "Hej", 2),
|
|
158
|
+
],
|
|
159
|
+
overlays: new Map([["a1", overlay("live", blocks)]]),
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
const closed = deriveTimeline(
|
|
163
|
+
input({
|
|
164
|
+
history: [
|
|
165
|
+
assistantRow("a1", { offset: 0, blocks }),
|
|
166
|
+
userRow("u1", "Hej", 2),
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
expect(streaming.items.map((i) => i.id)).toEqual(
|
|
171
|
+
closed.items.map((i) => i.id),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("deriveTimeline — dawne race'y jako zwykłe przypadki", () => {
|
|
177
|
+
it("flip DB wyprzedził SSE done (stary produkcyjny hang): stale overlay ignorowany", () => {
|
|
178
|
+
// Row już zamknięty z finalnymi blocks, ale overlay wciąż wisi w mapie
|
|
179
|
+
// (hook nie zdążył go GC-nąć). Derywacja MUSI brać DB.
|
|
180
|
+
const { items, busy } = deriveTimeline(
|
|
181
|
+
input({
|
|
182
|
+
history: [
|
|
183
|
+
assistantRow("a1", {
|
|
184
|
+
offset: 0,
|
|
185
|
+
blocks: [{ type: "text", text: "Final z DB" }],
|
|
186
|
+
}),
|
|
187
|
+
userRow("u1", "Hej", 2),
|
|
188
|
+
],
|
|
189
|
+
overlays: new Map([
|
|
190
|
+
["a1", overlay("live", [{ type: "text", text: "Stare z SSE" }])],
|
|
191
|
+
]),
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
expect((items[1] as any).content).toBe("Final z DB");
|
|
195
|
+
expect((items[1] as any).isStreaming).toBeFalsy();
|
|
196
|
+
expect(busy).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("zgubione done: row zamknięty, overlay nigdy nie dostał done → wynik identyczny", () => {
|
|
200
|
+
const withStale = deriveTimeline(
|
|
201
|
+
input({
|
|
202
|
+
history: [
|
|
203
|
+
assistantRow("a1", { offset: 0, blocks: [{ type: "text", text: "X" }] }),
|
|
204
|
+
],
|
|
205
|
+
overlays: new Map([
|
|
206
|
+
["a1", overlay("live", [{ type: "text", text: "Częściowe" }])],
|
|
207
|
+
]),
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
const clean = deriveTimeline(
|
|
211
|
+
input({
|
|
212
|
+
history: [
|
|
213
|
+
assistantRow("a1", { offset: 0, blocks: [{ type: "text", text: "X" }] }),
|
|
214
|
+
],
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
expect(withStale.items).toEqual(clean.items);
|
|
218
|
+
expect(withStale.busy).toBe(clean.busy);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("backstop tool_result (askQuestions po odpowiedzi): wynik z DB widoczny mimo trwającego nowego turnu", () => {
|
|
222
|
+
// respondToTool: tool_result row + NOWY generujący row. Stary kod
|
|
223
|
+
// zamrażał timeline guardem isStreaming — derywacja bierze resultMap
|
|
224
|
+
// z DB zawsze.
|
|
225
|
+
const { items } = deriveTimeline(
|
|
226
|
+
input({
|
|
227
|
+
history: [
|
|
228
|
+
userRow("u1", "Hej", 0),
|
|
229
|
+
assistantRow("a1", {
|
|
230
|
+
offset: 100,
|
|
231
|
+
blocks: [
|
|
232
|
+
{ type: "tool_call", id: "tc1", name: "askQuestions", arguments: {} },
|
|
233
|
+
],
|
|
234
|
+
}),
|
|
235
|
+
assistantRow("a2", { offset: 5_000, sessionId: "s2", isGenerating: true }),
|
|
236
|
+
toolResultRow("tr1", "tc1", "askQuestions", '{"answer":"tak"}', 5_001, "s2"),
|
|
237
|
+
],
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
const toolItem = items.find((i) => i.type === "tool") as any;
|
|
241
|
+
expect(toolItem.calling).toBe(false);
|
|
242
|
+
expect(toolItem.status).toBe("complete");
|
|
243
|
+
expect(toolItem.result).toEqual({ answer: "tak" });
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("deriveTimeline — interrupted i error", () => {
|
|
248
|
+
it("DB interrupted → item interrupted, nie busy", () => {
|
|
249
|
+
const { items, busy } = deriveTimeline(
|
|
250
|
+
input({
|
|
251
|
+
history: [
|
|
252
|
+
userRow("u1", "Hej", 0),
|
|
253
|
+
assistantRow("a1", { offset: 1, interrupted: true }),
|
|
254
|
+
],
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
expect(items[1]).toMatchObject({ type: "interrupted", messageId: "a1" });
|
|
258
|
+
expect(busy).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("overlay gone + isGenerating (lazy repair jeszcze nie przeszedł) → interrupted, nie busy", () => {
|
|
262
|
+
const { items, busy } = deriveTimeline(
|
|
263
|
+
input({
|
|
264
|
+
history: [
|
|
265
|
+
userRow("u1", "Hej", 0),
|
|
266
|
+
assistantRow("a1", { offset: 1, isGenerating: true }),
|
|
267
|
+
],
|
|
268
|
+
overlays: new Map([["a1", overlay("gone")]]),
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
expect(items[1]).toMatchObject({ type: "interrupted", messageId: "a1" });
|
|
272
|
+
expect(busy).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("persystowany błąd generacji renderuje się z DB (przeżywa F5)", () => {
|
|
276
|
+
const { items, busy } = deriveTimeline(
|
|
277
|
+
input({
|
|
278
|
+
history: [
|
|
279
|
+
userRow("u1", "Hej", 0),
|
|
280
|
+
assistantRow("a1", { offset: 1, blocks: [], error: "AI error: boom" }),
|
|
281
|
+
],
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
expect(items[1]).toMatchObject({
|
|
285
|
+
type: "message",
|
|
286
|
+
id: "a1_error",
|
|
287
|
+
content: "AI error: boom",
|
|
288
|
+
});
|
|
289
|
+
expect(busy).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("deriveTimeline — busy (multi-turn tool loop)", () => {
|
|
294
|
+
const closedWithServerCall = assistantRow("a1", {
|
|
295
|
+
offset: 0,
|
|
296
|
+
blocks: [
|
|
297
|
+
{ type: "text", text: "Sprawdzam" },
|
|
298
|
+
{ type: "tool_call", id: "tc1", name: "srvSearch", arguments: {} },
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("Gap A: server tool bez wyniku → busy (świeże)", () => {
|
|
303
|
+
const { busy } = deriveTimeline(
|
|
304
|
+
input({ history: [userRow("u1", "Hej", -10), closedWithServerCall] }),
|
|
305
|
+
);
|
|
306
|
+
expect(busy).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("Gap A: cutoff świeżości — stary turn bez wyniku nie blokuje inputu", () => {
|
|
310
|
+
const { busy } = deriveTimeline(
|
|
311
|
+
input({
|
|
312
|
+
history: [userRow("u1", "Hej", -10), closedWithServerCall],
|
|
313
|
+
now: T0 + 10 * 60_000,
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
expect(busy).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("Gap B: świeży tool_result server-toola jako ostatni row → busy", () => {
|
|
320
|
+
const { busy } = deriveTimeline(
|
|
321
|
+
input({
|
|
322
|
+
history: [
|
|
323
|
+
userRow("u1", "Hej", -10),
|
|
324
|
+
closedWithServerCall,
|
|
325
|
+
toolResultRow("tr1", "tc1", "srvSearch", "{}", 3_000),
|
|
326
|
+
],
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
expect(busy).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("interactive tool bez wyniku NIE daje busy (czeka na użytkownika)", () => {
|
|
333
|
+
const { busy, hasWaitingInteractive } = deriveTimeline(
|
|
334
|
+
input({
|
|
335
|
+
history: [
|
|
336
|
+
userRow("u1", "Hej", -10),
|
|
337
|
+
assistantRow("a1", {
|
|
338
|
+
offset: 0,
|
|
339
|
+
blocks: [
|
|
340
|
+
{ type: "tool_call", id: "tc1", name: "askQuestions", arguments: {} },
|
|
341
|
+
],
|
|
342
|
+
}),
|
|
343
|
+
],
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
expect(busy).toBe(false);
|
|
347
|
+
expect(hasWaitingInteractive).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("deriveTimeline — optimistic state", () => {
|
|
352
|
+
it("pending send widoczny i busy dopóki row nie dotarł", () => {
|
|
353
|
+
const { items, busy } = deriveTimeline(
|
|
354
|
+
input({
|
|
355
|
+
pendingSends: [
|
|
356
|
+
{ localId: "p1", content: "Nowa wiadomość", sentAt: NOW - 100 },
|
|
357
|
+
],
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
expect(items).toEqual([
|
|
361
|
+
{ type: "message", id: "p1", role: "user", content: "Nowa wiadomość" },
|
|
362
|
+
]);
|
|
363
|
+
expect(busy).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("pending send znika gdy row dotarł (match po dbId i po treści)", () => {
|
|
367
|
+
const history = [userRow("u1", "Nowa wiadomość", 29_000)];
|
|
368
|
+
expect(
|
|
369
|
+
isPendingSendSettled(history, {
|
|
370
|
+
localId: "p1",
|
|
371
|
+
content: "Nowa wiadomość",
|
|
372
|
+
sentAt: NOW - 5_000,
|
|
373
|
+
dbId: "u1",
|
|
374
|
+
}),
|
|
375
|
+
).toBe(true);
|
|
376
|
+
expect(
|
|
377
|
+
isPendingSendSettled(history, {
|
|
378
|
+
localId: "p1",
|
|
379
|
+
content: "Nowa wiadomość",
|
|
380
|
+
sentAt: NOW - 5_000,
|
|
381
|
+
}),
|
|
382
|
+
).toBe(true);
|
|
383
|
+
const { items } = deriveTimeline(
|
|
384
|
+
input({
|
|
385
|
+
history,
|
|
386
|
+
pendingSends: [
|
|
387
|
+
{ localId: "p1", content: "Nowa wiadomość", sentAt: NOW - 5_000 },
|
|
388
|
+
],
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
expect(items.filter((i) => i.id === "p1")).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("optimistic tool result → answer-view natychmiast, DB wygrywa gdy dotrze", () => {
|
|
395
|
+
const history = [
|
|
396
|
+
assistantRow("a1", {
|
|
397
|
+
offset: 0,
|
|
398
|
+
blocks: [
|
|
399
|
+
{ type: "tool_call", id: "tc1", name: "askQuestions", arguments: {} },
|
|
400
|
+
],
|
|
401
|
+
}),
|
|
402
|
+
];
|
|
403
|
+
const optimistic = deriveTimeline(
|
|
404
|
+
input({
|
|
405
|
+
history,
|
|
406
|
+
pendingToolResults: new Map([["tc1", { answer: "tak" }]]),
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
const tool = optimistic.items.find((i) => i.type === "tool") as any;
|
|
410
|
+
expect(tool.calling).toBe(false);
|
|
411
|
+
expect(tool.result).toEqual({ answer: "tak" });
|
|
412
|
+
expect(optimistic.hasWaitingInteractive).toBe(false);
|
|
413
|
+
|
|
414
|
+
const withDb = deriveTimeline(
|
|
415
|
+
input({
|
|
416
|
+
history: [
|
|
417
|
+
...history,
|
|
418
|
+
toolResultRow("tr1", "tc1", "askQuestions", '{"answer":"nie"}', 5_000),
|
|
419
|
+
],
|
|
420
|
+
pendingToolResults: new Map([["tc1", { answer: "tak" }]]),
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
const dbTool = withDb.items.find((i) => i.type === "tool") as any;
|
|
424
|
+
expect(dbTool.result).toEqual({ answer: "nie" });
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("deriveTimeline — interactive tool podczas streamowania", () => {
|
|
429
|
+
const generatingWithUser = [
|
|
430
|
+
assistantRow("a1", { offset: 0, isGenerating: true }),
|
|
431
|
+
userRow("u1", "Hej", 2),
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
it("blok interactive toola z pustymi args (tool_call_pending) jest ukryty, a tekst trzyma caret", () => {
|
|
435
|
+
// View komponenty interactive tooli (registerInputOverride) muszą
|
|
436
|
+
// montować się z kompletem argumentów — jak w DB po zamknięciu turnu.
|
|
437
|
+
// W oknie streamowania argumentów wskaźnikiem aktywności jest caret
|
|
438
|
+
// na ostatnim WIDOCZNYM bloku (tekście) — zero martwej przerwy.
|
|
439
|
+
const { items, hasWaitingInteractive } = deriveTimeline(
|
|
440
|
+
input({
|
|
441
|
+
history: generatingWithUser,
|
|
442
|
+
overlays: new Map([
|
|
443
|
+
[
|
|
444
|
+
"a1",
|
|
445
|
+
overlay("live", [
|
|
446
|
+
{ type: "text", text: "Mam pytania" },
|
|
447
|
+
{ type: "tool_call", id: "tc1", name: "askQuestions", arguments: {} },
|
|
448
|
+
]),
|
|
449
|
+
],
|
|
450
|
+
]),
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
expect(items.some((i) => i.type === "tool")).toBe(false);
|
|
454
|
+
expect(hasWaitingInteractive).toBe(false);
|
|
455
|
+
const text = items.find((i) => i.id === "a1_t0") as any;
|
|
456
|
+
expect(text.content).toBe("Mam pytania");
|
|
457
|
+
expect(text.isStreaming).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("turn z samym ukrytym interactive toolem → spinner-placeholder", () => {
|
|
461
|
+
const { items } = deriveTimeline(
|
|
462
|
+
input({
|
|
463
|
+
history: generatingWithUser,
|
|
464
|
+
overlays: new Map([
|
|
465
|
+
[
|
|
466
|
+
"a1",
|
|
467
|
+
overlay("live", [
|
|
468
|
+
{ type: "tool_call", id: "tc1", name: "askQuestions", arguments: {} },
|
|
469
|
+
]),
|
|
470
|
+
],
|
|
471
|
+
]),
|
|
472
|
+
}),
|
|
473
|
+
);
|
|
474
|
+
expect(items.find((i) => i.id === "a1_t0")).toMatchObject({
|
|
475
|
+
type: "message",
|
|
476
|
+
content: "",
|
|
477
|
+
isStreaming: true,
|
|
478
|
+
});
|
|
479
|
+
expect(items.some((i) => i.type === "tool")).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("pojawia się gdy argumenty są kompletne", () => {
|
|
483
|
+
const { items, hasWaitingInteractive } = deriveTimeline(
|
|
484
|
+
input({
|
|
485
|
+
history: generatingWithUser,
|
|
486
|
+
overlays: new Map([
|
|
487
|
+
[
|
|
488
|
+
"a1",
|
|
489
|
+
overlay("live", [
|
|
490
|
+
{
|
|
491
|
+
type: "tool_call",
|
|
492
|
+
id: "tc1",
|
|
493
|
+
name: "askQuestions",
|
|
494
|
+
arguments: { questions: [{ id: "q1" }] },
|
|
495
|
+
},
|
|
496
|
+
]),
|
|
497
|
+
],
|
|
498
|
+
]),
|
|
499
|
+
}),
|
|
500
|
+
);
|
|
501
|
+
const tool = items.find((i) => i.type === "tool") as any;
|
|
502
|
+
expect(tool).toBeDefined();
|
|
503
|
+
expect(tool.params).toEqual({ questions: [{ id: "q1" }] });
|
|
504
|
+
expect(hasWaitingInteractive).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("server tool z pustymi args pokazuje loader od razu (stara semantyka)", () => {
|
|
508
|
+
const { items } = deriveTimeline(
|
|
509
|
+
input({
|
|
510
|
+
history: generatingWithUser,
|
|
511
|
+
overlays: new Map([
|
|
512
|
+
[
|
|
513
|
+
"a1",
|
|
514
|
+
overlay("live", [
|
|
515
|
+
{ type: "tool_call", id: "tc1", name: "srvSearch", arguments: {} },
|
|
516
|
+
]),
|
|
517
|
+
],
|
|
518
|
+
]),
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
expect(items.find((i) => i.type === "tool")).toMatchObject({
|
|
522
|
+
status: "executing",
|
|
523
|
+
calling: true,
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("po zamknięciu turnu interactive tool widoczny także z pustymi args", () => {
|
|
528
|
+
const { items } = deriveTimeline(
|
|
529
|
+
input({
|
|
530
|
+
history: [
|
|
531
|
+
assistantRow("a1", {
|
|
532
|
+
offset: 0,
|
|
533
|
+
blocks: [
|
|
534
|
+
{ type: "tool_call", id: "tc1", name: "askQuestions", arguments: {} },
|
|
535
|
+
],
|
|
536
|
+
}),
|
|
537
|
+
],
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
expect(items.some((i) => i.type === "tool")).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe("deriveTimeline — kształty danych po hydracji z Postgresa", () => {
|
|
545
|
+
// Adapter Postgresa auto-parsuje kolumny tekstowe wyglądające jak JSON:
|
|
546
|
+
// po restarcie serwera `blocks` i JSON-owy `content` przychodzą jako
|
|
547
|
+
// sparsowane wartości, nie stringi. Regresja: zamknięte wiadomości
|
|
548
|
+
// asystenta znikały z timeline'u.
|
|
549
|
+
it("blocks jako sparsowana tablica (nie string) renderuje się normalnie", () => {
|
|
550
|
+
const { items, busy } = deriveTimeline(
|
|
551
|
+
input({
|
|
552
|
+
history: [
|
|
553
|
+
userRow("u1", "Hej", 0),
|
|
554
|
+
{
|
|
555
|
+
_id: "a1",
|
|
556
|
+
role: "assistant",
|
|
557
|
+
sessionId: "s1",
|
|
558
|
+
isGenerating: false,
|
|
559
|
+
// surowa tablica — jak z deserializeValue adaptera
|
|
560
|
+
blocks: [
|
|
561
|
+
{ type: "text", text: "Odpowiedź z bazy" },
|
|
562
|
+
{ type: "tool_call", id: "tc1", name: "srvSearch", arguments: {} },
|
|
563
|
+
],
|
|
564
|
+
createdAt: new Date(T0 + 1),
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
expect(items.map((i) => i.id)).toEqual(["u1", "a1_t0", "tc1"]);
|
|
570
|
+
expect((items[1] as any).content).toBe("Odpowiedź z bazy");
|
|
571
|
+
expect(busy).toBe(true); // server tool bez wyniku — Gap A też widzi tablicę
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("tool_result content jako sparsowany obiekt", () => {
|
|
575
|
+
const { items } = deriveTimeline(
|
|
576
|
+
input({
|
|
577
|
+
history: [
|
|
578
|
+
{
|
|
579
|
+
_id: "a1",
|
|
580
|
+
role: "assistant",
|
|
581
|
+
sessionId: "s1",
|
|
582
|
+
isGenerating: false,
|
|
583
|
+
blocks: [
|
|
584
|
+
{ type: "tool_call", id: "tc1", name: "srvSearch", arguments: {} },
|
|
585
|
+
],
|
|
586
|
+
createdAt: new Date(T0),
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
...toolResultRow("tr1", "tc1", "srvSearch", "", 100),
|
|
590
|
+
content: { ok: true }, // sparsowane przez adapter
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
}),
|
|
594
|
+
);
|
|
595
|
+
const tool = items.find((i) => i.type === "tool") as any;
|
|
596
|
+
expect(tool.status).toBe("complete");
|
|
597
|
+
expect(tool.result).toEqual({ ok: true });
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("user content jako sparsowana wartość renderuje się jako string", () => {
|
|
601
|
+
const { items } = deriveTimeline(
|
|
602
|
+
input({
|
|
603
|
+
history: [
|
|
604
|
+
{ ...userRow("u1", "", 0), content: { jsonowa: "wiadomość" } },
|
|
605
|
+
],
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
expect((items[0] as any).content).toBe('{"jsonowa":"wiadomość"}');
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe("deriveTimeline — porządek i widoczność", () => {
|
|
613
|
+
it("system rows ukryte", () => {
|
|
614
|
+
const { items } = deriveTimeline(
|
|
615
|
+
input({
|
|
616
|
+
history: [
|
|
617
|
+
assistantRow("a1", { offset: 0, isGenerating: true }),
|
|
618
|
+
{
|
|
619
|
+
_id: "sys1",
|
|
620
|
+
role: "system",
|
|
621
|
+
content: "Rozpocznij etap",
|
|
622
|
+
sessionId: "s1",
|
|
623
|
+
createdAt: new Date(T0 + 1),
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
expect(items.map((i) => i.id)).toEqual(["a1_t0"]);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("pełna sesja multi-turn w kanonicznej kolejności", () => {
|
|
632
|
+
const { items } = deriveTimeline(
|
|
633
|
+
input({
|
|
634
|
+
history: [
|
|
635
|
+
assistantRow("a1", {
|
|
636
|
+
offset: 0,
|
|
637
|
+
blocks: [
|
|
638
|
+
{ type: "text", text: "Sprawdzam" },
|
|
639
|
+
{ type: "tool_call", id: "tc1", name: "srvSearch", arguments: {} },
|
|
640
|
+
],
|
|
641
|
+
}),
|
|
642
|
+
userRow("u1", "Hej", 2),
|
|
643
|
+
toolResultRow("tr1", "tc1", "srvSearch", '{"ok":true}', 5_000),
|
|
644
|
+
assistantRow("a2", {
|
|
645
|
+
offset: 5_100,
|
|
646
|
+
blocks: [{ type: "text", text: "Wynik: ok" }],
|
|
647
|
+
}),
|
|
648
|
+
],
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
expect(items.map((i) => i.id)).toEqual(["u1", "a1_t0", "tc1", "a2_t0"]);
|
|
652
|
+
expect((items[2] as any).status).toBe("complete");
|
|
653
|
+
});
|
|
654
|
+
});
|