@arcote.tech/arc-chat 0.7.8 → 0.7.9

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.7.8",
4
+ "version": "0.7.9",
5
5
  "private": false,
6
6
  "description": "Chat module with AI integration for Arc framework",
7
7
  "main": "./src/index.ts",
@@ -10,11 +10,12 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc": "^0.7.8",
14
- "@arcote.tech/arc-ai": "^0.7.8",
15
- "@arcote.tech/arc-auth": "^0.7.8",
16
- "@arcote.tech/arc-ds": "^0.7.8",
17
- "@arcote.tech/platform": "^0.7.8",
13
+ "@arcote.tech/arc": "^0.7.9",
14
+ "@arcote.tech/arc-ai": "^0.7.9",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.9",
16
+ "@arcote.tech/arc-auth": "^0.7.9",
17
+ "@arcote.tech/arc-ds": "^0.7.9",
18
+ "@arcote.tech/platform": "^0.7.9",
18
19
  "lucide-react": ">=0.400.0",
19
20
  "react": ">=18.0.0",
20
21
  "typescript": "^5.0.0"
@@ -4,6 +4,7 @@ import {
4
4
  boolean,
5
5
  date,
6
6
  id,
7
+ number,
7
8
  string,
8
9
  type ArcId,
9
10
  } from "@arcote.tech/arc";
@@ -78,6 +79,18 @@ export const createMessageAggregate = <
78
79
  previousResponseId: string().optional(),
79
80
  isGenerating: boolean().optional(),
80
81
  usage: string().optional(),
82
+ /**
83
+ * Partial snapshot blocks (JSON-serialized AssistantContentBlock[])
84
+ * zapisywane w trakcie streamingu co kilka chunków. Pozwala klientowi
85
+ * po reload przeglądarki przywrócić stan i kontynuować SSE od
86
+ * `partialLastSeq`. Czyszczone po `assistantTurnCompleted`.
87
+ */
88
+ partialBlocks: string().optional(),
89
+ /**
90
+ * Ostatni seq SSE event'u zaaplikowany do `partialBlocks`. Klient
91
+ * wysyła `?afterSeq=partialLastSeq` przy SSE resume.
92
+ */
93
+ partialLastSeq: number().optional(),
81
94
  createdAt: date(),
82
95
  })
83
96
 
@@ -92,6 +105,14 @@ export const createMessageAggregate = <
92
105
  content: string(),
93
106
  model: string().optional(),
94
107
  isGenerating: boolean().optional(),
108
+ /**
109
+ * Pre-utworzone messageId pustego assistant row'a który zostaje
110
+ * stworzony w tej samej mutacji (atomowo z `messageSent`). Listener
111
+ * AI generation używa go zamiast wołać `startAssistantTurn`. Dzięki
112
+ * temu klient widzi assistant row natychmiast (przez useQuery push),
113
+ * otwiera SSE i streaming jest visible od pierwszego chunka.
114
+ */
115
+ assistantMessageId: messageId.optional(),
95
116
  },
96
117
  async (ctx, event) => {
97
118
  const p = event.payload;
@@ -132,10 +153,30 @@ export const createMessageAggregate = <
132
153
  },
133
154
  )
134
155
 
156
+ // ─── assistantTurnProgressSnapshot — checkpoint w trakcie streamingu ─
157
+ // Listener emituje co N chunków lub T sekund — klient po reload czyta
158
+ // `partialBlocks` + `partialLastSeq` i kontynuuje SSE od miejsca w
159
+ // którym był.
160
+ .publicEvent(
161
+ "assistantTurnProgressSnapshot",
162
+ {
163
+ messageId,
164
+ partialBlocks: string(),
165
+ partialLastSeq: number(),
166
+ },
167
+ async (ctx, event) => {
168
+ const p = event.payload;
169
+ await ctx.modify(p.messageId, {
170
+ partialBlocks: p.partialBlocks,
171
+ partialLastSeq: p.partialLastSeq,
172
+ } as any);
173
+ },
174
+ )
175
+
135
176
  // ─── assistantTurnCompleted — finalize an in-progress turn row ───
136
177
  // Partial update on the SAME row — fills `blocks`, flips
137
178
  // `isGenerating` to false, optionally records `previousResponseId`,
