praxis-agent 0.55.2 → 0.56.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.
package/README.md CHANGED
@@ -172,8 +172,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
172
172
  deterministic resize-aware URL/form elicitation rendering, and measured
173
173
  context budgets; print mode,
174
174
  structured JSON/JSONL, context compaction, tool loops, and bounded execution.
175
- - **Built-in tools** — read, write, edit, glob, search, shell, notebook, PDF,
176
- image, web, scheduled prompts, workflows, and worktrees.
175
+ - **Built-in tools** — read, write, edit, `ApplyPatch` for bounded ordered exact
176
+ multi-file replacements, glob, search, shell, notebook, PDF, image, web,
177
+ scheduled prompts, workflows, and worktrees.
177
178
  - **Shell lifecycle** — foreground Bash allows up to 10 minutes and carries a
178
179
  validated final working directory across calls in the same session without
179
180
  leaking state across sessions or overriding an explicit `/cd`.
@@ -51,6 +51,7 @@ import { completeMeteredModelRequest } from './metered-model-completion.js';
51
51
  import { SessionMemoryController, SessionMemoryStateError, SessionMemoryStore, } from './session-memory.js';
52
52
  import { FilteredToolRegistry } from '../tools/filtered-tool-registry.js';
53
53
  import { DeferredToolCatalog } from '../tools/deferred-tool-catalog.js';
54
+ import { parseApplyPatchInput } from '../tools/apply-patch.js';
54
55
  import { ClaudeCapabilityToolRegistry, resolveClaudeToolCapabilities, } from '../tools/claude-capabilities.js';
55
56
  import { generateToolUseSummary } from './tool-use-summary.js';
56
57
  import { ClaudeUserMessageToolRegistry, } from '../tools/claude-user-message.js';
@@ -88,6 +89,21 @@ function mainAgentToolNames(tools, agent) {
88
89
  .map(({ name }) => name)
89
90
  .filter((name) => (!requested || requested.has(name)) && !disallowed.has(name));
90
91
  }
