@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,118 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { orderMessages } from "./ordering";
|
|
3
|
+
|
|
4
|
+
let t0 = 1_750_000_000_000;
|
|
5
|
+
const at = (offsetMs: number) => new Date(t0 + offsetMs);
|
|
6
|
+
|
|
7
|
+
const row = (
|
|
8
|
+
_id: string,
|
|
9
|
+
role: string,
|
|
10
|
+
sessionId: string,
|
|
11
|
+
offsetMs: number,
|
|
12
|
+
) => ({ _id, role, sessionId, createdAt: at(offsetMs) });
|
|
13
|
+
|
|
14
|
+
describe("orderMessages — kanoniczna kolejność konwersacji", () => {
|
|
15
|
+
it("sortuje po createdAt", () => {
|
|
16
|
+
const rows = [
|
|
17
|
+
row("c", "user", "s2", 200),
|
|
18
|
+
row("a", "user", "s1", 0),
|
|
19
|
+
row("b", "assistant", "s1", 100),
|
|
20
|
+
];
|
|
21
|
+
expect(orderMessages(rows).map((r) => r._id)).toEqual(["a", "b", "c"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("pre-utworzony assistant placeholder ląduje ZA user rowem (sendMessage)", () => {
|
|
25
|
+
// assistantTurnStarted emitowany PRZED messageSent → wcześniejszy timestamp.
|
|
26
|
+
const rows = [
|
|
27
|
+
row("a1", "assistant", "s1", 0),
|
|
28
|
+
row("u1", "user", "s1", 2),
|
|
29
|
+
];
|
|
30
|
+
expect(orderMessages(rows).map((r) => r._id)).toEqual(["u1", "a1"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("pre-utworzony assistant ląduje ZA tool_result (respondToTool)", () => {
|
|
34
|
+
const rows = [
|
|
35
|
+
row("a2", "assistant", "s2", 100),
|
|
36
|
+
row("tr1", "tool_result", "s2", 101),
|
|
37
|
+
];
|
|
38
|
+
expect(orderMessages(rows).map((r) => r._id)).toEqual(["tr1", "a2"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("multi-turn tool loop zachowuje kolejność iteracji", () => {
|
|
42
|
+
const rows = [
|
|
43
|
+
row("a1", "assistant", "s1", 0), // placeholder (pre-created)
|
|
44
|
+
row("u1", "user", "s1", 1),
|
|
45
|
+
row("tr1", "tool_result", "s1", 5000), // wynik server toola
|
|
46
|
+
row("a2", "assistant", "s1", 5100), // iteracja 2 (startAssistantTurn)
|
|
47
|
+
row("tr2", "tool_result", "s1", 9000),
|
|
48
|
+
row("a3", "assistant", "s1", 9100),
|
|
49
|
+
];
|
|
50
|
+
expect(orderMessages(rows).map((r) => r._id)).toEqual([
|
|
51
|
+
"u1",
|
|
52
|
+
"a1",
|
|
53
|
+
"tr1",
|
|
54
|
+
"a2",
|
|
55
|
+
"tr2",
|
|
56
|
+
"a3",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("tie-break rolą w tej samej milisekundzie", () => {
|
|
61
|
+
const rows = [
|
|
62
|
+
row("a1", "assistant", "s1", 0),
|
|
63
|
+
row("u1", "user", "s1", 0),
|
|
64
|
+
];
|
|
65
|
+
expect(orderMessages(rows).map((r) => r._id)).toEqual(["u1", "a1"]);
|
|
66
|
+
|
|
67
|
+
const rows2 = [
|
|
68
|
+
row("a2", "assistant", "s2", 0),
|
|
69
|
+
row("tr1", "tool_result", "s2", 0),
|
|
70
|
+
];
|
|
71
|
+
expect(orderMessages(rows2).map((r) => r._id)).toEqual(["tr1", "a2"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("wiele sesji z fix-upem naraz (każda para lokalnie naprawiona)", () => {
|
|
75
|
+
const rows = [
|
|
76
|
+
row("a1", "assistant", "s1", 0),
|
|
77
|
+
row("u1", "user", "s1", 2),
|
|
78
|
+
row("a2", "assistant", "s2", 60_000),
|
|
79
|
+
row("u2", "user", "s2", 60_002),
|
|
80
|
+
];
|
|
81
|
+
expect(orderMessages(rows).map((r) => r._id)).toEqual([
|
|
82
|
+
"u1",
|
|
83
|
+
"a1",
|
|
84
|
+
"u2",
|
|
85
|
+
"a2",
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("system row (startStage) zachowuje się jak user", () => {
|
|
90
|
+
const rows = [
|
|
91
|
+
row("a1", "assistant", "s1", 0),
|
|
92
|
+
row("sys1", "system", "s1", 1),
|
|
93
|
+
];
|
|
94
|
+
expect(orderMessages(rows).map((r) => r._id)).toEqual(["sys1", "a1"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("retry: fresh assistant bez trigger rowa w sesji zostaje na miejscu", () => {
|
|
98
|
+
const rows = [
|
|
99
|
+
row("u1", "user", "s1", 0),
|
|
100
|
+
row("a1", "assistant", "s1", 1),
|
|
101
|
+
// retryGeneration: nowa sesja zawiera TYLKO assistant row
|
|
102
|
+
row("a2", "assistant", "s_retry", 30_000),
|
|
103
|
+
];
|
|
104
|
+
expect(orderMessages(rows).map((r) => r._id)).toEqual(["u1", "a1", "a2"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("nie mutuje wejścia i jest stabilne dla identycznych wywołań", () => {
|
|
108
|
+
const rows = [
|
|
109
|
+
row("a1", "assistant", "s1", 0),
|
|
110
|
+
row("u1", "user", "s1", 2),
|
|
111
|
+
];
|
|
112
|
+
const snapshot = JSON.stringify(rows);
|
|
113
|
+
const first = orderMessages(rows).map((r) => r._id);
|
|
114
|
+
const second = orderMessages(rows).map((r) => r._id);
|
|
115
|
+
expect(JSON.stringify(rows)).toBe(snapshot);
|
|
116
|
+
expect(first).toEqual(second);
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/ordering.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// ─── Deterministyczne porządkowanie rzędów wiadomości ────────────
|
|
2
|
+
//
|
|
3
|
+
// Wspólne dla klienta (derywacja timeline'u) i serwera (buildHistory w
|
|
4
|
+
// listenerze) — oba muszą widzieć IDENTYCZNĄ kolejność konwersacji.
|
|
5
|
+
//
|
|
6
|
+
// Dlaczego samo `orderBy: { createdAt: "asc" }` nie wystarcza: mutacje
|
|
7
|
+
// `sendMessage` / `respondToTool` / `startStage` / `systemMessage`
|
|
8
|
+
// emitują `assistantTurnStarted` PRZED rzędem triggerującym (user /
|
|
9
|
+
// system / tool_result) — celowo, żeby async listener widział assistant
|
|
10
|
+
// row w DB. `createdAt` powstaje per emit, więc pre-utworzony placeholder
|
|
11
|
+
// asystenta ma timestamp WCZEŚNIEJSZY niż pytanie, na które odpowiada.
|
|
12
|
+
// Po samym sortowaniu czasowym odpowiedź wylądowałaby przed pytaniem
|
|
13
|
+
// (i w timeline, i w historii wysyłanej do LLM).
|
|
14
|
+
|
|
15
|
+
const ROLE_PRIORITY: Record<string, number> = {
|
|
16
|
+
user: 0,
|
|
17
|
+
system: 0,
|
|
18
|
+
tool_result: 1,
|
|
19
|
+
assistant: 2,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface OrderableMessage {
|
|
23
|
+
_id: string;
|
|
24
|
+
role: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
createdAt: string | Date;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ts(value: string | Date): number {
|
|
30
|
+
return value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Zwraca NOWĄ tablicę rzędów w kanonicznej kolejności konwersacji:
|
|
35
|
+
*
|
|
36
|
+
* 1. `createdAt` rosnąco,
|
|
37
|
+
* 2. tie-break rolą (user/system → tool_result → assistant) — pokrywa
|
|
38
|
+
* rzędy emitowane w tej samej milisekundzie w jednej mutacji,
|
|
39
|
+
* 3. tie-break `_id` (stabilność),
|
|
40
|
+
* 4. fix-up: PIERWSZY assistant row każdej sesji (pre-utworzony
|
|
41
|
+
* placeholder) jest przesuwany tuż ZA najwcześniejszy nie-assistant
|
|
42
|
+
* row tej samej sesji, jeśli wylądował przed nim. Kolejne assistant
|
|
43
|
+
* rows sesji (iteracje tool-loopa) powstają już po realnych awaitach,
|
|
44
|
+
* więc sortowanie czasowe je pokrywa.
|
|
45
|
+
*/
|
|
46
|
+
export function orderMessages<T extends OrderableMessage>(rows: T[]): T[] {
|
|
47
|
+
const sorted = [...rows].sort((a, b) => {
|
|
48
|
+
const dt = ts(a.createdAt) - ts(b.createdAt);
|
|
49
|
+
if (dt !== 0) return dt;
|
|
50
|
+
const dp =
|
|
51
|
+
(ROLE_PRIORITY[a.role] ?? 0) - (ROLE_PRIORITY[b.role] ?? 0);
|
|
52
|
+
if (dp !== 0) return dp;
|
|
53
|
+
return a._id < b._id ? -1 : a._id > b._id ? 1 : 0;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Fix-up pre-utworzonych placeholderów.
|
|
57
|
+
const firstAssistantIdx = new Map<string, number>();
|
|
58
|
+
const firstTriggerIdx = new Map<string, number>();
|
|
59
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
60
|
+
const row = sorted[i];
|
|
61
|
+
if (!row.sessionId) continue;
|
|
62
|
+
if (row.role === "assistant") {
|
|
63
|
+
if (!firstAssistantIdx.has(row.sessionId)) {
|
|
64
|
+
firstAssistantIdx.set(row.sessionId, i);
|
|
65
|
+
}
|
|
66
|
+
} else if (!firstTriggerIdx.has(row.sessionId)) {
|
|
67
|
+
firstTriggerIdx.set(row.sessionId, i);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Przesunięcia wykonujemy od końca, żeby wcześniejsze splice'y nie
|
|
72
|
+
// unieważniały zapamiętanych indeksów kolejnych par.
|
|
73
|
+
const moves: Array<{ from: number; to: number }> = [];
|
|
74
|
+
for (const [sessionId, aIdx] of firstAssistantIdx) {
|
|
75
|
+
const tIdx = firstTriggerIdx.get(sessionId);
|
|
76
|
+
if (tIdx === undefined || aIdx > tIdx) continue;
|
|
77
|
+
moves.push({ from: aIdx, to: tIdx });
|
|
78
|
+
}
|
|
79
|
+
moves.sort((a, b) => b.from - a.from);
|
|
80
|
+
for (const { from, to } of moves) {
|
|
81
|
+
const [row] = sorted.splice(from, 1);
|
|
82
|
+
// Po usunięciu `from` (które było < to) trigger jest na `to - 1`;
|
|
83
|
+
// wstawiamy ZA niego.
|
|
84
|
+
sorted.splice(to, 0, row);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return sorted;
|
|
88
|
+
}
|