138
- // `usage`, or `error`.
179
+ // `usage`, or `error`. Czyści `partialBlocks` / `partialLastSeq`.
139
180
  .publicEvent(
140
181
  "assistantTurnCompleted",
141
182
  {
@@ -152,6 +193,8 @@ export const createMessageAggregate = <
152
193
  previousResponseId: p.previousResponseId,
153
194
  usage: p.usage,
154
195
  isGenerating: false,
196
+ partialBlocks: undefined,
197
+ partialLastSeq: undefined,
155
198
  } as any);
156
199
  },
157
200
  )
@@ -192,6 +235,8 @@ export const createMessageAggregate = <
192
235
  toolName: string(),
193
236
  toolCallId: string(),
194
237
  content: string(),
238
+ /** Patrz dokumentacja `messageSent.assistantMessageId`. */
239
+ assistantMessageId: messageId.optional(),
195
240
  },
196
241
  async (ctx, event) => {
197
242
  const p = event.payload;
@@ -208,6 +253,11 @@ export const createMessageAggregate = <
208
253
  )
209
254
 
210
255
  // ─── sendMessage — user sends message, creates session ──────
256
+ // Emit'uje DWA eventy w jednej transakcji: messageSent (user row) +
257
+ // assistantTurnStarted (empty assistant row z isGenerating=true). Dzięki
258
+ // temu klient widzi placeholder asystenta natychmiast (przez useQuery
259
+ // push) i otwiera SSE zanim AI listener zacznie emit chunków → streaming
260
+ // od pierwszego znaku visible.
211
261
  .mutateMethod(
212
262
  "sendMessage",
213
263
  (fn) => fn.withParams({
@@ -218,8 +268,23 @@ export const createMessageAggregate = <
218
268
  ONLY_SERVER &&
219
269
  (async (ctx, params) => {
220
270
  const userMsgId = messageId.generate();
271
+ const assistantMsgId = messageId.generate();
221
272
  const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
222
273
 
274
+ // KOLEJNOŚĆ EMIT WAŻNA: assistantTurnStarted PRZED messageSent.
275
+ // aiGenerationListener listens to `messageSent` (async). Async
276
+ // listeners w arc fire'ują się "synchronicznie" w trakcie publish
277
+ // (handler startuje, suspendsna pierwszym await). Gdybyśmy emit'owali
278
+ // messageSent jako pierwsze, listener mógłby zacząć pracować zanim
279
+ // assistantTurnStarted skomituje assistant row do DB → listener
280
+ // tries to use messageId którego nie ma jeszcze w stores.
281
+ await ctx.assistantTurnStarted.emit({
282
+ messageId: assistantMsgId,
283
+ scopeId: params.scopeId,
284
+ sessionId,
285
+ model: params.model,
286
+ });
287
+
223
288
  await ctx.messageSent.emit({
224
289
  messageId: userMsgId,
225
290
  scopeId: params.scopeId,
@@ -227,8 +292,10 @@ export const createMessageAggregate = <
227
292
  role: "user",
228
293
  content: params.content,
229
294
  model: params.model,
295
+ assistantMessageId: assistantMsgId,
230
296
  });
231
- return { messageId: userMsgId, sessionId };
297
+
298
+ return { messageId: userMsgId, sessionId, assistantMessageId: assistantMsgId };
232
299
  }),
233
300
  ),
234
301
  )
@@ -258,6 +325,26 @@ export const createMessageAggregate = <
258
325
  ),
259
326
  )
260
327
 
328
+ // ─── saveProgressSnapshot — zapis partial JSON w trakcie streamingu ─
329
+ .mutateMethod(
330
+ "saveProgressSnapshot",
331
+ (fn) => fn.withParams({
332
+ messageId,
333
+ partialBlocks: string(),
334
+ partialLastSeq: number(),
335
+ }).handle(
336
+ ONLY_SERVER &&
337
+ (async (ctx, params) => {
338
+ await ctx.assistantTurnProgressSnapshot.emit({
339
+ messageId: params.messageId,
340
+ partialBlocks: params.partialBlocks,
341
+ partialLastSeq: params.partialLastSeq,
342
+ });
343
+ return { ok: true };
344
+ }),
345
+ ),
346
+ )
347
+
261
348
  // ─── completeAssistantTurn — partial update of the open turn row ─
