@thegitai/cli 1.0.0-preview.47 → 1.0.0-preview.49

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,10 +1,13 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { drainBackgroundJobNotifications } from '../background-jobs.js';
3
+ import { cancelActiveCommand } from '../executor.js';
2
4
  import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
3
5
  import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
4
6
  import { executeLocalToolCall } from '../tool-executor.js';
5
7
  import { saveGeneratedImage } from '../tools/save-generated-image.js';
6
8
  import { isUserInputQuestionArray } from './contracts.js';
7
- import { createTraceContext, gatewayFailureCategory, transportFailureDetail, normalizeServerUrl, readErrorResponse, } from './http.js';
9
+ import { createTraceContext, gatewayFailureCategory, normalizeServerUrl, QUOTA_EXCEEDED_CODE, readErrorResponse, ServerApiError, transportFailureDetail, } from './http.js';
10
+ import { createTurnRecovery, fetchTurnAttempt, interruptedStream, isRetryableTurnStatus, isTurnRecoveryExhausted, readTurnChunk, } from './turn-recovery.js';
8
11
  import { collectClientEnvironment } from '../client-environment.js';
9
12
  import { collectProjectOrientation } from '../project-orientation.js';
10
13
  import { describeSessionImageStore } from '../core/session-image-store.js';
@@ -38,8 +41,13 @@ export function isTurnCancelledError(error) {
38
41
  }
