praxis-agent 0.23.0 → 0.24.0

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.
@@ -15,6 +15,7 @@ import { redactSensitiveText, sensitiveEnvironmentValues, } from '../platform/se
15
15
  import { CommandPalette, BtwPanel, Composer, DiffDashboard, DialogFrame, ExternalEditorWait, HelpMenu, HookDashboard, ListDashboard, MemoryDashboard, MentionPicker, ModelMenu, PermissionDashboard, SelectionMenu, SessionPicker, ThemePicker, CustomThemeEditor, SessionIdentity, Transcript, WelcomePanel, useTerminalRows, useTerminalWidth, } from './tui/claude-style.js';
16
16
  import { loadTuiMemoryFiles, openTuiMemoryFolder, } from './tui/memory-files.js';
17
17
  import { loadClaudeReleaseNotes } from './tui/release-notes.js';
18
+ import { fullscreenInkRenderOptions } from './tui/fullscreen-renderer.js';
18
19
  import { StreamingFrameBuffer } from './tui/streaming-frame-buffer.js';
19
20
  import { createClaudeStatusLineInput, StatusLine } from './tui/status-line.js';
20
21
  import { FULLSCREEN_TRANSCRIPT_RESERVED_ROWS, transcriptLineCount, } from './tui/transcript-viewport.js';
@@ -1293,6 +1294,8 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1293
1294
  text: `API retry ${event.attempt}/${event.maxRetries} · ${event.error}`,
1294
1295
  });
1295
1296
  break;
1297
+ case 'terminal':
1298
+ break;
1296
1299
  case 'elicitation-complete':
1297
1300
  append({
1298
1301
  kind: 'notice',
@@ -2692,6 +2695,14 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
2692
2695
  }
2693
2696
  setUsage(result.usage);
2694
2697
  setCostUsd(result.costUsd);
2698
+ // The active stream and its committed final item must never render in the
2699
+ // same frame. Publish the cleared stream before appending the final
2700
+ // assistant entry; there is no await between these operations, and the
2701
+ // renderer's frame buffer prevents the identical text from being shown
2702
+ // twice during the transition.
2703
+ streamingFrameRef.current?.resetText();
2704
+ streamingFrameRef.current?.resetThinking();
2705
+ streamingFrameRef.current?.flush();
2695
2706
  append({ kind: 'assistant', text: result.text });
2696
2707
  if (turnMutatedFilesRef.current) {
2697
2708
  try {
@@ -6070,9 +6081,8 @@ export async function runInteractive(options) {
6070
6081
  ? { allowDangerouslySkipPermissions: true }
6071
6082
  : {}) }), {
6072
6083
  exitOnCtrlC: false,
6073
- incrementalRendering: !options.axScreenReader,
6084
+ ...fullscreenInkRenderOptions(currentRenderer, options.axScreenReader),
6074
6085
  interactive: true,
6075
- alternateScreen: currentRenderer === 'fullscreen',
6076
6086
  });
6077
6087
  await instance.waitUntilExit();
6078
6088
  if (activeTurn)