262
349
  .mutateMethod(
263
350
  "completeAssistantTurn",
@@ -311,6 +398,9 @@ export const createMessageAggregate = <
311
398
  )
312
399
 
313
400
  // ─── respondToTool — user answers interactive tool ──────────
401
+ // Patrz `sendMessage` — analogicznie tworzy assistant row w tej samej
402
+ // transakcji, żeby resume listener wypełnił istniejący row a klient
403
+ // widział streaming live.
314
404
  .mutateMethod(
315
405
  "respondToTool",
316
406
  (fn) => fn.withParams({
@@ -322,8 +412,16 @@ export const createMessageAggregate = <
322
412
  ONLY_SERVER &&
323
413
  (async (ctx, params) => {
324
414
  const msgId = messageId.generate();
415
+ const assistantMsgId = messageId.generate();
325
416
  const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
326
417
 
418
+ // KOLEJNOŚĆ EMIT — patrz komentarz w `sendMessage`.
419
+ await ctx.assistantTurnStarted.emit({
420
+ messageId: assistantMsgId,
421
+ scopeId: params.scopeId,
422
+ sessionId,
423
+ });
424
+
327
425
  await ctx.userResponded.emit({
328
426
  messageId: msgId,
329
427
  scopeId: params.scopeId,
@@ -331,8 +429,10 @@ export const createMessageAggregate = <
331
429
  toolName: params.toolName,
332
430
  toolCallId: params.toolCallId,
333
431
  content: params.result,
432
+ assistantMessageId: assistantMsgId,
334
433
  });
335
- return { messageId: msgId, sessionId };
434
+
435
+ return { messageId: msgId, sessionId, assistantMessageId: assistantMsgId };
336
436
  }),
337
437
  ),
338
438
  )
@@ -351,7 +451,17 @@ export const createMessageAggregate = <
351
451
  ONLY_SERVER &&
352
452
  (async (ctx, params) => {
353
453
  const msgId = messageId.generate();
454
+ const assistantMsgId = messageId.generate();
354
455
  const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
456
+ const model = params.model ?? "gpt-5";
457
+
458
+ // KOLEJNOŚĆ EMIT — patrz komentarz w `sendMessage`.
459
+ await ctx.assistantTurnStarted.emit({
460
+ messageId: assistantMsgId,
461
+ scopeId: params.scopeId,
462
+ sessionId,
463
+ model,
464
+ });
355
465
 
356
466
  await ctx.messageSent.emit({
357
467
  messageId: msgId,
@@ -359,9 +469,11 @@ export const createMessageAggregate = <
359
469
  sessionId,
360
470
  role: "system",
361
471
  content: "Rozpocznij ten etap. Przywitaj się i zadaj pierwsze pytanie.",
362
- model: params.model ?? "gpt-5.4-mini",
472
+ model,
473
+ assistantMessageId: assistantMsgId,
363
474
  });
364
- return { messageId: msgId, sessionId };
475
+
476
+ return { messageId: msgId, sessionId, assistantMessageId: assistantMsgId };
365
477
  }),
366
478
  ),
367
479
  )
@@ -382,7 +494,17 @@ export const createMessageAggregate = <
382
494
  ONLY_SERVER &&
383
495
  (async (ctx, params) => {
384
496
  const msgId = messageId.generate();
497
+ const assistantMsgId = messageId.generate();
385
498
  const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
499
+ const model = params.model ?? "gpt-5";
500
+
501
+ // KOLEJNOŚĆ EMIT — patrz komentarz w `sendMessage`.
502
+ await ctx.assistantTurnStarted.emit({
503
+ messageId: assistantMsgId,
504
+ scopeId: params.scopeId,
505
+ sessionId,
506
+ model,
507
+ });
386
508
 
387
509
  await ctx.messageSent.emit({
388
510
  messageId: msgId,
@@ -390,9 +512,11 @@ export const createMessageAggregate = <
390
512
  sessionId,
391
513
  role: "system",
392
514
  content: params.content,
393
- model: params.model ?? "gpt-5.4-mini",
515
+ model,
516
+ assistantMessageId: assistantMsgId,
394
517
  });
395
- return { messageId: msgId, sessionId };
518
+
519
+ return { messageId: msgId, sessionId, assistantMessageId: assistantMsgId };
396
520
  }),