39
42
  function parseSseBlock(block) {
40
43
  let event = 'message';
44
+ let id = '';
41
45
  const dataLines = [];
42
46
  for (const line of block.split(/\r?\n/)) {
47
+ if (line.startsWith('id:')) {
48
+ id = line.slice(3).trim();
49
+ continue;
50
+ }
43
51
  if (line.startsWith('event:')) {
44
52
  event = line.slice('event:'.length).trim() || event;
45
53
  continue;
@@ -52,10 +60,10 @@ function parseSseBlock(block) {
52
60
  return null;
53
61
  const text = dataLines.join('\n');
54
62
  try {
55
- return { event, data: JSON.parse(text) };
63
+ return { id, event, data: JSON.parse(text) };
56
64
  }
57
65
  catch {
58
- return { event, data: text };
66
+ throw new Error('Server emitted invalid event JSON.');
59
67
  }
60
68
  }
61
69
  let toolStateSeqCounter = 0;
@@ -92,7 +100,7 @@ export function preserveCancelledTurnInput(session, input) {
92
100
  return;
93
101
  for (let i = session.history.length - 1; i >= 0; i--) {
94
102
  const entry = session.history[i];
95
- if (!entry || entry.role !== 'user')
103
+ if (!entry || entry.role !== 'user' || entry.kind === 'userInterjection')
96
104
  continue;
97
105
  const prior = userHistoryText(entry);
98
106
  if (!prior)
@@ -138,12 +146,9 @@ function turnPhaseOf(error) {
138
146
  const phase = error?.thegitaiPhase;
139
147
  return typeof phase === 'string' ? phase : null;
140
148
  }
141
- function historyHasToolCall(session, callId) {
142
- return session.history.some((entry) => (entry.parts ?? []).some((part) => String(part?.functionCall?.id ?? '') === callId));
143
- }
144
149
  function preserveCancelledTurnToolResult(session, input, event, result) {
145
150
  const callId = String(event.call.id ?? '').trim();
146
- if (!callId || historyHasToolCall(session, callId))
151
+ if (!callId)
147
152
  return;
148
153
  preserveCancelledTurnInput(session, input);
149
154
  session.history.push({
@@ -163,6 +168,22 @@ function preserveCancelledTurnToolResult(session, input, event, result) {
163
168
  ],
164
169
  });
165
170
  }
171
+ const turnInterjections = new Map();
172
+ function preserveTurnInterjection(session, input, sent) {
173
+ sent.delivered = true;
174
+ preserveCancelledTurnInput(session, input);
175
+ session.history.push({
176
+ role: 'user',
177
+ parts: [{ text: sent.text }],
178
+ kind: 'userInterjection',
179
+ userInput: sent.text,
180
+ });
181
+ }
182
+ function preserveTurnAnswer(session, input, requestId, questions, result) {
183
+ preserveCancelledTurnInput(session, input);
184
+ session.history.push({ role: 'model', parts: [{ userInputRequest: { requestId, questions } }] });
185
+ session.history.push({ role: 'user', parts: [{ userInputResult: { requestId, result } }] });
186
+ }
166
187
  function publicStatusMessage(data) {
167
188
  if (!data || typeof data !== 'object')
168
189
  return null;
@@ -220,15 +241,33 @@ function normalizeShellJobToolCall(call) {
220
241
  }
221
242
  return changed ? { ...call, args } : call;
222
243
  }
223
- async function postToolResult({ config, turnId, event, result, session, fetchImpl, traceId, }) {
244
+ function createTurnRequester({ fetchImpl, recovery, signal, traceId, }) {
245
+ return (operation, url, init) => {
246
+ const caller = init.signal ? AbortSignal.any([signal, init.signal]) : signal;
247
+ return tagTurnPhase(operation, () => recovery.run(operation, async () => {
248
+ const response = await fetchTurnAttempt(fetchImpl, url, init, caller, {
249
+ readBody: true,
250
+ });
251
+ if (isRetryableTurnStatus(response.status)) {
252
+ throw await readErrorResponse(response, traceId);
253
+ }
254
+ return response;
255
+ }, caller));
256
+ };
257
+ }
258
+ function turnUrl(config, turnId, action) {
259
+ return `${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/${action}`;
260
+ }
261
+ async function postToolResult({ config, turnId, event, result, session, request, traceId, delivery, }) {
224
262
  const payload = {
263
+ executionId: event.executionId,
225
264
  toolCallId: event.call.id,
226
265
  result,
227
266
  toolState: toolStateFromSession(session),
228
267
  toolStateSeq: ++toolStateSeqCounter,
229
268
  };
230
269
  const trace = createTraceContext(traceId);
231
- const response = await tagTurnPhase('posting a tool result', () => fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
270
+ const response = await request('posting a tool result', turnUrl(config, turnId, 'tool-result'), {
232
271
  method: 'POST',
233
272
  headers: {
234
273
  authorization: `Bearer ${config.token}`,
@@ -236,21 +275,21 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
236
275
  ...trace.headers,
237
276
  },
238
277
  body: JSON.stringify(payload),
239
- }));
240
- if (response.status === 410) {
278
+ signal: delivery,
279
+ });
280
+ if (response.status === 410)
241
281
  return;
242
- }
243
282
  if (!response.ok) {
244
283
  throw await readErrorResponse(response, trace.traceId);
245
284
  }
246
285
  }
247
- async function postUserInputResult({ config, turnId, requestId, result, fetchImpl, traceId, }) {
286
+ async function postUserInputResult({ config, turnId, requestId, result, request, traceId, delivery, }) {
248
287
  const payload = {
249
288
  requestId,
250
289
  result,
251
290
  };
252
291
  const trace = createTraceContext(traceId);
253
- const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/user-input-result`, {
292
+ const response = await request('posting your answer', turnUrl(config, turnId, 'user-input-result'), {
254
293
  method: 'POST',
255
294
  headers: {
256
295
  authorization: `Bearer ${config.token}`,
@@ -258,14 +297,15 @@ async function postUserInputResult({ config, turnId, requestId, result, fetchImp
258
297
  ...trace.headers,
259
298
  },
260
299
  body: JSON.stringify(payload),
300
+ signal: delivery,
261
301
  });
262
- if (response.status === 410) {
302
+ if (response.status === 410)
263
303
  return;
264
- }
265
304
  if (!response.ok) {
266
305
  throw await readErrorResponse(response, trace.traceId);
267
306
  }
268
307
  }
308
+ const activeTurnRequesters = new Map();
269
309
  export async function postInterjection({ config, turnId, text, messageId, imageAttachments = [], fetchImpl = globalThis.fetch, traceId, }) {
270
310
  const payload = {
271
311
  text,
@@ -273,7 +313,8 @@ export async function postInterjection({ config, turnId, text, messageId, imageA
273
313
  ...(imageAttachments.length > 0 ? { imageAttachments } : {}),
274
314
  };
275
315
  const trace = createTraceContext(traceId);
276
- const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/interject`, {
316
+ const url = turnUrl(config, turnId, 'interject');
317
+ const init = {
277
318
  method: 'POST',
278
319
  headers: {
279
320
  authorization: `Bearer ${config.token}`,
@@ -281,11 +322,37 @@ export async function postInterjection({ config, turnId, text, messageId, imageA
281
322
  ...trace.headers,
282
323
  },
283
324
  body: JSON.stringify(payload),
284
- });
325
+ };
326
+ const request = activeTurnRequesters.get(turnId);
327
+ const sent = turnInterjections.get(turnId);
328
+ const record = {
329
+ messageId,
330
+ text,
331
+ hasImages: imageAttachments.length > 0,
332
+ delivered: false,
333
+ };
334
+ sent?.push(record);
335
+ const forget = () => {
336
+ const index = sent?.indexOf(record) ?? -1;
337
+ if (index !== -1)
338
+ sent.splice(index, 1);
339
+ };
340
+ let response;
341
+ try {
342
+ response = request
343
+ ? await request('posting a message into the turn', url, init)
344
+ : await fetchImpl(url, init);
345
+ }
346
+ catch (error) {
347
+ forget();
348
+ throw error;
349
+ }
285
350
  if (response.status === 410) {
351
+ forget();
286
352
  return 'stale';
287
353
  }
288
354
  if (!response.ok) {
355
+ forget();
289
356
  throw await readErrorResponse(response, trace.traceId);
290
357
  }
291
358
  return 'delivered';
@@ -313,14 +380,13 @@ function exitServerTurnId(session) {
313
380
  turnIdOverrides.delete(session);
314
381
  }
315
382
  }
316
- async function executeAndPostToolResult({ config, session, event, input, fetchImpl, signal, traceId, }) {
383
+ async function executeAndPostToolResult({ config, session, event, input, request, signal, delivery, executed, traceId, }) {
317
384
  const turnId = String(event?.turnId ?? '').trim();
318
- if (!turnId || !event?.call?.id || !event.call.name) {
385
+ if (!turnId || !event?.executionId || !event?.call?.id || !event.call.name) {
319
386
  throw new Error('Server emitted an invalid tool-call event.');
320
387
  }
321
- if (signal?.aborted) {
322
- throw new TurnCancelledError();
323
- }
388
+ signal.throwIfAborted();
389
+ delivery.throwIfAborted();
324
390
  const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
325
391
  if (serverSessionTurnId) {
326
392
  enterServerTurnId(session, serverSessionTurnId);
@@ -339,17 +405,19 @@ async function executeAndPostToolResult({ config, session, event, input, fetchIm
339
405
  })
340
406
  : await executeLocalToolCall(session, call);
341
407
  preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
342
- if (signal?.aborted) {
343
- throw new TurnCancelledError();
344
- }
408
+ executed();
409
+ signal.throwIfAborted();
410
+ if (delivery.aborted)
411
+ return;
345
412
  await postToolResult({
346
413
  config,
347
414
  turnId,
348
415
  event,
349
416
  result: rawResult,
350
417
  session,
351
- fetchImpl,
418
+ request,
352
419
  traceId,
420
+ delivery,
353
421
  });
354
422
  }
355
423
  finally {
@@ -358,190 +426,312 @@ async function executeAndPostToolResult({ config, session, event, input, fetchIm
358
426
  }
359
427
  }
360
428
  }
361
- async function consumeTurnStream({ response, config, session, input, fetchImpl, signal, traceId, onTurnStart, onInterjectionDelivered, }) {
362
- if (!response.body) {
363
- throw new Error('Server returned an empty chat stream.');
429
+ function createTurnWork() {
430
+ const executions = new Map();
431
+ const inFlight = new Set();
432
+ const running = new Set();
433
+ let lastSerial = Promise.resolve();
434
+ let hasFailed = false;
435
+ let rejectFailed;
436
+ const failed = new Promise((_, reject) => {
437
+ rejectFailed = reject;
438
+ });
439
+ failed.catch(() => { });
440
+ function dispatch(key, parallel, run) {
441
+ if (executions.has(key))
442
+ return;
443
+ const prior = parallel ? lastSerial : Promise.all([lastSerial, ...inFlight]);
444
+ let executed;
445
+ const local = new Promise((resolve) => {
446
+ executed = resolve;
447
+ });
448
+ running.add(local);
449
+ void local.then(() => running.delete(local));
450
+ const execution = prior.then(() => run(executed));
451
+ execution.then(executed, executed);
452
+ executions.set(key, execution);
453
+ inFlight.add(execution);
454
+ if (!parallel)
455
+ lastSerial = execution;
456
+ execution.then(() => inFlight.delete(execution), (error) => {
457
+ inFlight.delete(execution);
458
+ hasFailed = true;
459
+ rejectFailed(error);
460
+ });
364
461
  }
365
- const decoder = new TextDecoder();
366
- const reader = response.body.getReader();
367
- let buffer = '';
368
- const finalResult = {
369
- current: null,
462
+ return {
463
+ dispatch,
464
+ failed,
465
+ get hasFailed() {
466
+ return hasFailed;
467
+ },
468
+ async executed() {
469
+ await Promise.all([...running]);
470
+ },
471
+ async settled() {
472
+ await Promise.allSettled([...inFlight]);
473
+ },
370
474
  };
371
- const pendingParallelTools = [];
372
- let firstParallelFailure = null;
373
- let rejectOnParallelFailure = null;
374
- const parallelToolFailure = new Promise((_, reject) => {
375
- rejectOnParallelFailure = reject;
376
- });
377
- parallelToolFailure.catch(() => { });
378
- function recordParallelFailure(error) {
379
- const failure = error ?? new Error('Local tool execution failed.');
380
- if (firstParallelFailure == null) {
381
- firstParallelFailure = failure;
382
- rejectOnParallelFailure?.(failure);
383
- }
384
- return failure;
475
+ }
476
+ function terminalFailure(event, traceId) {
477
+ const data = (event.data ?? {});
478
+ const message = String(data.message ?? 'The turn failed before completion.');
479
+ const eventTraceId = typeof data.traceId === 'string' ? data.traceId : traceId;
480
+ if (event.event === 'cancelled') {
481
+ return new TurnCancelledError(message);
385
482
  }
386
- async function drainParallelTools() {
387
- if (!pendingParallelTools.length)
388
- return;
389
- const pending = pendingParallelTools.splice(0);
390
- const outcomes = await Promise.all(pending);
391
- for (const outcome of outcomes) {
392
- if (outcome != null)
393
- throw outcome;
394
- }
483
+ if (data.code === QUOTA_EXCEEDED_CODE) {
484
+ return new ServerApiError(message, 429, eventTraceId, QUOTA_EXCEEDED_CODE, data.details ?? {});
395
485
  }
396
- async function handleEvent(event) {
397
- if (event.event === 'turn-start') {
398
- const turnId = String(event.data?.turnId ?? '').trim();
399
- if (turnId)
400
- onTurnStart?.(turnId);
486
+ return new ChatTurnFailedError(message, typeof data.category === 'string' ? data.category : 'unknown_error', Boolean(data.retryable), eventTraceId, data.snapshot);
487
+ }
488
+ const TURN_EVENT_ACK_INTERVAL_MS = 5_000;
489
+ const MAX_TURN_EVENT_BYTES = 64 * 1024 * 1024;
490
+ function createEventAcknowledger(send) {
491
+ let latest = '';
492
+ let confirmed = '';
493
+ let inFlight = false;
494
+ let stopped = false;
495
+ let timer = null;
496
+ const schedule = () => {
497
+ if (timer || stopped)
401
498
  return;
402
- }
403
- if (event.event === 'interjection-delivered') {
404
- onInterjectionDelivered?.(event.data);
499
+ timer = setTimeout(flush, TURN_EVENT_ACK_INTERVAL_MS);
500
+ timer.unref?.();
501
+ };
502
+ const flush = () => {
503
+ if (timer)
504
+ clearTimeout(timer);
505
+ timer = null;
506
+ if (stopped || inFlight || !latest || latest === confirmed)
405
507
  return;
508
+ const eventId = latest;
509
+ inFlight = true;
510
+ send(eventId)
511
+ .then(() => {
512
+ confirmed = eventId;
513
+ }, () => { })
514
+ .finally(() => {
515
+ inFlight = false;
516
+ if (latest !== confirmed)
517
+ schedule();
518
+ });
519
+ };
520
+ return {
521
+ note(eventId) {
522
+ latest = eventId;
523
+ schedule();
524
+ },
525
+ flush,
526
+ stop() {
527
+ stopped = true;
528
+ if (timer)
529
+ clearTimeout(timer);
530
+ timer = null;
531
+ },
532
+ };
533
+ }
534
+ async function runTurnStream({ openStream, config, session, input, request, recovery, signal, traceId, acknowledge, stopLocalWork, interjections, onTurnStart, onInterjectionDelivered, }) {
535
+ const seen = new Set();
536
+ const work = createTurnWork();
537
+ const deliveries = new AbortController();
538
+ const delivery = AbortSignal.any([signal, deliveries.signal]);
539
+ const streamToken = {};
540
+ let lastEventId = '';
541
+ let registeredTurnId = '';
542
+ function handleEvent(event) {
543
+ if (!event.id) {
544
+ throw new Error('The server sent a turn event without an id.');
406
545
  }
407
- if (event.event === 'status') {
408
- const data = event.data;
409
- if (data?.phase === 'analyzing_image') {
410
- session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
411
- session.onImageGeneration?.(false);
546
+ if (seen.has(event.id))
547
+ return null;
548
+ seen.add(event.id);
549
+ lastEventId = event.id;
550
+ const data = event.data;
551
+ switch (event.event) {
552
+ case 'turn-start': {
553
+ const turnId = String(data?.turnId ?? '').trim();
554
+ if (!turnId)
555
+ throw new Error('The server sent a turn without an id.');
556
+ registeredTurnId = turnId;
557
+ activeTurnRequesters.set(turnId, request);
558
+ turnInterjections.set(turnId, interjections);
559
+ onTurnStart?.(turnId);
560
+ break;
412
561
  }
413
- else if (data?.phase === 'generating_image') {
414
- session.onImageGeneration?.(true);
415
- session.onImageAnalysis?.(0);
562
+ case 'interjection-delivered': {
563
+ const delivered = data;
564
+ for (const messageId of delivered?.messageIds ?? []) {
565
+ const sent = interjections.find((item) => item.messageId === messageId);
566
+ if (sent && !sent.delivered)
567
+ preserveTurnInterjection(session, input, sent);
568
+ }
569
+ onInterjectionDelivered?.(delivered);
570
+ break;
416
571
  }
417
- else if (data?.phase) {
418
- session.onImageAnalysis?.(0);
419
- session.onImageGeneration?.(false);
572
+ case 'status': {
573
+ const status = (data ?? {});
574
+ if (status.phase === 'analyzing_image') {
575
+ session.onImageAnalysis?.(Math.max(1, Number(status.imageCount ?? 1) || 1));
576
+ session.onImageGeneration?.(false);
577
+ }
578
+ else if (status.phase === 'generating_image') {
579
+ session.onImageGeneration?.(true);
580
+ session.onImageAnalysis?.(0);
581
+ }
582
+ else if (status.phase) {
583
+ session.onImageAnalysis?.(0);
584
+ session.onImageGeneration?.(false);
585
+ }
586
+ const message = publicStatusMessage(data);
587
+ if (message)
588
+ session.onStatus(message);
589
+ break;
420
590
  }
421
- const message = publicStatusMessage(event.data);
422
- if (message)
423
- session.onStatus(message);
424
- return;
425
- }
426
- if (event.event === 'context') {
427
- return;
428
- }
429
- if (event.event === 'tool-call') {
430
- const data = event.data;
431
- if (data?.parallelSafe === true) {
432
- pendingParallelTools.push(executeAndPostToolResult({
591
+ case 'tool-call': {
592
+ const toolCall = data;
593
+ if (!toolCall?.executionId || !toolCall.call?.name) {
594
+ throw new Error('Server emitted an invalid tool-call event.');
595
+ }
596
+ work.dispatch(`tool:${toolCall.executionId}`, toolCall.parallelSafe === true, (executed) => executeAndPostToolResult({
433
597
  config,
434
598
  session,
435
- event: data,
599
+ event: toolCall,
436
600
  input,
437
- fetchImpl,
601
+ request,
438
602
  signal,
603
+ delivery,
604
+ executed,
439
605
  traceId,
440
- }).then(() => null, (error) => recordParallelFailure(error)));
441
- return;
442
- }
443
- await drainParallelTools();
444
- await executeAndPostToolResult({
445
- config,
446
- session,
447
- event: data,
448
- input,
449
- fetchImpl,
450
- signal,
451
- traceId,
452
- });
453
- return;
454
- }
455
- if (event.event === 'tool-result') {
456
- const data = event.data;
457
- if (data?.call?.name) {
458
- session.onToolEvent?.({ call: data.call, result: data.result });
459
- }
460
- return;
461
- }
462
- if (event.event === 'user-input-request') {
463
- await drainParallelTools();
464
- const data = event.data;
465
- const turnId = String(data?.turnId ?? '').trim();
466
- const requestId = String(data?.requestId ?? '').trim();
467
- if (!turnId ||
468
- !requestId ||
469
- !isUserInputQuestionArray(data?.questions)) {
470
- throw new Error('Server emitted an invalid user-input request.');
606
+ }));
607
+ break;
471
608
  }
472
- if (!session.requestUserInput) {
473
- throw new Error('Interactive user input is unavailable in this client.');
609
+ case 'tool-result': {
610
+ const toolResult = data;
611
+ if (toolResult?.call?.name) {
612
+ session.onToolEvent?.({ call: toolResult.call, result: toolResult.result });
613
+ }
614
+ break;
474
615
  }
475
- const result = await session.requestUserInput({ questions: data.questions }, signal);
476
- if (signal?.aborted) {
477
- throw new TurnCancelledError();
616
+ case 'user-input-request': {
617
+ const question = data;
618
+ const turnId = String(question?.turnId ?? '').trim();
619
+ const requestId = String(question?.requestId ?? '').trim();
620
+ if (!turnId || !requestId || !isUserInputQuestionArray(question?.questions)) {
621
+ throw new Error('Server emitted an invalid user-input request.');
622
+ }
623
+ work.dispatch(`question:${requestId}`, false, async (executed) => {
624
+ if (!session.requestUserInput) {
625
+ throw new Error('Interactive user input is unavailable in this client.');
626
+ }
627
+ const result = await session.requestUserInput({ questions: question.questions }, signal);
628
+ executed();
629
+ signal.throwIfAborted();
630
+ preserveTurnAnswer(session, input, requestId, question.questions, result);
631
+ if (delivery.aborted)
632
+ return;
633
+ await postUserInputResult({
634
+ config,
635
+ turnId,
636
+ requestId,
637
+ result,
638
+ request,
639
+ traceId,
640
+ delivery,
641
+ });
642
+ });
643
+ break;
478
644
  }
479
- await postUserInputResult({
480
- config,
481
- turnId,
482
- requestId,
483
- result,
484
- fetchImpl,
485
- traceId,
486
- });
487
- return;
488
- }
489
- if (event.event === 'result') {
490
- await drainParallelTools();
491
- finalResult.current = event.data;
492
- return;
645
+ case 'result':
646
+ if (!data?.snapshot)
647
+ throw new Error('Server returned an invalid chat result.');
648
+ return { kind: 'result', result: data };
649
+ case 'error':
650
+ case 'cancelled':
651
+ return { kind: 'failed', error: terminalFailure(event, traceId) };
652
+ default:
653
+ break;
493
654
  }
494
- if (event.event === 'cancelled' || event.event === 'error') {
495
- await drainParallelTools().catch(() => { });
496
- const message = String(event.data?.message ?? 'Server chat failed.');
497
- if (event.event === 'cancelled') {
498
- throw new TurnCancelledError(message);
655
+ acknowledge.note(event.id);
656
+ return null;
657
+ }
658
+ async function readUntilOutcome(response) {
659
+ if (!response.body)
660
+ throw interruptedStream('The server returned an empty turn stream.');
661
+ const reader = response.body.getReader();
662
+ const decoder = new TextDecoder();
663
+ let buffer = '';
664
+ try {
665
+ for (;;) {
666
+ const read = await Promise.race([readTurnChunk(reader, signal), work.failed]);
667
+ if (read.done)
668
+ throw interruptedStream();
669
+ buffer += decoder.decode(read.value, { stream: true });
670
+ for (let match = /\r?\n\r?\n/.exec(buffer); match; match = /\r?\n\r?\n/.exec(buffer)) {
671
+ const block = buffer.slice(0, match.index);
672
+ buffer = buffer.slice(match.index + match[0].length);
673
+ signal.throwIfAborted();
674
+ const event = parseSseBlock(block);
675
+ if (!event)
676
+ continue;
677
+ recovery.restored(streamToken);
678
+ const outcome = handleEvent(event);
679
+ if (outcome)
680
+ return outcome;
681
+ }
682
+ if (buffer.length > MAX_TURN_EVENT_BYTES) {
683
+ throw new Error('The server sent a turn event larger than the protocol allows.');
684
+ }
499
685
  }
500
- throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId, event.data?.snapshot);
686
+ }
687
+ finally {
688
+ await reader.cancel().catch(() => { });
501
689
  }
502
690
  }
503
691
  try {
504
- while (true) {
505
- if (signal?.aborted) {
506
- await reader.cancel().catch(() => { });
507
- throw new TurnCancelledError();
692
+ for (;;) {
693
+ let outcome;
694
+ try {
695
+ signal.throwIfAborted();
696
+ const response = await openStream(lastEventId);
697
+ recovery.reconnected(streamToken);
698
+ outcome = await readUntilOutcome(response);
508
699
  }
509
- const read = await Promise.race([reader.read(), parallelToolFailure]);
510
- if (read.done)
511
- break;
512
- buffer += decoder.decode(read.value, { stream: true });
513
- let separatorIndex = buffer.indexOf('\n\n');
514
- while (separatorIndex !== -1) {
515
- const block = buffer.slice(0, separatorIndex);
516
- buffer = buffer.slice(separatorIndex + 2);
517
- const event = parseSseBlock(block);
518
- if (event)
519
- await handleEvent(event);
520
- separatorIndex = buffer.indexOf('\n\n');
700
+ catch (error) {
701
+ signal.throwIfAborted();
702
+ if (work.hasFailed)
703
+ throw error;
704
+ await recovery.retryAfter(streamToken, lastEventId ? 'reading the turn stream' : 'opening the turn', error);
705
+ continue;
706
+ }
707
+ acknowledge.note(lastEventId);
708
+ acknowledge.flush();
709
+ if (outcome.kind === 'result') {
710
+ deliveries.abort(new Error('The turn already finished.'));
711
+ await work.executed();
712
+ signal.throwIfAborted();
521
713
  }
714
+ else {
715
+ stopLocalWork();
716
+ await work.settled();
717
+ }
718
+ return outcome;
522
719
  }
523
- await drainParallelTools();
524
720
  }
525
721
  catch (error) {
526
- await reader.cancel().catch(() => { });
527
- throw error;
722
+ const outcome = signal.aborted ? signal.reason : error;
723
+ stopLocalWork();
724
+ await work.settled();
725
+ throw outcome;
528
726
  }
529
727
  finally {
530
- await drainParallelTools().catch(() => { });
531
- }
532
- buffer += decoder.decode();
533
- const tail = buffer.trim();
534
- if (tail) {
535
- const event = parseSseBlock(tail);
536
- if (event)
537
- await handleEvent(event);
538
- }
539
- if (!finalResult.current?.snapshot) {
540
- throw new Error('Server returned an invalid chat result.');
728
+ if (registeredTurnId) {
729
+ activeTurnRequesters.delete(registeredTurnId);
730
+ turnInterjections.delete(registeredTurnId);
731
+ }
541
732
  }
542
- return finalResult.current;
543
733
  }
544
- export async function sendServerUserMessage({ config, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, onTurnStart, onInterjectionDelivered, }) {
734
+ export async function sendServerUserMessage({ config, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, onTurnStart, onInterjectionDelivered, recoverySleep, }) {
545
735
  const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
546
736
  const requestImageAttachments = autoAttach.attachments.length > 0
547
737
  ? [...imageAttachments, ...autoAttach.attachments]
@@ -554,6 +744,7 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
554
744
  session.onStatus(`Image: ${err}`);
555
745
  }
556
746
  const request = {
747
+ clientTurnId: randomUUID(),
557
748
  modelId: session.modelId,
558
749
  session: snapshotForServer(session),
559
750
  input: requestInputBase,
@@ -569,46 +760,83 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
569
760
  agentMode: session.agentMode,
570
761
  };
571
762
  const trace = createTraceContext();
763
+ let completed = false;
764
+ let turnEnded = false;
765
+ const reportRecovery = (status) => {
766
+ if (!turnEnded)
767
+ session.onRecovery?.(status);
768
+ };
769
+ const startAttempt = (turnRequest, onStart) => startTurnAttempt({
770
+ config,
771
+ session,
772
+ turnRequest,
773
+ input: requestInputBase,
774
+ trace,
775
+ fetchImpl,
776
+ recoverySleep,
777
+ reportRecovery,
778
+ onTurnStart: onStart,
779
+ onInterjectionDelivered,
780
+ });
781
+ let attempt = startAttempt(request, onTurnStart);
782
+ const cancelTurn = () => attempt.cancel();
572
783
  const preTurnHistoryLength = session.history.length;
573
784
  const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInputBase);
574
785
  if (signal?.aborted) {
575
786
  preserveOnAbort();
787
+ cancelTurn();
576
788
  }
577
789
  else {
578
790
  signal?.addEventListener('abort', preserveOnAbort, { once: true });
791
+ signal?.addEventListener('abort', cancelTurn, { once: true });
579
792
  }
580
793
  try {
581
- const response = await tagTurnPhase('opening the turn', () => fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn`, {
582
- method: 'POST',
583
- headers: {
584
- accept: 'text/event-stream',
585
- authorization: `Bearer ${config.token}`,
586
- 'content-type': 'application/json',
587
- ...trace.headers,
588
- },
589
- body: JSON.stringify(request),
590
- signal,
591
- }));
592
- if (!response.ok) {
593
- throw await readErrorResponse(response, trace.traceId);
794
+ let outcome;
795
+ try {
796
+ outcome = await attempt.run();
594
797
  }
595
- const result = await tagTurnPhase('reading the turn stream', () => consumeTurnStream({
596
- response,
597
- config,
598
- session,
599
- input: requestInputBase,
600
- fetchImpl,
601
- signal,
602
- traceId: trace.traceId,
603
- onTurnStart,
604
- onInterjectionDelivered,
605
- }));
606
- applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
798
+ catch (error) {
799
+ const sent = attempt.interjections;
800
+ if (!isTurnLostByServer(error) || sent.some((item) => item.hasImages))
801
+ throw error;
802
+ attempt.close(false);
803
+ if (signal?.aborted)
804
+ throw new TurnCancelledError();
805
+ const carried = sent.filter((item) => !item.delivered);
806
+ for (const item of carried)
807
+ preserveTurnInterjection(session, requestInputBase, item);
808
+ reportRecovery({ phase: 'continuing' });
809
+ attempt = startAttempt({
810
+ ...request,
811
+ clientTurnId: randomUUID(),
812
+ session: snapshotForServer(session),
813
+ continuation: {
814
+ clientTurnId: request.clientTurnId,
815
+ historyIndex: preTurnHistoryLength,
816
+ },
817
+ }, (turnId) => {
818
+ reportRecovery({ phase: 'continued' });
819
+ onTurnStart?.(turnId);
820
+ if (carried.length > 0) {
821
+ onInterjectionDelivered?.({
822
+ turnId,
823
+ step: 0,
824
+ deliveredAt: 'tool_round_boundary',
825
+ messageIds: carried.map((item) => item.messageId),
826
+ });
827
+ }
828
+ });
829
+ outcome = await attempt.run();
830
+ }
831
+ if (outcome.kind === 'failed')
832
+ throw outcome.error;
833
+ applySessionSnapshot(session, outcome.result.snapshot, { preserveAgentMode: true });
834
+ completed = true;
607
835
  return {
608
- text: result.text,
609
- waitingForApproval: result.waitingForApproval,
610
- toolBudgetReached: result.toolBudgetReached,
611
- usageSummary: result.usageSummary,
836
+ text: outcome.result.text,
837
+ waitingForApproval: outcome.result.waitingForApproval,
838
+ toolBudgetReached: outcome.result.toolBudgetReached,
839
+ usageSummary: outcome.result.usageSummary,
612
840
  };
613
841
  }
614
842
  catch (error) {
@@ -618,23 +846,32 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
618
846
  ? error
619
847
  : new TurnCancelledError();
620
848
  }
621
- const transportDetail = error instanceof ChatTurnFailedError ? null : transportFailureDetail(error);
849
+ const exhausted = isTurnRecoveryExhausted(error);
850
+ const carriesTrace = error instanceof ChatTurnFailedError || error instanceof ServerApiError;
851
+ const transportDetail = carriesTrace || exhausted ? null : transportFailureDetail(error);
622
852
  if (transportDetail && error instanceof Error) {
623
853
  const phase = turnPhaseOf(error) ?? 'reaching the server';
624
854
  error.message = `connection lost while ${phase}: ${transportDetail} [trace ${trace.traceId}]`;
625
855
  }
856
+ else if (!carriesTrace && error instanceof Error) {
857
+ error.message = `${error.message} [trace ${trace.traceId}]`;
858
+ }
626
859
  const category = error instanceof ChatTurnFailedError
627
860
  ? error.category
628
- :
629
- gatewayFailureCategory(error) ?? 'unknown_error';
861
+ : exhausted
862
+ ? 'connection_error'
863
+ :
864
+ (gatewayFailureCategory(error) ?? 'unknown_error');
630
865
  const partial = error instanceof ChatTurnFailedError ? error.partialSnapshot : undefined;
631
866
  if (partial) {
632
867
  applySessionSnapshot(session, partial, { preserveAgentMode: true });
633
868
  }
634
- else {
635
- session.history.length = preTurnHistoryLength;
869
+ else if (session.history.length === preTurnHistoryLength) {
636
870
  preserveFailedTurnInput(session, requestInputBase, category);
637
871
  }
872
+ else {
873
+ appendTurnFailureMarker(session, category);
874
+ }
638
875
  try {
639
876
  saveSessionState(session, session.env);
640
877
  }
@@ -642,7 +879,120 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
642
879
  throw error;
643
880
  }
644
881
  finally {
882
+ turnEnded = true;
645
883
  signal?.removeEventListener('abort', preserveOnAbort);
884
+ signal?.removeEventListener('abort', cancelTurn);
885
+ attempt.close(completed);
646
886
  releaseBrowserAfterTurn();
647
887
  }
648
888
  }
889
+ function isTurnLostByServer(error) {
890
+ return error instanceof ServerApiError && error.code === 'RECOVERY_UNAVAILABLE';
891
+ }
892
+ function startTurnAttempt({ config, session, turnRequest, input, trace, fetchImpl, recoverySleep, reportRecovery, onTurnStart, onInterjectionDelivered, }) {
893
+ const requestBody = JSON.stringify(turnRequest);
894
+ const interjections = [];
895
+ const turnController = new AbortController();
896
+ const turnSignal = turnController.signal;
897
+ session.turnAbortSignal = turnSignal;
898
+ let serverSettled = false;
899
+ let cancellationSent = false;
900
+ const sendCancellation = () => {
901
+ if (cancellationSent)
902
+ return;
903
+ cancellationSent = true;
904
+ const cancelController = new AbortController();
905
+ void fetchTurnAttempt(fetchImpl, turnUrl(config, turnRequest.clientTurnId, 'cancel'), {
906
+ method: 'POST',
907
+ headers: {
908
+ authorization: `Bearer ${config.token}`,
909
+ 'content-type': 'application/json',
910
+ ...trace.headers,
911
+ },
912
+ body: JSON.stringify({ sessionId: turnRequest.session.id }),
913
+ }, cancelController.signal, { readBody: true }).catch(() => { });
914
+ };
915
+ const stopLocalWork = () => {
916
+ turnController.abort(new TurnCancelledError());
917
+ cancelActiveCommand();
918
+ };
919
+ const recovery = createTurnRecovery({
920
+ signal: turnSignal,
921
+ sleep: recoverySleep,
922
+ onStatus: reportRecovery,
923
+ });
924
+ const requester = createTurnRequester({
925
+ fetchImpl,
926
+ recovery,
927
+ signal: turnSignal,
928
+ traceId: trace.traceId,
929
+ });
930
+ const acknowledge = createEventAcknowledger(async (eventId) => {
931
+ const response = await fetchTurnAttempt(fetchImpl, turnUrl(config, turnRequest.clientTurnId, 'ack'), {
932
+ method: 'POST',
933
+ headers: {
934
+ authorization: `Bearer ${config.token}`,
935
+ 'content-type': 'application/json',
936
+ ...trace.headers,
937
+ },
938
+ body: JSON.stringify({ eventId }),
939
+ }, new AbortController().signal, { readBody: true });
940
+ if (!response.ok)
941
+ throw await readErrorResponse(response, trace.traceId);
942
+ });
943
+ const openStream = async (lastEventId) => {
944
+ const response = await fetchTurnAttempt(fetchImpl, `${normalizeServerUrl(config.serverUrl)}/v1/chat/turn`, {
945
+ method: 'POST',
946
+ headers: {
947
+ accept: 'text/event-stream',
948
+ authorization: `Bearer ${config.token}`,
949
+ 'content-type': 'application/json',
950
+ ...trace.headers,
951
+ ...(lastEventId ? { 'last-event-id': lastEventId } : {}),
952
+ },
953
+ body: requestBody,
954
+ }, turnSignal);
955
+ if (!response.ok) {
956
+ throw await readErrorResponse(response, trace.traceId);
957
+ }
958
+ return response;
959
+ };
960
+ return {
961
+ interjections,
962
+ cancel() {
963
+ stopLocalWork();
964
+ sendCancellation();
965
+ },
966
+ async run() {
967
+ const outcome = await runTurnStream({
968
+ openStream,
969
+ config,
970
+ session,
971
+ input,
972
+ request: requester,
973
+ recovery,
974
+ signal: turnSignal,
975
+ traceId: trace.traceId,
976
+ acknowledge,
977
+ stopLocalWork,
978
+ interjections,
979
+ onTurnStart,
980
+ onInterjectionDelivered,
981
+ });
982
+ serverSettled = true;
983
+ return outcome;
984
+ },
985
+ close(completed) {
986
+ acknowledge.stop();
987
+ if (!serverSettled)
988
+ sendCancellation();
989
+ if (!completed)
990
+ turnController.abort(new TurnCancelledError());
991
+ if (session.turnAbortSignal === turnSignal)
992
+ session.turnAbortSignal = undefined;
993
+ },
994
+ };
995
+ }
996
+ export function isTurnOutcomeKnown(error) {
997
+ return error instanceof ChatTurnFailedError;
998
+ }