@@ -247,6 +247,7 @@ export declare class StreamJsonOutput {
247
247
  private turnThinking;
248
248
  private turnCalls;
249
249
  private turnUsage;
250
+ private turnTerminalReason;
250
251
  private turnActive;
251
252
  private assistantFlushed;
252
253
  private contentStarted;
@@ -1666,6 +1666,7 @@ export class StreamJsonOutput {
1666
1666
  turnThinking = [];
1667
1667
  turnCalls = [];
1668
1668
  turnUsage = emptyUsage();
1669
+ turnTerminalReason;
1669
1670
  turnActive = false;
1670
1671
  assistantFlushed = true;
1671
1672
  contentStarted = false;
@@ -1884,6 +1885,11 @@ export class StreamJsonOutput {
1884
1885
  this.turnUsage = event.usage;
1885
1886
  return;
1886
1887
  }
1888
+ if (event.type === 'terminal') {
1889
+ this.ensureTurn();
1890
+ this.turnTerminalReason = event.reason;
1891
+ return;
1892
+ }
1887
1893
  if (event.type === 'permission-decision') {
1888
1894
  this.flushAssistant();
1889
1895
  return;
@@ -2111,6 +2117,7 @@ export class StreamJsonOutput {
2111
2117
  this.turnThinking = [];
2112
2118
  this.turnCalls = [];
2113
2119
  this.turnUsage = emptyUsage();
2120
+ this.turnTerminalReason = undefined;
2114
2121
  this.turnActive = true;
2115
2122
  this.assistantFlushed = false;
2116
2123
  this.contentStarted = false;
@@ -2224,7 +2231,10 @@ export class StreamJsonOutput {
2224
2231
  event: {
2225
2232
  type: 'message_delta',
2226
2233
  delta: {
2227
- stop_reason: this.turnCalls.length > 0 ? 'tool_use' : 'end_turn',
2234
+ stop_reason: this.turnTerminalReason === 'prompt_too_long'
2235
+ ? 'model_context_window_exceeded'
2236
+ : (this.turnTerminalReason ??
2237
+ (this.turnCalls.length > 0 ? 'tool_use' : 'end_turn')),
2228
2238
  stop_sequence: null,
2229
2239
  },
2230
2240
  usage: { output_tokens: this.turnUsage.outputTokens },
@@ -0,0 +1,14 @@
1
+ import type { TuiRendererMode } from './tui-view-model.js';
2
+ /** Stable terminal-frame policy for the fullscreen renderer. */
3
+ export interface FullscreenInkRenderOptions {
4
+ incrementalRendering: boolean;
5
+ alternateScreen: boolean;
6
+ }
7
+ /**
8
+ * The Ink render options for an interactive session. Fullscreen uses a stable
9
+ * terminal-frame policy: incremental rendering is disabled (every frame is a
10
+ * full redraw that erases the previous viewport) while classic and screen-reader
11
+ * modes keep their current behavior.
12
+ */
13
+ export declare function fullscreenInkRenderOptions(currentRenderer: TuiRendererMode, axScreenReader: boolean | undefined): FullscreenInkRenderOptions;
14
+ //# sourceMappingURL=fullscreen-renderer.d.ts.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The Ink render options for an interactive session. Fullscreen uses a stable
3
+ * terminal-frame policy: incremental rendering is disabled (every frame is a
4
+ * full redraw that erases the previous viewport) while classic and screen-reader
5
+ * modes keep their current behavior.
6
+ */
7
+ export function fullscreenInkRenderOptions(currentRenderer, axScreenReader) {
8
+ return {
9
+ incrementalRendering: currentRenderer !== 'fullscreen' && !axScreenReader,
10
+ alternateScreen: currentRenderer === 'fullscreen',
11
+ };
12
+ }
13
+ //# sourceMappingURL=fullscreen-renderer.js.map
@@ -78,6 +78,7 @@ export interface ModelWebSearch {
78
78
  blockedDomains?: readonly string[];
79
79
  maxUses: number;
80
80
  }
81
+ export type ModelTerminalReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'prompt_too_long';
81
82
  export type ModelStreamEvent = {
82
83
  type: 'text-delta';
83
84
  delta: string;
@@ -102,6 +103,9 @@ export type ModelStreamEvent = {
102
103
  } | {
103
104
  type: 'usage';
104
105
  usage: ModelUsage;
106
+ } | {
107
+ type: 'terminal';
108
+ reason: ModelTerminalReason;
105
109
  } | {
106
110
  type: 'api-retry';
107
111
  attempt: number;
@@ -135,6 +139,8 @@ export interface ModelProviderCapabilities {
135
139
  };
136
140
  contextWindowTokens?: number;
137
141
  maxOutputTokens?: number;
142
+ /** The provider emits exactly one terminal event as its final stream event. */
143
+ terminalReasons?: boolean;
138
144
  }
139
145
  export interface ModelProvider {
140
146
  readonly capabilities: ModelProviderCapabilities;
@@ -170,6 +176,9 @@ export type RuntimeEvent = {
170
176
  } | {
171
177
  type: 'usage';
172
178
  usage: ModelUsage;
179
+ } | {
180
+ type: 'terminal';
181
+ reason: ModelTerminalReason;
173
182
  } | {
174
183
  type: 'tool-call';
175
184
  call: ModelToolCall;
@@ -480,8 +489,10 @@ export declare class ModelProviderError extends Error {
480
489
  readonly retryable: boolean;
481
490
  readonly status?: number;
482
491
  readonly retryDelayMs?: number;
492
+ readonly kind?: ProviderErrorKind;
483
493
  constructor(message: string, options: {
484
494
  retryable: boolean;
495
+ kind?: ProviderErrorKind;
485
496
  status?: number;
486
497
  retryDelayMs?: number;
487
498
  cause?: unknown;
@@ -492,7 +503,7 @@ export declare class AgentRunCancelledError extends Error {
492
503
  constructor();
493
504
  }
494
505
  export type RuntimeEventSink = (event: RuntimeEvent) => void;
495
- export type ProviderErrorKind = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'unknown' | 'max_output_tokens';
506
+ export type ProviderErrorKind = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'timeout' | 'overloaded' | 'api_error' | 'prompt_too_long' | 'transport_error' | 'cancelled' | 'unknown' | 'max_output_tokens';
496
507
  export declare function modelProviderErrorKind(error: ModelProviderError): ProviderErrorKind;
497
508
  export declare class AgentRuntime {
498
509
  private readonly provider;
@@ -20,9 +20,12 @@ export class ModelProviderError extends Error {
20
20
  retryable;
21
21
  status;
22
22
  retryDelayMs;
23
+ kind;
23
24
  constructor(message, options) {
24
25
  super(message, options.cause === undefined ? undefined : { cause: options.cause });
25
26
  this.retryable = options.retryable;
27
+ if (options.kind !== undefined)
28
+ this.kind = options.kind;
26
29
  if (options.status !== undefined)
27
30
  this.status = options.status;
28
31
  if (options.retryDelayMs !== undefined)
@@ -37,6 +40,8 @@ export class AgentRunCancelledError extends Error {
37
40
  }
38
41
  const emptyUsage = () => ({ inputTokens: 0, outputTokens: 0 });
39
42
  export function modelProviderErrorKind(error) {
43
+ if (error.kind !== undefined)
44
+ return error.kind;
40
45
  if (error.status === 401 || error.status === 403)
41
46
  return 'authentication_failed';
42
47
  if (error.status === 402)
@@ -341,6 +346,7 @@ export class AgentRuntime {
341
346
  let turnUsage = emptyUsage();
342
347
  let streaming = false;
343
348
  const toolCalls = [];
349
+ let terminalReason;
344
350
  const apiStartedAt = request.collectMetrics ? performance.now() : 0;
345
351
  let turnApiDurationMs = 0;
346
352
  let turnApiDurationWithoutRetriesMs;
@@ -349,6 +355,9 @@ export class AgentRuntime {
349
355
  for await (const event of this.provider.complete(providerRequest)) {
350
356
  if (request.signal?.aborted)
351
357
  return this.cancel();
358
+ if (terminalReason !== undefined) {
359
+ throw new ModelProviderError(`Provider emitted ${event.type} after terminal reason ${terminalReason}`, { retryable: false });
360
+ }
352
361
  if (event.type === 'api-retry') {
353
362
  this.emit(event);
354
363
  continue;
@@ -414,6 +423,10 @@ export class AgentRuntime {
414
423
  toolCalls.push(event.call);
415
424
  this.emit(event);
416
425
  }
426
+ else if (event.type === 'terminal') {
427
+ terminalReason = event.reason;
428
+ this.emit(event);
429
+ }
417
430
  else {
418
431
  turnUsage = event.usage;
419
432
  this.emit(event);
@@ -427,6 +440,18 @@ export class AgentRuntime {
427
440
  unrecordedDurationApiMs = addApiDurationMetric(turnApiDurationMs, unrecordedDurationApiMs, 'durationApiMs');
428
441
  }
429
442
  }
443
+ if (this.provider.capabilities.terminalReasons === true &&
444
+ terminalReason === undefined) {
445
+ throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
446
+ }
447
+ if (terminalReason === 'tool_use' && toolCalls.length === 0) {
448
+ throw new ModelProviderError('Provider reported tool_use without a completed tool call', { retryable: false });
449
+ }
450
+ if (terminalReason !== undefined &&
451
+ terminalReason !== 'tool_use' &&
452
+ toolCalls.length > 0) {
453
+ throw new ModelProviderError(`Provider reported ${terminalReason} with completed tool calls`, { retryable: false });
454
+ }
430
455
  if (request.collectMetrics) {
431
456
  const turnApiDurationWithoutRetriesMsResolved = turnApiDurationWithoutRetriesMs ?? turnApiDurationMs;
432
457
  durationApiWithoutRetriesMs = addApiDurationMetric(turnApiDurationWithoutRetriesMsResolved, durationApiWithoutRetriesMs, 'durationApiWithoutRetriesMs');
@@ -1,4 +1,5 @@
1
1
  import { ModelProviderError, } from '../core/runtime.js';
2
+ import { transportFailureKind } from './provider-errors.js';
2
3
  function isRecord(value) {
3
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
4
5
  }
@@ -61,6 +62,52 @@ function readErrorMessage(value, status) {
61
62
  }
62
63
  return `Provider request failed with HTTP ${status}`;
63
64
  }
65
+ function isPromptTooLongError(type, message) {
66
+ return (['prompt_too_long', 'context_length_exceeded'].includes(type) ||
67
+ /prompt\s+(?:is\s+)?too long|context.{0,80}(?:exceed|too long)|maximum context length/iu.test(message));
68
+ }
69
+ function anthropicErrorKind(value, status) {
70
+ const error = isRecord(value) && isRecord(value.error) ? value.error : value;
71
+ const type = isRecord(error) && typeof error.type === 'string' ? error.type : '';
72
+ const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
73
+ if (isPromptTooLongError(type, message))
74
+ return 'prompt_too_long';
75
+ if (type === 'authentication_error' || status === 401 || status === 403)
76
+ return 'authentication_failed';
77
+ if (type === 'billing_error' || status === 402)
78
+ return 'billing_error';
79
+ if (type === 'rate_limit_error' || status === 429)
80
+ return 'rate_limit';
81
+ if (type === 'overloaded_error' || status === 529)
82
+ return 'overloaded';
83
+ if (type === 'api_error')
84
+ return 'api_error';
85
+ if (status === 408)
86
+ return 'timeout';
87
+ if (type === 'invalid_request_error')
88
+ return 'invalid_request';
89
+ if (status !== undefined && status >= 400 && status < 500)
90
+ return 'invalid_request';
91
+ if (status !== undefined && status >= 500)
92
+ return 'server_error';
93
+ return 'unknown';
94
+ }
95
+ function anthropicStopReason(value) {
96
+ if (value === 'end_turn' || value === 'stop_sequence' || value === 'refusal')
97
+ return 'end_turn';
98
+ if (value === 'tool_use')
99
+ return 'tool_use';
100
+ if (value === 'max_tokens')
101
+ return 'max_tokens';
102
+ if (value === 'model_context_window_exceeded')
103
+ return 'prompt_too_long';
104
+ if (typeof value !== 'string' || value.length === 0) {
105
+ throw new ModelProviderError('Provider message delta is missing stop reason', {
106
+ retryable: false,
107
+ });
108
+ }
109
+ throw new ModelProviderError(`Provider returned unsupported stop reason ${value}`, { retryable: false });
110
+ }
64
111
  function completedToolCall(state, index) {
65
112
  const pending = state.tools.get(index);
66
113
  if (!pending)
@@ -105,12 +152,14 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
105
152
  const message = typeof error.message === 'string'
106
153
  ? error.message
107
154
  : 'Provider stream returned an error';
155
+ const kind = anthropicErrorKind(error);
108
156
  const retryable = [
109
157
  'api_error',
110
- 'overloaded_error',
111
- 'rate_limit_error',
112
- ].includes(String(error.type));
113
- throw new ModelProviderError(message, { retryable });
158
+ 'overloaded',
159
+ 'rate_limit',
160
+ 'timeout',
161
+ ].includes(kind);
162
+ throw new ModelProviderError(message, { kind, retryable });
114
163
  }
115
164
  if (value.type === 'message_start') {
116
165
  if (state.messageStarted || !isRecord(value.message)) {
@@ -344,6 +393,17 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
344
393
  throw new ModelProviderError('Provider returned an invalid message delta', { retryable: false });
345
394
  }
346
395
  state.messageDeltaSeen = true;
396
+ if (value.delta !== undefined && !isRecord(value.delta)) {
397
+ throw new ModelProviderError('Provider returned an invalid message delta', {
398
+ retryable: false,
399
+ });
400
+ }
401
+ if (isRecord(value.delta) && value.delta.stop_reason !== undefined) {
402
+ if (state.terminalReason !== undefined) {
403
+ throw new ModelProviderError('Provider returned multiple terminal stop reasons', { retryable: false });
404
+ }
405
+ state.terminalReason = anthropicStopReason(value.delta.stop_reason);
406
+ }
347
407
  if (typeof value.usage.output_tokens === 'number') {
348
408
  state.outputTokens = value.usage.output_tokens;
349
409
  state.usageSeen = true;
@@ -360,6 +420,11 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
360
420
  if (!state.messageDeltaSeen) {
361
421
  throw new ModelProviderError('Provider stopped before the terminal message delta', { retryable: false });
362
422
  }
423
+ if (state.terminalReason === undefined) {
424
+ throw new ModelProviderError('Provider message delta is missing stop reason', {
425
+ retryable: false,
426
+ });
427
+ }
363
428
  state.terminal = true;
364
429
  const events = [];
365
430
  if (state.usageSeen) {
@@ -381,6 +446,7 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
381
446
  });
382
447
  state.usageSeen = false;
383
448
  }
449
+ events.push({ type: 'terminal', reason: state.terminalReason });
384
450
  return events;
385
451
  }
386
452
  return [];
@@ -532,6 +598,7 @@ export class AnthropicCompatibleProvider {
532
598
  ? {}
533
599
  : { contextWindowTokens: options.contextWindowTokens }),
534
600
  maxOutputTokens: this.maxOutputTokens,
601
+ terminalReasons: true,
535
602
  };
536
603
  this.thinking = validateThinking(options.thinking);
537
604
  this.anthropicVersion = options.anthropicVersion ?? '2023-06-01';
@@ -618,10 +685,14 @@ export class AnthropicCompatibleProvider {
618
685
  response = await this.fetchImplementation(this.endpoint, requestInit);
619
686
  }
620
687
  catch (error) {
621
- if (request.signal?.aborted)
622
- throw error;
623
- throw new ModelProviderError('Provider transport failed', {
624
- retryable: true,
688
+ const kind = transportFailureKind(error, request.signal);
689
+ throw new ModelProviderError(kind === 'cancelled'
690
+ ? 'Provider request cancelled'
691
+ : kind === 'timeout'
692
+ ? 'Provider request timed out'
693
+ : 'Provider transport failed', {
694
+ kind,
695
+ retryable: kind === 'timeout' || kind === 'transport_error',
625
696
  cause: error,
626
697
  });
627
698
  }
@@ -669,12 +740,14 @@ export class AnthropicCompatibleProvider {
669
740
  payload = null;
670
741
  }
671
742
  throw new ModelProviderError(readErrorMessage(payload, response.status), {
743
+ kind: anthropicErrorKind(payload, response.status),
672
744
  retryable: isRetryableStatus(response.status),
673
745
  status: response.status,
674
746
  });
675
747
  }
676
748
  if (!response.body) {
677
749
  throw new ModelProviderError('Provider response has no body', {
750
+ kind: 'transport_error',
678
751
  retryable: true,
679
752
  });
680
753
  }
@@ -734,11 +807,12 @@ export class AnthropicCompatibleProvider {
734
807
  }
735
808
  }
736
809
  catch (error) {
737
- if (error instanceof ModelProviderError || request.signal?.aborted) {
810
+ if (error instanceof ModelProviderError)
738
811
  throw error;
739
- }
812
+ const kind = transportFailureKind(error, request.signal);
740
813
  throw new ModelProviderError('Provider stream failed', {
741
- retryable: true,
814
+ kind,
815
+ retryable: kind === 'timeout' || kind === 'transport_error',
742
816
  cause: error,
743
817
  });
744
818
  }
@@ -35,7 +35,11 @@ export class FallbackModelProvider {
35
35
  // cannot duplicate text/tool events when retried.
36
36
  const events = [];
37
37
  let attemptDurationMs;
38
+ let terminalSeen = false;
38
39
  for await (const event of provider.complete(request)) {
40
+ if (terminalSeen) {
41
+ throw new ModelProviderError(`Provider emitted ${event.type} after its terminal event`, { retryable: false });
42
+ }
39
43
  if (event.type === 'api-attempt-duration') {
40
44
  // Consume the underlying attempt timing rather than replaying it
41
45
  // so nested wrappers report a single retry-free duration.
@@ -51,8 +55,13 @@ export class FallbackModelProvider {
51
55
  attemptDurationMs = durationMs;
52
56
  continue;
53
57
  }
58
+ if (event.type === 'terminal')
59
+ terminalSeen = true;
54
60
  events.push(event);
55
61
  }
62
+ if (provider.capabilities.terminalReasons === true && !terminalSeen) {
63
+ throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
64
+ }
56
65
  yield {
57
66
  type: 'api-attempt-duration',
58
67
  durationMs: attemptDurationMs ??
@@ -1,4 +1,5 @@
1
1
  import { ModelProviderError, } from '../core/runtime.js';
2
+ import { transportFailureKind } from './provider-errors.js';
2
3
  function isRecord(value) {
3
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
4
5
  }
@@ -13,6 +14,48 @@ function readErrorMessage(value, status) {
13
14
  }
14
15
  return `Provider request failed with HTTP ${status}`;
15
16
  }
17
+ function openAiFinishReason(value) {
18
+ if (value === 'stop' || value === 'content_filter')
19
+ return 'end_turn';
20
+ if (value === 'tool_calls' || value === 'function_call')
21
+ return 'tool_use';
22
+ if (value === 'length')
23
+ return 'max_tokens';
24
+ if (typeof value !== 'string' || value.length === 0) {
25
+ throw new ModelProviderError('Provider stream is missing finish reason', {
26
+ retryable: false,
27
+ });
28
+ }
29
+ throw new ModelProviderError(`Provider returned unsupported finish reason ${value}`, { retryable: false });
30
+ }
31
+ function openAiErrorKind(value, status) {
32
+ const error = isRecord(value) && isRecord(value.error) ? value.error : value;
33
+ const type = isRecord(error) && typeof error.type === 'string' ? error.type : '';
34
+ const code = isRecord(error) && typeof error.code === 'string' ? error.code : '';
35
+ const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
36
+ if (['context_length_exceeded', 'prompt_too_long'].includes(type) ||
37
+ ['context_length_exceeded', 'prompt_too_long'].includes(code) ||
38
+ /prompt\s+(?:is\s+)?too long|context.{0,80}(?:exceed|too long)|maximum context length/iu.test(message)) {
39
+ return 'prompt_too_long';
40
+ }
41
+ if (status === 401 || status === 403)
42
+ return 'authentication_failed';
43
+ if (status === 402)
44
+ return 'billing_error';
45
+ if (status === 408)
46
+ return 'timeout';
47
+ if (type === 'rate_limit_error' || status === 429)
48
+ return 'rate_limit';
49
+ if (type === 'overloaded_error' || status === 529)
50
+ return 'overloaded';
51
+ if (type === 'api_error' || type === 'server_error')
52
+ return 'api_error';
53
+ if (status !== undefined && status >= 400 && status < 500)
54
+ return 'invalid_request';
55
+ if (status !== undefined && status >= 500)
56
+ return 'server_error';
57
+ return 'unknown';
58
+ }
16
59
  function completedToolCallEvents(pending) {
17
60
  const events = [...pending.calls.entries()]
18
61
  .sort(([left], [right]) => left - right)
@@ -42,9 +85,18 @@ function completedToolCallEvents(pending) {
42
85
  }
43
86
  function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPerResponse, maxToolMetadataBytes) {
44
87
  if (data === '[DONE]') {
88
+ if (pendingTools.terminalReason === undefined) {
89
+ throw new ModelProviderError('Provider stream is missing finish reason', {
90
+ retryable: false,
91
+ });
92
+ }
45
93
  pendingTools.done = true;
46
94
  pendingTools.terminal = true;
47
- return completedToolCallEvents(pendingTools);
95
+ pendingTools.terminalEmitted = true;
96
+ return [
97
+ ...completedToolCallEvents(pendingTools),
98
+ { type: 'terminal', reason: pendingTools.terminalReason },
99
+ ];
48
100
  }
49
101
  let value;
50
102
  try {
@@ -58,6 +110,16 @@ function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPe
58
110
  }
59
111
  if (!isRecord(value))
60
112
  return [];
113
+ if (isRecord(value.error)) {
114
+ const kind = openAiErrorKind(value);
115
+ const message = typeof value.error.message === 'string'
116
+ ? value.error.message
117
+ : 'Provider stream returned an error';
118
+ throw new ModelProviderError(message, {
119
+ kind,
120
+ retryable: ['api_error', 'overloaded', 'rate_limit', 'timeout'].includes(kind),
121
+ });
122
+ }
61
123
  const events = [];
62
124
  const choices = value.choices;
63
125
  if (Array.isArray(choices)) {
@@ -106,7 +168,11 @@ function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPe
106
168
  if (isRecord(first) &&
107
169
  first.finish_reason !== null &&
108
170
  first.finish_reason !== undefined) {
171
+ if (pendingTools.terminalReason !== undefined) {
172
+ throw new ModelProviderError('Provider returned multiple finish reasons', { retryable: false });
173
+ }
109
174
  pendingTools.terminal = true;
175
+ pendingTools.terminalReason = openAiFinishReason(first.finish_reason);
110
176
  if (pendingTools.calls.size > 0) {
111
177
  events.push(...completedToolCallEvents(pendingTools));
112
178
  }
@@ -219,6 +285,7 @@ export class OpenAICompatibleProvider {
219
285
  tools: true,
220
286
  images: true,
221
287
  thinking: { modes: ['disabled'], maxTokens: false },
288
+ terminalReasons: true,
222
289
  ...(options.contextWindowTokens === undefined
223
290
  ? {}
224
291
  : { contextWindowTokens: options.contextWindowTokens }),
@@ -279,10 +346,14 @@ export class OpenAICompatibleProvider {
279
346
  response = await this.fetchImplementation(this.endpoint, requestInit);
280
347
  }
281
348
  catch (error) {
282
- if (request.signal?.aborted)
283
- throw error;
284
- throw new ModelProviderError('Provider transport failed', {
285
- retryable: true,
349
+ const kind = transportFailureKind(error, request.signal);
350
+ throw new ModelProviderError(kind === 'cancelled'
351
+ ? 'Provider request cancelled'
352
+ : kind === 'timeout'
353
+ ? 'Provider request timed out'
354
+ : 'Provider transport failed', {
355
+ kind,
356
+ retryable: kind === 'timeout' || kind === 'transport_error',
286
357
  cause: error,
287
358
  });
288
359
  }
@@ -330,12 +401,14 @@ export class OpenAICompatibleProvider {
330
401
  payload = null;
331
402
  }
332
403
  throw new ModelProviderError(readErrorMessage(payload, response.status), {
404
+ kind: openAiErrorKind(payload, response.status),
333
405
  retryable: isRetryableStatus(response.status),
334
406
  status: response.status,
335
407
  });
336
408
  }
337
409
  if (!response.body) {
338
410
  throw new ModelProviderError('Provider response has no body', {
411
+ kind: 'transport_error',
339
412
  retryable: true,
340
413
  });
341
414
  }
@@ -347,6 +420,7 @@ export class OpenAICompatibleProvider {
347
420
  metadataBytes: 0,
348
421
  done: false,
349
422
  terminal: false,
423
+ terminalEmitted: false,
350
424
  };
351
425
  let streamEnded = false;
352
426
  try {
@@ -383,13 +457,23 @@ export class OpenAICompatibleProvider {
383
457
  if (!pendingTools.terminal) {
384
458
  throw new ModelProviderError('Provider stream ended before a terminal event', { retryable: true });
385
459
  }
460
+ if (!pendingTools.terminalEmitted) {
461
+ if (pendingTools.terminalReason === undefined) {
462
+ throw new ModelProviderError('Provider stream is missing finish reason', {
463
+ retryable: false,
464
+ });
465
+ }
466
+ pendingTools.terminalEmitted = true;
467
+ yield { type: 'terminal', reason: pendingTools.terminalReason };
468
+ }
386
469
  }
387
470
  catch (error) {
388
- if (error instanceof ModelProviderError || request.signal?.aborted) {
471
+ if (error instanceof ModelProviderError)
389
472
  throw error;
390
- }
473
+ const kind = transportFailureKind(error, request.signal);
391
474
  throw new ModelProviderError('Provider stream failed', {
392
- retryable: true,
475
+ kind,
476
+ retryable: kind === 'timeout' || kind === 'transport_error',
393
477
  cause: error,
394
478
  });
395
479
  }
@@ -0,0 +1,3 @@
1
+ export type TransportFailureKind = 'cancelled' | 'timeout' | 'transport_error';
2
+ export declare function transportFailureKind(error: unknown, signal?: AbortSignal): TransportFailureKind;
3
+ //# sourceMappingURL=provider-errors.d.ts.map
@@ -0,0 +1,9 @@
1
+ export function transportFailureKind(error, signal) {
2
+ const reason = signal?.reason;
3
+ if ((error instanceof Error && error.name === 'TimeoutError') ||
4
+ (reason instanceof Error && reason.name === 'TimeoutError')) {
5
+ return 'timeout';
6
+ }
7
+ return signal?.aborted ? 'cancelled' : 'transport_error';
8
+ }
9
+ //# sourceMappingURL=provider-errors.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -44,6 +44,7 @@
44
44
  "test:docs": "node scripts/verify-docs.mjs",
45
45
  "test:compat": "npm run build && node scripts/verify-claude-compatibility.mjs && node scripts/verify-claude-advanced-fixtures.mjs",
46
46
  "test:compat:all": "npm run build && node scripts/verify-compatibility-matrix.mjs",
47
+ "test:tui:pty": "npm run build && node scripts/verify-fullscreen-rendering.mjs",
47
48
  "test:cli-surface-compat": "npm run build && node scripts/verify-cli-surface-parity.mjs",
48
49
  "test:cli-controls-compat": "npm run build && node scripts/verify-cli-controls.mjs",
49
50
  "test:conditional-compat": "npm run build && node scripts/verify-conditional-rules.mjs",