397
521
  ),
398
522
  )
@@ -17,7 +17,7 @@ import { createMessageId, createMessageAggregate } from "./aggregates/message";
17
17
  import { createAiGenerationListener, createAiResumeListener } from "./listeners/ai-generation-listener";
18
18
  import { createChatStreamRoute } from "./routes/chat-stream-route";
19
19
  import { createChatComponent } from "./react/chat-component";
20
- import type { ChatLabels } from "@arcote.tech/arc-ds";
20
+ import type { ChatInputTextareaSlotProps, ChatLabels } from "@arcote.tech/arc-ds";
21
21
  import type { ComponentType, ReactNode } from "react";
22
22
 
23
23
  export interface ChatReactComponentOptions {
@@ -33,6 +33,12 @@ export interface ChatReactComponentOptions {
33
33
  onClick: () => void;
34
34
  disabled: boolean;
35
35
  }) => ReactNode;
36
+ /**
37
+ * Slot na pole tekstowe ChatInput. Pozwala podpiąć `VoiceTextarea` z
38
+ * `@arcote.tech/arc-ai-voice` żeby włączyć dyktowanie głosowe w chacie.
39
+ * Bez tego propsa używany jest domyślny `TextareaField`.
40
+ */
41
+ renderTextarea?: (props: ChatInputTextareaSlotProps) => ReactNode;
36
42
  /** Partial overrides for chat i18n labels. Falls back to English defaults. */
37
43
  labels?: Partial<ChatLabels>;
38
44
  /**
@@ -282,6 +288,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
282
288
  showModelSelector: options.showModelSelector,
283
289
  showWebSearch: options.showWebSearch,
284
290
  renderSendButton: options.renderSendButton,
291
+ renderTextarea: options.renderTextarea,
285
292
  labels: options.labels,
286
293
  footer: options.footer,
287
294
  });
@@ -3,6 +3,7 @@ import { listener, type ArcContextElement, type ArcFunction } from "@arcote.tech
3
3
  import type {
4
4
  ArcToolAny,
5
5
  AssistantContentBlock,
6
+ ChatStreamEvent,
6
7
  Conversation,
7
8
  ConversationTurn,
8
9
  LLMProvider,
@@ -187,6 +188,13 @@ interface RunLoopConfig {
187
188
  maxExecutionCount: number;
188
189
  toolChoice?: "auto" | "required" | { type: "function"; name: string };
189
190
  instruction?: ArcFunction<any>;
191
+ /** ID pustego assistant row'a utworzonego synchronicznie w mutacji
192
+ * triggerującej generację (`sendMessage`/`systemMessage`/`startStage`/
193
+ * `respondToTool`). Listener używa go w PIERWSZEJ iteracji zamiast
194
+ * wołać `startAssistantTurn`. Dzięki temu klient widzi assistant row
195
+ * natychmiast po mutacji i otwiera SSE zanim chunki zaczną lecieć.
196
+ * Następne iteracje (multi-turn po server tool exec) tworzą fresh rows. */
197
+ preCreatedAssistantMessageId?: string;
190
198
  }
191
199
 
