@arcote.tech/arc-chat 0.7.19 → 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.
@@ -5,19 +5,33 @@ import { subscribe } from "../streaming/stream-registry";
5
5
  export function createChatStreamRoute(config: {
6
6
  name: string;
7
7
  userToken: Token;
8
+ /** Message aggregate tego chatu — używany do lazy repair przy 410. */
9
+ messageElement: any;
8
10
  }) {
11
+ const { messageElement } = config;
9
12
  return route(`${config.name}ChatStream`)
10
13
  .path(`/chat/${config.name}/stream/:messageId`)
11
14
  .protectBy(config.userToken, () => true)
15
+ .mutate([messageElement])
12
16
  .handle({
13
- GET: async (_ctx, _req: Request, params: Record<string, string>) => {
17
+ GET: async (ctx: any, _req: Request, params: Record<string, string>) => {
14
18
  const result = subscribe(params.messageId);
15
19
  if (!result) {
16
20
  // Brak in-memory streamu: generacja zakończona poza grace window
17
- // ALBO proces zrestartował się mid-stream. Klient rozróżnia
18
- // sytuacje po `isGenerating` w DB: jeśli row ma `isGenerating=false`
19
- // użyj final `blocks`, jeśli `true` "Generation interrupted"
20
- // + retry.
21
+ // ALBO proces zrestartował się mid-stream. Lazy repair: jeśli row
22
+ // w DB nadal ma `isGenerating=true` i jest wystarczająco stary
23
+ // (nie ściga się ze świeżym startStream), flipujemy go trwale na
24
+ // `interrupted` — pierwszy klient, który odkryje sierotę, naprawia
25
+ // ją dla wszystkich (cross-client, przeżywa F5). Walidacja wieku
26
+ // i stanu rowa siedzi w mutacji `markInterrupted` (no-op gdy
27
+ // warunki niespełnione), więc wołamy ją przy każdym 410.
28
+ try {
29
+ await ctx.mutate(messageElement).markInterrupted({
30
+ messageId: params.messageId,
31
+ });
32
+ } catch {
33
+ // Best-effort: klient i tak obsłuży 410 retry/interrupted path.
34
+ }
21
35
  return new Response(
22
36
  JSON.stringify({
23
37
  error: "stream_not_found",
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { AssistantContentBlock } from "@arcote.tech/arc-ai";
3
+ import { applyStreamEvent, type StreamBlocksEvent } from "./blocks-reducer";
4
+
5
+ function replay(
6
+ base: AssistantContentBlock[],
7
+ events: StreamBlocksEvent[],
8
+ ): AssistantContentBlock[] {
9
+ return events.reduce(applyStreamEvent, base);
10
+ }
11
+
12
+ /** Reprezentatywny turn: tekst → tool call → args → dalszy tekst. */
13
+ const TURN_EVENTS: StreamBlocksEvent[] = [
14
+ { type: "text_delta", textDelta: "Cześć" },
15
+ { type: "text_delta", textDelta: ", już " },
16
+ { type: "text_delta", textDelta: "sprawdzam." },
17
+ { type: "tool_call_pending", toolCallId: "tc1", toolCallName: "search" },
18
+ {
19
+ type: "tool_call_arguments_delta",
20
+ toolCallId: "tc1",
21
+ // argumentsDelta nie mutuje blocks — identity
22
+ },
23
+ {
24
+ type: "tool_call_arguments_complete",
25
+ toolCallId: "tc1",
26
+ toolCallName: "search",
27
+ arguments: { query: "arc" },
28
+ },
29
+ { type: "usage_update" },
30
+ { type: "tool_call_pending", toolCallId: "tc2" },
31
+ {
32
+ type: "tool_call_arguments_complete",
33
+ toolCallId: "tc2",
34
+ toolCallName: "lateName",
35
+ arguments: { a: 1 },
36
+ },
37
+ { type: "text_delta", textDelta: "Drugi blok tekstu" },
38
+ { type: "done" },
39
+ ];
40
+
41
+ describe("applyStreamEvent — semantyka bloków", () => {
42
+ it("text_delta dokleja do ostatniego text blocka lub tworzy nowy", () => {
43
+ let blocks: AssistantContentBlock[] = [];
44
+ blocks = applyStreamEvent(blocks, { type: "text_delta", textDelta: "a" });
45
+ blocks = applyStreamEvent(blocks, { type: "text_delta", textDelta: "b" });
46
+ expect(blocks).toEqual([{ type: "text", text: "ab" }]);
47
+
48
+ blocks = applyStreamEvent(blocks, {
49
+ type: "tool_call_pending",
50
+ toolCallId: "t1",
51
+ });
52
+ blocks = applyStreamEvent(blocks, { type: "text_delta", textDelta: "c" });
53
+ expect(blocks).toEqual([
54
+ { type: "text", text: "ab" },
55
+ { type: "tool_call", id: "t1", name: "", arguments: {} },
56
+ { type: "text", text: "c" },
57
+ ]);
58
+ });
59
+
60
+ it("pusty text_delta jest identity", () => {
61
+ const blocks: AssistantContentBlock[] = [{ type: "text", text: "x" }];
62
+ expect(applyStreamEvent(blocks, { type: "text_delta" })).toBe(blocks);
63
+ });
64
+
65
+ it("tool_call_arguments_complete aktualizuje args i nazwę po toolCallId", () => {
66
+ const blocks = replay([], [
67
+ { type: "tool_call_pending", toolCallId: "t1" },
68
+ {
69
+ type: "tool_call_arguments_complete",
70
+ toolCallId: "t1",
71
+ toolCallName: "named",
72
+ arguments: { x: 1 },
73
+ },
74
+ ]);
75
+ expect(blocks).toEqual([
76
+ { type: "tool_call", id: "t1", name: "named", arguments: { x: 1 } },
77
+ ]);
78
+ });
79
+
80
+ it("tool_call_arguments_complete dla nieznanego id jest identity", () => {
81
+ const blocks: AssistantContentBlock[] = [{ type: "text", text: "x" }];
82
+ expect(
83
+ applyStreamEvent(blocks, {
84
+ type: "tool_call_arguments_complete",
85
+ toolCallId: "ghost",
86
+ arguments: {},
87
+ }),
88
+ ).toBe(blocks);
89
+ });
90
+
91
+ it("eventy nie kształtujące blocks są identity (ta sama referencja)", () => {
92
+ const blocks: AssistantContentBlock[] = [{ type: "text", text: "x" }];
93
+ for (const type of [
94
+ "init",
95
+ "tool_call_arguments_delta",
96
+ "tool_call_executed",
97
+ "interactive_tool_request",
98
+ "usage_update",
99
+ "done",
100
+ "error",
101
+ ]) {
102
+ expect(applyStreamEvent(blocks, { type })).toBe(blocks);
103
+ }
104
+ });
105
+
106
+ it("nigdy nie mutuje wejścia", () => {
107
+ const base: AssistantContentBlock[] = [
108
+ { type: "text", text: "stary" },
109
+ { type: "tool_call", id: "t1", name: "n", arguments: { a: 1 } },
110
+ ];
111
+ const frozen = JSON.stringify(base);
112
+ replay(base, TURN_EVENTS);
113
+ expect(JSON.stringify(base)).toBe(frozen);
114
+ });
115
+ });
116
+
117
+ describe("applyStreamEvent — inwariant reconnectu (snapshot + replay)", () => {
118
+ it("snapshot w dowolnym punkcie K + replay reszty == pełny replay", () => {
119
+ const full = replay([], TURN_EVENTS);
120
+ for (let k = 0; k <= TURN_EVENTS.length; k++) {
121
+ const snapshot = replay([], TURN_EVENTS.slice(0, k));
122
+ const resumed = replay(snapshot, TURN_EVENTS.slice(k));
123
+ expect(resumed).toEqual(full);
124
+ }
125
+ });
126
+ });
@@ -0,0 +1,88 @@
1
+ import type { AssistantContentBlock } from "@arcote.tech/arc-ai";
2
+
3
+ // ─── Shared stream→blocks reducer ────────────────────────────────
4
+ //
5
+ // Jedyna implementacja semantyki "jak eventy streamu budują
6
+ // AssistantContentBlock[]". Używana w DWÓCH miejscach:
7
+ // - serwer: `stream-registry.publish()` akumuluje `currentBlocks`,
8
+ // - klient: hook overlayów aplikuje delty na bazie z eventu `init`.
9
+ //
10
+ // Inwariant reconnectu: snapshot blocks w dowolnym punkcie K + replay
11
+ // eventów od K == pełny replay wszystkich eventów. Dzięki temu `init`
12
+ // (snapshot) + dalsze delty dają na kliencie identyczny stan co na
13
+ // serwerze — bez sekwencerów i bufora replay.
14
+
15
+ /**
16
+ * Strukturalny podzbiór eventu streamu — pasuje zarówno do
17
+ * `PublishableEvent` (serwer), jak i `ChatStreamEvent` (klient/SSE).
18
+ */
19
+ export interface StreamBlocksEvent {
20
+ type: string;
21
+ textDelta?: string;
22
+ toolCallId?: string;
23
+ toolCallName?: string;
24
+ arguments?: Record<string, unknown>;
25
+ }
26
+
27
+ /**
28
+ * Czysta funkcja: zwraca NOWĄ tablicę blocks z zaaplikowanym eventem
29
+ * (structural sharing — niezmienione bloki są reused). Eventy nie
30
+ * mutujące blocks (`done`, `usage_update`, `tool_call_arguments_delta`,
31
+ * `error`, ...) zwracają wejściową tablicę bez zmian.
32
+ *
33
+ * Nigdy nie mutuje wejścia — snapshoty trzymane przez subskrybentów
34
+ * (event `init`) i poprzednie stany Reacta pozostają nietknięte.
35
+ */
36
+ export function applyStreamEvent(
37
+ blocks: AssistantContentBlock[],
38
+ event: StreamBlocksEvent,
39
+ ): AssistantContentBlock[] {
40
+ switch (event.type) {
41
+ case "text_delta": {
42
+ if (!event.textDelta) return blocks;
43
+ const last = blocks[blocks.length - 1];
44
+ if (last && last.type === "text") {
45
+ return [
46
+ ...blocks.slice(0, -1),
47
+ { type: "text", text: last.text + event.textDelta },
48
+ ];
49
+ }
50
+ return [...blocks, { type: "text", text: event.textDelta }];
51
+ }
52
+
53
+ case "tool_call_pending": {
54
+ if (!event.toolCallId) return blocks;
55
+ return [
56
+ ...blocks,
57
+ {
58
+ type: "tool_call",
59
+ id: event.toolCallId,
60
+ name: event.toolCallName ?? "",
61
+ arguments: {},
62
+ },
63
+ ];
64
+ }
65
+
66
+ case "tool_call_arguments_complete": {
67
+ if (!event.toolCallId) return blocks;
68
+ const idx = blocks.findIndex(
69
+ (b) => b.type === "tool_call" && b.id === event.toolCallId,
70
+ );
71
+ if (idx === -1) return blocks;
72
+ const block = blocks[idx] as Extract<
73
+ AssistantContentBlock,
74
+ { type: "tool_call" }
75
+ >;
76
+ const next = blocks.slice();
77
+ next[idx] = {
78
+ ...block,
79
+ arguments: event.arguments ?? {},
80
+ name: event.toolCallName ?? block.name,
81
+ };
82
+ return next;
83
+ }
84
+
85
+ default:
86
+ return blocks;
87
+ }
88
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ finalize,
4
+ getCurrentBlocks,
5
+ isActive,
6
+ publish,
7
+ startStream,
8
+ subscribe,
9
+ } from "./stream-registry";
10
+
11
+ let nextId = 0;
12
+ const freshId = () => `msg_test_${nextId++}`;
13
+
14
+ describe("stream-registry — akumulacja przez shared reducer", () => {
15
+ it("publish akumuluje currentBlocks identycznie jak reducer", () => {
16
+ const id = freshId();
17
+ startStream(id);
18
+ publish(id, { type: "text_delta", textDelta: "Hej" });
19
+ publish(id, { type: "text_delta", textDelta: "!" });
20
+ publish(id, { type: "tool_call_pending", toolCallId: "t1", toolCallName: "x" });
21
+ publish(id, {
22
+ type: "tool_call_arguments_complete",
23
+ toolCallId: "t1",
24
+ arguments: { q: 1 },
25
+ });
26
+ expect(getCurrentBlocks(id)).toEqual([
27
+ { type: "text", text: "Hej!" },
28
+ { type: "tool_call", id: "t1", name: "x", arguments: { q: 1 } },
29
+ ]);
30
+ finalize(id);
31
+ });
32
+
33
+ it("snapshot z subscribe() jest stabilny mimo kolejnych publishy", () => {
34
+ const id = freshId();
35
+ startStream(id);
36
+ publish(id, { type: "text_delta", textDelta: "abc" });
37
+
38
+ const sub = subscribe(id);
39
+ expect(sub).not.toBeNull();
40
+ const snapshot = sub!.currentBlocks;
41
+ expect(snapshot).toEqual([{ type: "text", text: "abc" }]);
42
+
43
+ publish(id, { type: "text_delta", textDelta: "def" });
44
+ publish(id, { type: "tool_call_pending", toolCallId: "t1" });
45
+
46
+ // Immutable reducer: snapshot nietknięty, registry poszło dalej.
47
+ expect(snapshot).toEqual([{ type: "text", text: "abc" }]);
48
+ expect(getCurrentBlocks(id)).toEqual([
49
+ { type: "text", text: "abcdef" },
50
+ { type: "tool_call", id: "t1", name: "", arguments: {} },
51
+ ]);
52
+ finalize(id);
53
+ });
54
+
55
+ it("publish po finalize jest no-opem", () => {
56
+ const id = freshId();
57
+ startStream(id);
58
+ publish(id, { type: "text_delta", textDelta: "a" });
59
+ finalize(id);
60
+ expect(isActive(id)).toBe(false);
61
+ publish(id, { type: "text_delta", textDelta: "b" });
62
+ expect(getCurrentBlocks(id)).toEqual([{ type: "text", text: "a" }]);
63
+ });
64
+ });
@@ -6,12 +6,14 @@ import type {
6
6
  ToolCall,
7
7
  ToolResult,
8
8
  } from "@arcote.tech/arc-ai";
9
+ import { applyStreamEvent } from "./blocks-reducer";
9
10
 
10
11
  // ─── Per-message in-memory stream registry ──────────────────────────
11
12
  //
12
13
  // Live state of an in-progress assistant message lives ONLY here, never in
13
14
  // DB. `currentBlocks` accumulates text + tool_call blocks as the LLM streams.
14
- // Each `publish()` mutates it AND broadcasts the SSE event to subscribers.
15
+ // Each `publish()` applies the shared `applyStreamEvent` reducer (immutable)
16
+ // AND broadcasts the SSE event to subscribers.
15
17
  // A new subscriber receives `{ type: "init", currentBlocks }` as the first
16
18
  // event — that's the snapshot of the in-memory state — and then live events.
17
19
  //
@@ -26,11 +28,13 @@ import type {
26
28
 
27
29
  interface MessageStream {
28
30
  messageId: string;
31
+ /**
32
+ * Akumulowany stan bloków — aktualizowany WYŁĄCZNIE przez immutable
33
+ * `applyStreamEvent` (shared reducer, ten sam kod co klient). Każdy
34
+ * publish podmienia referencję; wcześniej rozdane snapshoty (`init`)
35
+ * pozostają nietknięte.
36
+ */
29
37
  currentBlocks: AssistantContentBlock[];
30
- toolCallsById: Map<
31
- string,
32
- Extract<AssistantContentBlock, { type: "tool_call" }>
33
- >;
34
38
  subscribers: Set<ReadableStreamDefaultController<Uint8Array>>;
35
39
  keepAliveInterval?: ReturnType<typeof setInterval>;
36
40
  /**
@@ -88,7 +92,6 @@ export function startStream(messageId: string): void {
88
92
  const s: MessageStream = {
89
93
  messageId,
90
94
  currentBlocks: [],
91
- toolCallsById: new Map(),
92
95
  subscribers: new Set(),
93
96
  finalized: false,
94
97
  };
@@ -156,8 +159,13 @@ export type PublishableEvent =
156
159
  | { type: "error"; error: string; executionCount?: number };
157
160
 
158
161
  /**
159
- * Mutate the in-memory `currentBlocks` for the relevant event types, then
160
- * broadcast the event to all active subscribers as SSE.
162
+ * Apply the event to the in-memory `currentBlocks` (via the shared
163
+ * `applyStreamEvent` reducer — the same code the client runs on SSE
164
+ * events), then broadcast the event to all active subscribers as SSE.
165
+ *
166
+ * Events that don't shape blocks (`tool_call_arguments_delta`,
167
+ * `tool_call_executed`, `interactive_tool_request`, `usage_update`,
168
+ * `error`) are broadcast-only — the reducer returns the input unchanged.
161
169
  *
162
170
  * No-op if the stream has been finalized or never started (race with a
163
171
  * client disconnect / listener teardown).
@@ -166,42 +174,7 @@ export function publish(messageId: string, event: PublishableEvent): void {
166
174
  const s = streams.get(messageId);
167
175
  if (!s || s.finalized) return;
168
176
 
169
- switch (event.type) {
170
- case "text_delta": {
171
- const last = s.currentBlocks[s.currentBlocks.length - 1];
172
- if (last && last.type === "text") {
173
- last.text += event.textDelta;
174
- } else {
175
- s.currentBlocks.push({ type: "text", text: event.textDelta });
176
- }
177
- break;
178
- }
179
- case "tool_call_pending": {
180
- const block: Extract<AssistantContentBlock, { type: "tool_call" }> = {
181
- type: "tool_call",
182
- id: event.toolCallId,
183
- name: event.toolCallName ?? "",
184
- arguments: {},
185
- };
186
- s.currentBlocks.push(block);
187
- s.toolCallsById.set(event.toolCallId, block);
188
- break;
189
- }
190
- case "tool_call_arguments_complete": {
191
- const block = s.toolCallsById.get(event.toolCallId);
192
- if (block) {
193
- block.arguments = event.arguments;
194
- if (event.toolCallName) block.name = event.toolCallName;
195
- }
196
- break;
197
- }
198
- // tool_call_arguments_delta, tool_call_executed, interactive_tool_request,
199
- // usage_update, error — broadcast-only. They don't mutate `currentBlocks`:
200
- // - args_delta is incremental JSON the listener replaces via args_complete
201
- // - executed / interactive_tool_request relate to tool execution flow
202
- // (tool_result rows exist separately in DB)
203
- // - usage_update / error are transient signals to the UI
204
- }
177
+ s.currentBlocks = applyStreamEvent(s.currentBlocks, event);
205
178
 
206
179
  const payload = { ...event, messageId } as ChatStreamEvent;
207
180
  const data = encode(payload);
@@ -232,11 +205,10 @@ export function subscribe(messageId: string): {
232
205
  const s = streams.get(messageId);
233
206
  if (!s) return null;
234
207
 
235
- // Deep snapshot the subscriber sees a stable `init` payload even if the
236
- // listener mutates `currentBlocks` on a parallel publish.
237
- const snapshot: AssistantContentBlock[] = JSON.parse(
238
- JSON.stringify(s.currentBlocks),
239
- );
208
+ // Stable snapshot for free: `applyStreamEvent` never mutates every
209
+ // publish swaps the `currentBlocks` reference, so the array we capture
210
+ // here can't change under the subscriber.
211
+ const snapshot: AssistantContentBlock[] = s.currentBlocks;
240
212
 
241
213
  const stream = new ReadableStream<Uint8Array>({
242
214
  start(controller) {
@@ -39,10 +39,13 @@ function AskQuestionsView({
39
39
  const { registerInputOverride, clearInputOverride } = useChatInput();
40
40
  const { answerBelowLabel } = useChatLabels();
41
41
 
42
+ // Dep po TREŚCI pytań, nie referencji: derywacja timeline'u tworzy nowe
43
+ // obiekty params przy każdym przeliczeniu, a effect ma się odpalić tylko
44
+ // gdy pytania faktycznie dotrą / zmienią się (np. zamontowano komponent
45
+ // zanim argumenty toola się zestreamowały).
46
+ const questionsKey = JSON.stringify(params.questions ?? null);
47
+
42
48
  useEffect(() => {
43
- // `params.questions` może być undefined podczas streamingu tool args —
44
- // partial JSON nie ma jeszcze tablicy. Czekamy aż streaming skończy
45
- // wypełniać argumenty.
46
49
  if (calling && Array.isArray(params.questions)) {
47
50
  const questions: Question[] = params.questions.map((q) => ({
48
51
  id: q.id,
@@ -63,7 +66,7 @@ function AskQuestionsView({
63
66
  }
64
67
 
65
68
  return () => clearInputOverride();
66
- }, [calling]);
69
+ }, [calling, questionsKey]);
67
70
 
68
71
  // Answered — show summary
69
72
  if (!calling && result) {
@@ -1 +0,0 @@
1
- // Removed — use chat().toReactComponent() instead.