92
+ function mutationPaths(call) {
93
+ if (call.name === 'ApplyPatch')
94
+ return parseApplyPatchInput(call.input).map((edit) => edit.file_path);
95
+ if (call.name === 'Write' || call.name === 'Edit') {
96
+ return typeof call.input.file_path === 'string'
97
+ ? [call.input.file_path]
98
+ : [];
99
+ }
100
+ if (call.name === 'NotebookEdit') {
101
+ return typeof call.input.notebook_path === 'string'
102
+ ? [call.input.notebook_path]
103
+ : [];
104
+ }
105
+ return [];
106
+ }
91
107
  function isSessionCandidateError(error) {
92
108
  return (error instanceof NativeTranscriptIndexCandidateError ||
93
109
  ['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code ?? ''));
@@ -1168,6 +1184,7 @@ export class ClaudeSessionService {
1168
1184
  'Read',
1169
1185
  'LSP',
1170
1186
  'Edit',
1187
+ 'ApplyPatch',
1171
1188
  'Write',
1172
1189
  'NotebookEdit',
1173
1190
  'WebFetch',
@@ -2869,16 +2886,18 @@ export class ClaudeSessionService {
2869
2886
  }),
2870
2887
  prepare: (call, context) => interactiveMessageTools.prepare(call, context),
2871
2888
  execute: async (call, context) => {
2872
- const path = call.name === 'Write' || call.name === 'Edit'
2873
- ? call.input.file_path
2874
- : call.name === 'NotebookEdit'
2875
- ? call.input.notebook_path
2876
- : undefined;
2877
- if (typeof path !== 'string') {
2889
+ const paths = mutationPaths(call);
2890
+ if (paths.length === 0) {
2878
2891
  return interactiveMessageTools.execute(call, context);
2879
2892
  }
2880
- if ((call.name === 'Write' || call.name === 'Edit') &&
2881
- (await this.options.interactiveTools?.isPlanFile(sessionId, path))) {
2893
+ const historyPaths = call.name === 'Write' ||
2894
+ call.name === 'Edit' ||
2895
+ call.name === 'ApplyPatch'
2896
+ ? (await Promise.all(paths.map(async (path) => (await this.options.interactiveTools?.isPlanFile(sessionId, path))
2897
+ ? null
2898
+ : path))).filter((path) => path !== null)
2899
+ : paths;
2900
+ if (historyPaths.length === 0) {
2882
2901
  return interactiveMessageTools.execute(call, context);
2883
2902
  }
2884
2903
  const snapshotMessageId = currentPromptId ??
@@ -2888,21 +2907,31 @@ export class ClaudeSessionService {
2888
2907
  if (!snapshotMessageId || !assistantMessageId) {
2889
2908
  throw new Error('Claude file history could not link tool call');
2890
2909
  }
2891
- const prepared = await fileHistory.prepareMutation(projectionSnapshot().entries, snapshotMessageId, path);
2910
+ const preparedMutations = [];
2911
+ try {
2912
+ for (const path of [...new Set(historyPaths)])
2913
+ preparedMutations.push(await fileHistory.prepareMutation(projectionSnapshot().entries, snapshotMessageId, path));
2914
+ }
2915
+ catch (error) {
2916
+ await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
2917
+ throw error;
2918
+ }
2892
2919
  let result;
2893
2920
  try {
2894
2921
  result = await interactiveMessageTools.execute(call, context);
2895
2922
  }
2896
2923
  catch (error) {
2897
- await prepared.rollback();
2924
+ await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
2898
2925
  throw error;
2899
2926
  }
2900
2927
  if (result.isError) {
2901
- await prepared.rollback();
2928
+ await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
2902
2929
  return result;
2903
2930
  }
2904
- const entry = prepared.commit(assistantMessageId);
2905
- if (entry) {
2931
+ const entries = preparedMutations
2932
+ .map((mutation) => mutation.commit(assistantMessageId))
2933
+ .filter((entry) => entry !== null);
2934
+ for (const entry of entries) {
2906
2935
  await persistence.commit({
2907
2936
  kind: 'messages',
2908
2937
  input: {
@@ -3105,7 +3134,9 @@ export class ClaudeSessionService {
3105
3134
  if (call.name === 'Read') {
3106
3135
  turnMemory.recordRead(path);
3107
3136
  }
3108
- else if (call.name === 'Write' || call.name === 'Edit') {
3137
+ else if (call.name === 'Write' ||
3138
+ call.name === 'Edit' ||
3139
+ call.name === 'ApplyPatch') {
3109
3140
  projectMemoryMaintained = true;
3110
3141
  }
3111
3142
  }
@@ -3117,6 +3148,14 @@ export class ClaudeSessionService {
3117
3148
  }
3118
3149
  }
3119
3150
  }
3151
+ else if (call.name === 'ApplyPatch') {
3152
+ for (const accessedPath of toolResult.accessedPaths ?? []) {
3153
+ const resolvedAccessedPath = resolve(this.activeCwd(), accessedPath);
3154
+ if (isPathWithin(this.options.projectMemoryDirectory, resolvedAccessedPath)) {
3155
+ projectMemoryMaintained = true;
3156
+ }
3157
+ }
3158
+ }
3120
3159
  }
3121
3160
  if (toolResult.isError ||
3122
3161
  call.name !== 'Read' ||
@@ -299,6 +299,7 @@ const BACKGROUND_AGENT_TOOLS = new Set([
299
299
  'Glob',
300
300
  'Bash',
301
301
  'Edit',
302
+ 'ApplyPatch',
302
303
  'Write',
303
304
  'NotebookEdit',
304
305
  'Skill',
@@ -1885,7 +1885,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1885
1885
  }
1886
1886
  break;
1887
1887
  case 'tool-call':
1888
- if (['Edit', 'Write', 'NotebookEdit'].includes(event.call.name))
1888
+ if (['Edit', 'Write', 'ApplyPatch', 'NotebookEdit'].includes(event.call.name))
1889
1889
  turnMutatedFilesRef.current = true;
1890
1890
  permissionCallsRef.current.set(event.call.id, event.call);
1891
1891
  append({
@@ -4055,7 +4055,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
4055
4055
  }
4056
4056
  if (permission.kind === 'tool' &&
4057
4057
  selected.action === 'allow-session-edits') {
4058
- for (const rule of ['Write', 'Edit', 'NotebookEdit']) {
4058
+ for (const rule of ['Write', 'Edit', 'ApplyPatch', 'NotebookEdit']) {
4059
4059
  if (!immediatePermissionRulesRef.current.includes(rule))
4060
4060
  immediatePermissionRulesRef.current.push(rule);
4061
4061
  }
@@ -0,0 +1,3 @@
1
+ export declare const DIRECT_PROCESS_SIGINT: unique symbol;
2
+ export declare function isDirectProcessSigint(signal: AbortSignal | undefined): boolean;
3
+ //# sourceMappingURL=process-signal.d.ts.map
@@ -0,0 +1,5 @@
1
+ export const DIRECT_PROCESS_SIGINT = Symbol('praxis.direct-process-sigint');
2
+ export function isDirectProcessSigint(signal) {
3
+ return signal?.aborted === true && signal.reason === DIRECT_PROCESS_SIGINT;
4
+ }
5
+ //# sourceMappingURL=process-signal.js.map
@@ -1,4 +1,4 @@
1
- import type { ModelDocument, ModelDocumentMediaType, ModelImage, ModelImageMediaType, ModelUsage, RuntimeEventSink } from '../core/runtime.js';
1
+ import type { ModelDocument, ModelDocumentMediaType, ModelImage, ModelImageMediaType, ModelTerminalReason, ModelUsage, RuntimeEventSink } from '../core/runtime.js';
2
2
  export type CliInputFormat = 'text' | 'stream-json';
3
3
  export type CliOutputFormat = 'text' | 'json' | 'stream-json';
4
4
  export type CliPermissionMode = 'acceptEdits' | 'auto' | 'bypassPermissions' | 'manual' | 'dontAsk' | 'plan' | 'default';
@@ -225,8 +225,25 @@ export interface ProtocolResult {
225
225
  */
226
226
  modelCostUsd?: Readonly<Record<string, number>>;
227
227
  }
228
- export declare function createSuccessResult(result: ProtocolResult, info: CliRuntimeInfo, startedAt: number, modelTurns: number): Record<string, unknown>;
229
- export declare function createErrorResult(message: string, sessionId: string, startedAt: number, modelTurns: number): Record<string, unknown>;
228
+ export interface ProtocolSuccessProjectionContext {
229
+ localCommand?: boolean;
230
+ stopReason?: ModelTerminalReason;
231
+ ttftMs?: number;
232
+ ttftStreamMs?: number;
233
+ timeToRequestMs?: number;
234
+ }
235
+ export interface ProtocolErrorProjectionContext {
236
+ providerApiError?: boolean;
237
+ apiErrorStatus?: number | null;
238
+ }
239
+ export interface ProtocolTimingProjection {
240
+ ttftMs?: number;
241
+ ttftStreamMs?: number;
242
+ timeToRequestMs?: number;
243
+ }
244
+ export declare function projectProtocolTimings(startedAt: number, requestAt?: number, outputAt?: number): ProtocolTimingProjection;
245
+ export declare function createSuccessResult(result: ProtocolResult, info: CliRuntimeInfo, startedAt: number, modelTurns: number, context?: ProtocolSuccessProjectionContext): Record<string, unknown>;
246
+ export declare function createErrorResult(message: string, sessionId: string, startedAt: number, modelTurns: number, context?: ProtocolErrorProjectionContext): Record<string, unknown>;
230
247
  /**
231
248
  * Returns the argument text of a headless `/color` prompt, or undefined when
232
249
  * the prompt is not a bare `/color` command. A trailing space matches with an
@@ -259,6 +276,11 @@ export declare class StreamJsonOutput {
259
276
  private activeThinkingIndex;
260
277
  private nextContentIndex;
261
278
  private modelTurns;
279
+ private projectionStartedAt;
280
+ private firstRequestAt;
281
+ private firstOutputAt;
282
+ private turnHasRealContent;
283
+ private pendingFailureMessage;
262
284
  private compacting;
263
285
  private sessionState;
264
286
  private readonly emitSessionStateEvents;
@@ -272,7 +294,7 @@ export declare class StreamJsonOutput {
272
294
  private readonly includePartialMessages;
273
295
  private readonly includeHookEvents;
274
296
  private write;
275
- init(): void;
297
+ init(startedAt?: number): void;
276
298
  replayUser(message: StreamUserMessage['message']): void;
277
299
  /**
278
300
  * Emits the synthetic assistant message for a provider-free local command
@@ -285,13 +307,14 @@ export declare class StreamJsonOutput {
285
307
  request: Record<string, unknown>;
286
308
  }): void;
287
309
  readonly sink: RuntimeEventSink;
288
- result(result: ProtocolResult, startedAt: number): void;
310
+ result(result: ProtocolResult, startedAt: number, context?: ProtocolSuccessProjectionContext): void;
289
311
  promptSuggestion(suggestion: string): void;
290
- error(message: string, startedAt: number): void;
312
+ error(message: string, startedAt: number, context?: ProtocolErrorProjectionContext): void;
291
313
  private onEvent;
292
314
  private writeSessionState;
293
315
  private writeTerminalIdle;
294
316
  private ensureTurn;
317
+ private observeOutput;
295
318
  private discardTurn;
296
319
  private startTurn;
297
320
  private writePartialMessageStart;
@@ -300,6 +323,7 @@ export declare class StreamJsonOutput {
300
323
  private finishPartial;
301
324
  private finishPartialTail;
302
325
  private flushAssistant;
326
+ private flushPartialOnly;
303
327
  private finishTurn;
304
328
  }
305
329
  //# sourceMappingURL=protocol.d.ts.map
@@ -1,32 +1,74 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- export function createSuccessResult(result, info, startedAt, modelTurns) {
3
- const duration = Date.now() - startedAt;
4
- const modelUsage = result.modelUsage ?? { [info.model]: result.usage };
5
- const usage = {
6
- input_tokens: result.usage.inputTokens,
7
- output_tokens: result.usage.outputTokens,
8
- ...(result.usage.cacheReadInputTokens === undefined
2
+ export function projectProtocolTimings(startedAt, requestAt, outputAt) {
3
+ return {
4
+ ...(requestAt === undefined
9
5
  ? {}
10
- : { cache_read_input_tokens: result.usage.cacheReadInputTokens }),
11
- ...(result.usage.cacheCreationInputTokens === undefined
6
+ : { timeToRequestMs: requestAt - startedAt }),
7
+ ...(outputAt === undefined
12
8
  ? {}
13
9
  : {
14
- cache_creation_input_tokens: result.usage.cacheCreationInputTokens,
10
+ ttftMs: outputAt - startedAt,
11
+ ttftStreamMs: outputAt - startedAt,
15
12
  }),
16
13
  };
14
+ }
15
+ function protocolUsage(usage) {
16
+ return {
17
+ input_tokens: usage.inputTokens,
18
+ cache_creation_input_tokens: usage.cacheCreationInputTokens ?? 0,
19
+ cache_read_input_tokens: usage.cacheReadInputTokens ?? 0,
20
+ output_tokens: usage.outputTokens,
21
+ server_tool_use: {
22
+ web_search_requests: usage.webSearchRequests ?? 0,
23
+ web_fetch_requests: 0,
24
+ },
25
+ service_tier: 'standard',
26
+ cache_creation: {
27
+ ephemeral_1h_input_tokens: 0,
28
+ ephemeral_5m_input_tokens: 0,
29
+ },
30
+ inference_geo: '',
31
+ iterations: [],
32
+ speed: 'standard',
33
+ };
34
+ }
35
+ function normalizeApiError(message, status) {
36
+ const body = message.replace(/^API Error:\s*\d+\s*/u, '');
37
+ return `API Error: ${status} ${body}`;
38
+ }
39
+ export function createSuccessResult(result, info, startedAt, modelTurns, context = {}) {
40
+ const duration = Date.now() - startedAt;
41
+ const localCommand = context.localCommand === true;
42
+ const modelUsage = result.modelUsage ?? (localCommand ? {} : { [info.model]: result.usage });
43
+ const usage = protocolUsage(result.usage);
17
44
  return {
18
45
  type: 'result',
19
46
  subtype: 'success',
20
47
  is_error: false,
21
48
  duration_ms: duration,
22
- duration_api_ms: result.durationApiMs === undefined
23
- ? null
24
- : Math.round(result.durationApiMs),
25
- num_turns: modelTurns,
49
+ ...(localCommand
50
+ ? {}
51
+ : {
52
+ api_error_status: null,
53
+ ...(context.ttftMs === undefined ? {} : { ttft_ms: context.ttftMs }),
54
+ ...(context.ttftStreamMs === undefined
55
+ ? {}
56
+ : { ttft_stream_ms: context.ttftStreamMs }),
57
+ ...(context.timeToRequestMs === undefined
58
+ ? {}
59
+ : { time_to_request_ms: context.timeToRequestMs }),
60
+ }),
61
+ duration_api_ms: localCommand
62
+ ? 0
63
+ : result.durationApiMs === undefined
64
+ ? 0
65
+ : Math.round(result.durationApiMs),
66
+ num_turns: localCommand ? 0 : modelTurns,
26
67
  result: result.text,
27
- stop_reason: null,
68
+ stop_reason: localCommand ? null : (context.stopReason ?? null),
69
+ ...(localCommand ? {} : { terminal_reason: 'completed' }),
28
70
  session_id: result.sessionId,
29
- total_cost_usd: result.costUsd ?? null,
71
+ total_cost_usd: result.costUsd ?? 0,
30
72
  usage,
31
73
  modelUsage: Object.fromEntries(Object.entries(modelUsage).map(([model, modelUsage]) => [
32
74
  model,
@@ -35,11 +77,9 @@ export function createSuccessResult(result, info, startedAt, modelTurns) {
35
77
  outputTokens: modelUsage.outputTokens,
36
78
  cacheReadInputTokens: modelUsage.cacheReadInputTokens ?? 0,
37
79
  cacheCreationInputTokens: modelUsage.cacheCreationInputTokens ?? 0,
80
+ webSearchRequests: modelUsage.webSearchRequests ?? 0,
38
81
  costUSD: result.modelCostUsd?.[model] ??
39
- (model === info.model ? (result.costUsd ?? null) : null),
40
- ...(modelUsage.webSearchRequests === undefined
41
- ? {}
42
- : { webSearchRequests: modelUsage.webSearchRequests }),
82
+ (model === info.model ? (result.costUsd ?? 0) : 0),
43
83
  contextWindow: modelUsage.contextWindow ??
44
84
  (model === info.model ? (info.contextWindowTokens ?? 0) : 0),
45
85
  maxOutputTokens: modelUsage.maxOutputTokens ??
@@ -63,24 +103,45 @@ function errorResultSubtype(message) {
63
103
  return 'error_max_structured_output_retries';
64
104
  return 'error_during_execution';
65
105
  }
66
- export function createErrorResult(message, sessionId, startedAt, modelTurns) {
106
+ export function createErrorResult(message, sessionId, startedAt, modelTurns, context = {}) {
67
107
  const duration = Date.now() - startedAt;
108
+ const providerApiError = context.providerApiError === true;
109
+ const apiErrorStatus = context.apiErrorStatus ?? null;
110
+ const normalizedMessage = providerApiError && typeof context.apiErrorStatus === 'number'
111
+ ? normalizeApiError(message, context.apiErrorStatus)
112
+ : message;
113
+ if (providerApiError) {
114
+ return {
115
+ type: 'result',
116
+ subtype: 'success',
117
+ is_error: true,
118
+ api_error_status: apiErrorStatus,
119
+ duration_ms: duration,
120
+ duration_api_ms: 0,
121
+ num_turns: modelTurns,
122
+ result: normalizedMessage,
123
+ stop_reason: 'stop_sequence',
124
+ terminal_reason: 'api_error',
125
+ session_id: sessionId,
126
+ total_cost_usd: 0,
127
+ usage: protocolUsage({ inputTokens: 0, outputTokens: 0 }),
128
+ modelUsage: {},
129
+ permission_denials: [],
130
+ fast_mode_state: 'off',
131
+ uuid: randomUUID(),
132
+ };
133
+ }
68
134
  return {
69
135
  type: 'result',
70
136
  subtype: errorResultSubtype(message),
71
137
  is_error: true,
72
138
  duration_ms: duration,
73
- duration_api_ms: null,
139
+ duration_api_ms: 0,
74
140
  num_turns: modelTurns,
75
141
  stop_reason: null,
76
142
  session_id: sessionId,
77
- total_cost_usd: null,
78
- usage: {
79
- input_tokens: 0,
80
- output_tokens: 0,
81
- cache_creation_input_tokens: 0,
82
- cache_read_input_tokens: 0,
83
- },
143
+ total_cost_usd: 0,
144
+ usage: protocolUsage({ inputTokens: 0, outputTokens: 0 }),
84
145
  modelUsage: {},
85
146
  permission_denials: [],
86
147
  errors: [message],
@@ -1710,6 +1771,11 @@ export class StreamJsonOutput {
1710
1771
  activeThinkingIndex;
1711
1772
  nextContentIndex = 0;
1712
1773
  modelTurns = 0;
1774
+ projectionStartedAt;
1775
+ firstRequestAt;
1776
+ firstOutputAt;
1777
+ turnHasRealContent = false;
1778
+ pendingFailureMessage;
1713
1779
  compacting = false;
1714
1780
  sessionState;
1715
1781
  emitSessionStateEvents;
@@ -1732,7 +1798,11 @@ export class StreamJsonOutput {
1732
1798
  session_id: value.session_id ?? this.sessionId,
1733
1799
  });
1734
1800
  }
1735
- init() {
1801
+ init(startedAt = Date.now()) {
1802
+ this.projectionStartedAt = startedAt;
1803
+ this.firstRequestAt = undefined;
1804
+ this.firstOutputAt = undefined;
1805
+ this.pendingFailureMessage = undefined;
1736
1806
  this.writeSessionState('running');
1737
1807
  this.write({
1738
1808
  type: 'system',
@@ -1783,9 +1853,21 @@ export class StreamJsonOutput {
1783
1853
  this.write({ type: 'control_request', ...request });
1784
1854
  }
1785
1855
  sink = (event) => this.onEvent(event);
1786
- result(result, startedAt) {
1856
+ result(result, startedAt, context = {}) {
1787
1857
  this.finishTurn();
1788
- this.write(createSuccessResult(result, this.info, startedAt, this.modelTurns));
1858
+ const requestAt = this.firstRequestAt;
1859
+ const outputAt = this.firstOutputAt;
1860
+ const stopReason = context.stopReason ?? this.turnTerminalReason;
1861
+ const timing = projectProtocolTimings(this.projectionStartedAt ?? startedAt, requestAt, outputAt);
1862
+ this.write(createSuccessResult(result, this.info, startedAt, this.modelTurns, {
1863
+ ...context,
1864
+ ...(context.localCommand
1865
+ ? {}
1866
+ : {
1867
+ ...(stopReason === undefined ? {} : { stopReason }),
1868
+ ...timing,
1869
+ }),
1870
+ }));
1789
1871
  this.writeTerminalIdle();
1790
1872
  this.modelTurns = 0;
1791
1873
  }
@@ -1797,9 +1879,15 @@ export class StreamJsonOutput {
1797
1879
  session_id: this.sessionId,
1798
1880
  });
1799
1881
  }
1800
- error(message, startedAt) {
1882
+ error(message, startedAt, context = {}) {
1883
+ if (this.pendingFailureMessage !== undefined) {
1884
+ this.turnText =
1885
+ context.providerApiError && typeof context.apiErrorStatus === 'number'
1886
+ ? normalizeApiError(message, context.apiErrorStatus)
1887
+ : message;
1888
+ }
1801
1889
  this.finishTurn();
1802
- this.write(createErrorResult(message, this.sessionId, startedAt, this.modelTurns));
1890
+ this.write(createErrorResult(message, this.sessionId, startedAt, this.modelTurns, context));
1803
1891
  this.writeTerminalIdle();
1804
1892
  this.modelTurns = 0;
1805
1893
  }
@@ -1841,7 +1929,16 @@ export class StreamJsonOutput {
1841
1929
  event.state === 'completed' ||
1842
1930
  event.state === 'cancelled' ||
1843
1931
  event.state === 'failed') {
1844
- this.flushAssistant();
1932
+ const shouldFlush = event.state === 'cancelled'
1933
+ ? this.turnText.length > 0 ||
1934
+ this.turnThinking.length > 0 ||
1935
+ this.turnCalls.length > 0
1936
+ : event.state !== 'failed' ||
1937
+ this.pendingFailureMessage === undefined;
1938
+ if (shouldFlush)
1939
+ this.flushAssistant();
1940
+ else if (event.state === 'cancelled')
1941
+ this.flushPartialOnly();
1845
1942
  if (event.state === 'awaiting-permission')
1846
1943
  this.writeSessionState('requires_action');
1847
1944
  }
@@ -1849,12 +1946,14 @@ export class StreamJsonOutput {
1849
1946
  }
1850
1947
  if (event.type === 'text-delta') {
1851
1948
  this.ensureTurn();
1949
+ this.observeOutput();
1852
1950
  this.turnText += event.delta;
1853
1951
  this.partialText(event.delta);
1854
1952
  return;
1855
1953
  }
1856
1954
  if (event.type === 'thinking-start') {
1857
1955
  this.ensureTurn();
1956
+ this.observeOutput();
1858
1957
  if (this.includePartialMessages) {
1859
1958
  if (this.activeThinkingIndex !== undefined) {
1860
1959
  throw new Error('Thinking content blocks cannot overlap');
@@ -1885,6 +1984,7 @@ export class StreamJsonOutput {
1885
1984
  if (event.type === 'thinking-delta' ||
1886
1985
  event.type === 'thinking-signature-delta') {
1887
1986
  this.ensureTurn();
1987
+ this.observeOutput();
1888
1988
  if (this.includePartialMessages) {
1889
1989
  if (this.activeThinkingIndex === undefined) {
1890
1990
  throw new Error('Thinking delta arrived without an active block');
@@ -1906,6 +2006,7 @@ export class StreamJsonOutput {
1906
2006
  }
1907
2007
  if (event.type === 'thinking-stop') {
1908
2008
  this.ensureTurn();
2009
+ this.observeOutput();
1909
2010
  this.turnThinking.push(event.block);
1910
2011
  if (this.includePartialMessages) {
1911
2012
  if (this.activeThinkingIndex === undefined) {
@@ -1930,6 +2031,7 @@ export class StreamJsonOutput {
1930
2031
  }
1931
2032
  if (event.type === 'tool-call') {
1932
2033
  this.ensureTurn();
2034
+ this.observeOutput();
1933
2035
  this.turnCalls.push(event.call);
1934
2036
  if (this.includePartialMessages) {
1935
2037
  const index = this.nextContentIndex++;
@@ -2165,13 +2267,16 @@ export class StreamJsonOutput {
2165
2267
  }
2166
2268
  if (event.type === 'failed') {
2167
2269
  this.ensureTurn();
2168
- if (this.turnText.length === 0 &&
2270
+ if (!this.turnHasRealContent &&
2271
+ this.turnText.length === 0 &&
2169
2272
  this.turnThinking.length === 0 &&
2170
2273
  this.turnCalls.length === 0) {
2171
- this.turnText = event.message;
2172
- this.partialText(event.message);
2274
+ this.pendingFailureMessage = event.message;
2275
+ this.assistantFlushed = false;
2276
+ }
2277
+ else {
2278
+ this.flushAssistant();
2173
2279
  }
2174
- this.flushAssistant();
2175
2280
  }
2176
2281
  }
2177
2282
  writeSessionState(state) {
@@ -2204,6 +2309,11 @@ export class StreamJsonOutput {
2204
2309
  if (!this.turnActive)
2205
2310
  this.startTurn();
2206
2311
  }
2312
+ observeOutput() {
2313
+ this.turnHasRealContent = true;
2314
+ if (this.firstOutputAt === undefined)
2315
+ this.firstOutputAt = Date.now();
2316
+ }
2207
2317
  discardTurn(reason) {
2208
2318
  this.write({
2209
2319
  type: 'system',
@@ -2225,6 +2335,8 @@ export class StreamJsonOutput {
2225
2335
  this.nextContentIndex = 0;
2226
2336
  this.partialEvents = [];
2227
2337
  this.pendingPartialStop = undefined;
2338
+ this.turnHasRealContent = false;
2339
+ this.pendingFailureMessage = undefined;
2228
2340
  }
2229
2341
  startTurn() {
2230
2342
  if (this.turnActive)
@@ -2243,6 +2355,9 @@ export class StreamJsonOutput {
2243
2355
  this.nextContentIndex = 0;
2244
2356
  this.partialEvents = [];
2245
2357
  this.pendingPartialStop = undefined;
2358
+ this.turnHasRealContent = false;
2359
+ if (this.firstRequestAt === undefined)
2360
+ this.firstRequestAt = Date.now();
2246
2361
  this.modelTurns += 1;
2247
2362
  if (this.includePartialMessages) {
2248
2363
  this.write({
@@ -2384,6 +2499,18 @@ export class StreamJsonOutput {
2384
2499
  });
2385
2500
  this.finishPartialTail();
2386
2501
  }
2502
+ flushPartialOnly() {
2503
+ if (!this.includePartialMessages ||
2504
+ this.assistantFlushed ||
2505
+ this.partialEvents.length === 0)
2506
+ return;
2507
+ this.assistantFlushed = true;
2508
+ this.writePartialMessageStart();
2509
+ for (const event of this.partialEvents)
2510
+ this.write(event);
2511
+ this.partialEvents = [];
2512
+ this.pendingPartialStop = undefined;
2513
+ }
2387
2514
  finishTurn() {
2388
2515
  this.flushAssistant();
2389
2516
  this.turnActive = false;