192
200
  async function runGenerationLoop(config: RunLoopConfig) {
@@ -208,11 +216,34 @@ async function runGenerationLoop(config: RunLoopConfig) {
208
216
  let history = config.history;
209
217
  let newTurnsStartIdx = config.initialNewTurnsStartIdx;
210
218
  let executionCount = 0;
211
- /** The in-progress assistant row for the CURRENT iteration. Set at the top
212
- * of every iteration via `startAssistantTurn`; closed at the bottom via
213
- * `completeAssistantTurn`. The error handler uses it to mark the open turn
214
- * as failed. */
215
- let currentTurnId: string | undefined;
219
+ /** The in-progress assistant row for the CURRENT iteration. Pre-set z
220
+ * `preCreatedAssistantMessageId` dla pierwszej iteracji (atomowo utworzony
221
+ * w mutacji). Wartość `undefined` przy iteracjach 2+ loop wywoła
222
+ * `startAssistantTurn` jak wcześniej. Closed at the bottom via
223
+ * `completeAssistantTurn`. Error handler używa do mark open turn jako
224
+ * failed. */
225
+ let currentTurnId: string | undefined = config.preCreatedAssistantMessageId;
226
+ /** True gdy w bieżącej iteracji `currentTurnId` był pre-utworzony przez
227
+ * mutację. Wtedy skipujemy ponowne `startAssistantTurn`. */
228
+ let usingPreCreatedTurn = config.preCreatedAssistantMessageId != null;
229
+ /** Monotonicznie rosnący sequence number na całą sesję — klient po stronie
230
+ * React trzyma `lastSeq` i dedupuje. */
231
+ let seqCounter = 0;
232
+ /** Wrapper na broadcast — wstrzykuje seq + messageId (gdy znany). */
233
+ const send = (
234
+ evt: Omit<ChatStreamEvent, "seq" | "sessionId"> & {
235
+ seq?: number;
236
+ sessionId?: string;
237
+ },
238
+ ) => {
239
+ seqCounter += 1;
240
+ broadcast(sessionId, {
241
+ ...evt,
242
+ sessionId,
243
+ seq: seqCounter,
244
+ messageId: evt.messageId ?? currentTurnId,
245
+ } as ChatStreamEvent);
246
+ };
216
247
 
217
248
  try {
218
249
  while (executionCount <= maxExecutionCount) {
@@ -236,10 +267,50 @@ async function runGenerationLoop(config: RunLoopConfig) {
236
267
  // Open a new in-progress assistant row before the stream starts. The
237
268
  // frontend detects `isGenerating: true` on this row and subscribes to
238
269
  // the SSE stream identified by `sessionId`.
239
- const turnStart = await ctx
240
- .mutate(messageElement)
241
- .startAssistantTurn({ scopeId, sessionId, model });
242
- currentTurnId = turnStart.messageId;
270
+ //
271
+ // Pierwsza iteracja: row już utworzony w mutacji triggerującej (przez
272
+ // `preCreatedAssistantMessageId`) skipujemy. Kolejne iteracje
273
+ // (multi-turn po server tool exec): tworzymy fresh row.
274
+ if (usingPreCreatedTurn) {
275
+ usingPreCreatedTurn = false; // tylko dla 1. iteracji
276
+ } else {
277
+ const turnStart = await ctx
278
+ .mutate(messageElement)
279
+ .startAssistantTurn({ scopeId, sessionId, model });
280
+ currentTurnId = turnStart.messageId;
281
+ }
282
+
283
+ // Snapshot policy — co N=20 chunków LUB co T=2s zapisujemy `partialBlocks`
284
+ // do DB. Page reload mid-stream → klient czyta partial + kontynuuje SSE.
285
+ let chunksSinceSnapshot = 0;
286
+ let lastSnapshotAt = Date.now();
287
+ const SNAPSHOT_EVERY_N = 20;
288
+ const SNAPSHOT_EVERY_MS = 2000;
289
+ /** Aktualnie budowane bloki — accumulator dla snapshotu. */
290
+ const liveBlocks: AssistantContentBlock[] = [];
291
+ const liveToolCalls = new Map<
292
+ string,
293
+ { name: string; argumentsBuffer: string }
294
+ >();
295
+ const maybeSnapshot = async (force = false) => {
296
+ chunksSinceSnapshot += 1;
297
+ const due =
298
+ force ||
299
+ chunksSinceSnapshot >= SNAPSHOT_EVERY_N ||
300
+ Date.now() - lastSnapshotAt >= SNAPSHOT_EVERY_MS;
301
+ if (!due || !currentTurnId) return;
302
+ chunksSinceSnapshot = 0;
303
+ lastSnapshotAt = Date.now();
304
+ try {
305
+ await ctx.mutate(messageElement).saveProgressSnapshot({
306
+ messageId: currentTurnId,
307
+ partialBlocks: JSON.stringify(liveBlocks),
308
+ partialLastSeq: seqCounter,
309
+ });
310
+ } catch {
311
+ // snapshot best-effort — pojawi się przy kolejnym chunku
312
+ }
313
+ };
243
314
 
244
315
  const result = await provider.streamComplete(
245
316
  {
@@ -248,20 +319,76 @@ async function runGenerationLoop(config: RunLoopConfig) {
248
319
  conversation,
249
320
  tools: effectiveToolDefs,
250
321
  toolChoice,
322
+ // Skraca time-to-first-token dla gpt-5 / o-series — pomija reasoning
323
+ // step. Adaptery bez wsparcia ignorują.
324
+ reasoningEffort: "minimal",
251
325
  },
252
326
  (chunk) => {
253
- if (chunk.type === "content_delta" && chunk.content) {
254
- broadcast(sessionId, {
255
- type: "content_delta",
256
- sessionId,
257
- content: chunk.content,
327
+ if (chunk.type === "text_delta" && chunk.textDelta) {
328
+ // accumulate w liveBlocks (last text block lub nowy)
329
+ const last = liveBlocks[liveBlocks.length - 1];
330
+ if (last && last.type === "text") {
331
+ last.text += chunk.textDelta;
332
+ } else {
333
+ liveBlocks.push({ type: "text", text: chunk.textDelta });
334
+ }
335
+ send({ type: "text_delta", textDelta: chunk.textDelta });
336
+ void maybeSnapshot();
337
+ } else if (chunk.type === "tool_call_started" && chunk.toolCallId) {
338
+ liveToolCalls.set(chunk.toolCallId, {
339
+ name: chunk.toolCallName ?? "",
340
+ argumentsBuffer: "",
258
341
  });
259
- } else if (chunk.type === "usage_update") {
260
- broadcast(sessionId, {
261
- type: "usage_update",
262
- sessionId,
263
- usage: chunk.usage,
342
+ liveBlocks.push({
343
+ type: "tool_call",
344
+ id: chunk.toolCallId,
345
+ name: chunk.toolCallName ?? "",
346
+ arguments: {},
347
+ });
348
+ send({
349
+ type: "tool_call_pending",
350
+ toolCallId: chunk.toolCallId,
351
+ toolCallName: chunk.toolCallName,
352
+ });
353
+ void maybeSnapshot(true);
354
+ } else if (
355
+ chunk.type === "tool_call_arguments_delta" &&
356
+ chunk.toolCallId &&
357
+ chunk.argumentsDelta
358
+ ) {
359
+ const tc = liveToolCalls.get(chunk.toolCallId);
360
+ if (tc) tc.argumentsBuffer += chunk.argumentsDelta;
361
+ send({
362
+ type: "tool_call_arguments_delta",
363
+ toolCallId: chunk.toolCallId,
364
+ argumentsDelta: chunk.argumentsDelta,
264
365
  });
366
+ } else if (
367
+ chunk.type === "tool_call_arguments_complete" &&
368
+ chunk.toolCallId
369
+ ) {
370
+ // update accumulated block z complete args
371
+ const args = chunk.arguments ?? {};
372
+ const block = liveBlocks.find(
373
+ (b): b is Extract<AssistantContentBlock, { type: "tool_call" }> =>
374
+ b.type === "tool_call" && b.id === chunk.toolCallId,
375
+ );
376
+ if (block) block.arguments = args;
377
+ // toolCallName z liveBlocks (provider zna nazwę od tool_call_started)
378
+ // — bez tego klient pushuje tool z `toolName: ""` i nie znajduje
379
+ // viewComponent w toolsMap → fallback do generic ChatToolLog
380
+ // ("Wykonuję..."), AskQuestionsView nigdy nie mountuje się.
381
+ const toolCallName =
382
+ liveToolCalls.get(chunk.toolCallId)?.name ?? block?.name;
383
+ send({
384
+ type: "tool_call_arguments_complete",
385
+ toolCallId: chunk.toolCallId,
386
+ toolCallName,
387
+ arguments: args,
388
+ });
389
+ void maybeSnapshot(true);
390
+ } else if (chunk.type === "usage_update") {
391
+ send({ type: "usage_update", usage: chunk.usage });
265
392
  }
266
393
  },
267
394
  );
@@ -297,12 +424,12 @@ async function runGenerationLoop(config: RunLoopConfig) {
297
424
  currentTurnId = undefined;
298
425
 
299
426
  if (!hasToolCalls) {
300
- broadcast(sessionId, {
427
+ send({
301
428
  type: "done",
302
- sessionId,
303
429
  usage: result.usage,
304
430
  finishReason: result.finishReason,
305
431
  executionCount,
432
+ lastSeq: seqCounter,
306
433
  });
307
434
  endStream(sessionId);
308
435
  return;
@@ -316,10 +443,13 @@ async function runGenerationLoop(config: RunLoopConfig) {
316
443
  // Execute server tools — append each result to history as a separate turn
317
444
  const newToolResults: ConversationTurn[] = [];
318
445
  for (const tc of serverCalls) {
319
- broadcast(sessionId, {
320
- type: "server_tool_start",
321
- sessionId,
322
- toolCall: tc,
446
+ // `tool_call_pending` poszło już ze streamingu (przy `started`).
447
+ // Teraz `executing` po stronie servera.
448
+ send({
449
+ type: "tool_call_arguments_complete",
450
+ toolCallId: tc.id,
451
+ toolCallName: tc.name,
452
+ arguments: tc.arguments,
323
453
  executionCount,
324
454
  });
325
455
 
@@ -352,10 +482,10 @@ async function runGenerationLoop(config: RunLoopConfig) {
352
482
  isError,
353
483
  });
354
484
 
355
- broadcast(sessionId, {
356
- type: "server_tool_result",
357
- sessionId,
358
- toolCall: tc,
485
+ send({
486
+ type: "tool_call_executed",
487
+ toolCallId: tc.id,
488
+ toolCallName: tc.name,
359
489
  toolResult: {
360
490
  toolCallId: tc.id,
361
491
  name: tc.name,
@@ -378,9 +508,8 @@ async function runGenerationLoop(config: RunLoopConfig) {
378
508
  // The assistant turn (with the interactive tool_call) is already
379
509
  // persisted above. Listener B will resume.
380
510
  if (interactiveCalls.length > 0) {
381
- broadcast(sessionId, {
511
+ send({
382
512
  type: "interactive_tool_request",
383
- sessionId,
384
513
  toolCalls: interactiveCalls,
385
514
  executionCount,
386
515
  });
@@ -397,9 +526,8 @@ async function runGenerationLoop(config: RunLoopConfig) {
397
526
  }
398
527
  } catch (err) {
399
528
  const errorMsg = `AI error: ${err instanceof Error ? err.message : String(err)}`;
400
- broadcast(sessionId, {
529
+ send({
401
530
  type: "error",
402
- sessionId,
403
531
  error: errorMsg,
404
532
  executionCount,
405
533
  });
@@ -451,9 +579,11 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
451
579
  scopeId,
452
580
  content: userContent,
453
581
  model: modelName,
454
- } = event.payload;
582
+ role,
583
+ assistantMessageId,
584
+ } = event.payload as any;
455
585
 
456
- const model = modelName ?? "gpt-5.4-mini";
586
+ const model = modelName ?? "gpt-5";
457
587
  const provider = resolveProvider(model, scopeId);
458
588
  if (!provider) return;
459
589
 
@@ -483,6 +613,12 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
483
613
  maxExecutionCount,
484
614
  toolChoice: config.toolChoice,
485
615
  instruction,
616
+ // Pre-utworzony empty assistant row z mutacji `sendMessage`/
617
+ // `systemMessage`/`startStage` — pierwsza iteracja używa go zamiast
618
+ // wołać `startAssistantTurn`.
619
+ preCreatedAssistantMessageId: (
620
+ event.payload as { assistantMessageId?: string }
621
+ ).assistantMessageId,
486
622
  });
487
623
  });
488
624
  }
@@ -549,7 +685,7 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
549
685
  const lastAssistantRow = [...dbMessages]
550
686
  .reverse()
551
687
  .find((m: any) => m.role === "assistant" && m.model);
552
- const model = lastAssistantRow?.model ?? "gpt-5.4-mini";
688
+ const model = lastAssistantRow?.model ?? "gpt-5";
553
689
 
554
690
  const provider = resolveProvider(model, scopeId);
555
691
  if (!provider) return;
@@ -572,6 +708,10 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
572
708
  maxExecutionCount,
573
709
  toolChoice: config.toolChoice,
574
710
  instruction,
711
+ // Pre-utworzony empty assistant row z mutacji `respondToTool`.
712
+ preCreatedAssistantMessageId: (
713
+ event.payload as { assistantMessageId?: string }
714
+ ).assistantMessageId,
575
715
  });
576
716
  });
577
717
  }