tinker-agent 1.5.0 → 1.6.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +13 -5
  3. package/package.json +7 -5
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/tui-runner.tsx +21 -2
  14. package/src/events/observation-text-log.ts +21 -0
  15. package/src/events/stdout-event-printer.ts +11 -0
  16. package/src/events/types.ts +14 -2
  17. package/src/model/fake-model-client.ts +190 -0
  18. package/src/model/model-client.ts +3 -0
  19. package/src/model/openai-chat-model-client.ts +54 -15
  20. package/src/model/openai-chat-stream.ts +95 -72
  21. package/src/observation/observation-builder.ts +11 -0
  22. package/src/session/session-store.ts +1 -0
  23. package/src/tools/bash-guard.ts +131 -0
  24. package/src/tools/bash.ts +31 -0
  25. package/src/tools/delete.ts +182 -0
  26. package/src/tools/edit.ts +68 -9
  27. package/src/tools/registry.ts +47 -3
  28. package/src/tools/turn-undo-manager.ts +794 -0
  29. package/src/tools/types.ts +13 -0
  30. package/src/tools/write.ts +65 -14
  31. package/src/tui/app.tsx +301 -134
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/assistant-markdown.tsx +27 -26
  34. package/src/tui/components/background-tasks.tsx +7 -2
  35. package/src/tui/components/bash-confirmation.tsx +27 -0
  36. package/src/tui/components/context-status.tsx +11 -1
  37. package/src/tui/components/file-viewer.tsx +2 -2
  38. package/src/tui/components/footer.tsx +9 -12
  39. package/src/tui/components/memory-browser.tsx +1 -1
  40. package/src/tui/components/prompt-input.tsx +13 -1
  41. package/src/tui/components/resume-session-picker.tsx +3 -1
  42. package/src/tui/components/timeline.tsx +19 -11
  43. package/src/tui/context-format.ts +17 -0
  44. package/src/tui/event-store.ts +75 -3
  45. package/src/tui/shiki-highlighter.ts +104 -0
  46. package/src/tui/slash-commands.ts +28 -0
  47. package/src/tui/tui-projection-store.ts +277 -5
  48. package/src/tui/tui-session-controller.ts +32 -8
@@ -285,8 +285,8 @@ export type TurnFinishedData = {
285
285
  };
286
286
 
287
287
  export type ModelRequestAttemptData = {
288
- attemptNumber: 1 | 2;
289
- maxAttempts: 2;
288
+ attemptNumber: number;
289
+ maxAttempts: number;
290
290
  };
291
291
 
292
292
  export type ModelRequestFailureCode = ProviderResponseErrorCode;
@@ -294,6 +294,7 @@ export type ModelRequestFailureCode = ProviderResponseErrorCode;
294
294
  export type ModelRequestFailedData = ModelRequestAttemptData & {
295
295
  code: ModelRequestFailureCode;
296
296
  retryDisposition: "scheduled" | "not_retryable" | "exhausted";
297
+ retryDelayMs?: number;
297
298
  provider: string;
298
299
  model: string;
299
300
  error: string;
@@ -328,6 +329,16 @@ export type AgentEventDataMap = {
328
329
  "tool.raw_result": { call: ToolCall; raw: ToolRawResult };
329
330
  "tool.finished": { call: ToolCall; ok: boolean };
330
331
  "tool.observation": { call: ToolCall; observation: ToolObservation };
332
+ "tool.confirmation.requested": {
333
+ command: string;
334
+ reason: string;
335
+ };
336
+ "tool.confirmation.resolved": {
337
+ command: string;
338
+ reason: string;
339
+ decision: "allow" | "deny" | "cancelled";
340
+ durationMs: number;
341
+ };
331
342
  "agent.iteration.finished": {
332
343
  outcome: "continue" | "completed";
333
344
  toolCallCount: number;
@@ -419,6 +430,7 @@ export type AgentEventInput =
419
430
  | ToolEventInput<
420
431
  "tool.started" | "tool.raw_result" | "tool.finished" | "tool.observation"
421
432
  >
433
+ | ToolEventInput<"tool.confirmation.requested" | "tool.confirmation.resolved">
422
434
  | ToolEventInput<
423
435
  "bash.task.backgrounded" | "bash.task.stopping" | "bash.task.finished"
424
436
  >;
@@ -226,12 +226,24 @@ export class FakeModelClient implements ModelClient {
226
226
  if (this.mode === "pty-echo-history") {
227
227
  return this.ptyEchoHistory(input, prepared);
228
228
  }
229
+ if (this.mode === "pty-static-history") {
230
+ return this.ptyStaticHistory(input, prepared, options);
231
+ }
232
+ if (this.mode === "pty-incremental-output") {
233
+ return this.ptyIncrementalOutput(input, prepared, options);
234
+ }
235
+ if (this.mode === "pty-resume-layout") {
236
+ return this.ptyResumeLayout(input, prepared, options);
237
+ }
229
238
  if (this.mode === "pty-cancel-then-echo") {
230
239
  return this.ptyCancelThenEcho(input, prepared, options);
231
240
  }
232
241
  if (this.mode === "pty-tool-chain") {
233
242
  return this.ptyToolChain(input, prepared, options);
234
243
  }
244
+ if (this.mode === "pty-turn-undo") {
245
+ return this.ptyTurnUndo(input, prepared, options);
246
+ }
235
247
  if (this.mode === "pty-background-task") {
236
248
  return this.ptyBackgroundTask(input, prepared, options);
237
249
  }
@@ -307,6 +319,130 @@ export class FakeModelClient implements ModelClient {
307
319
  throw new Error(`Unexpected pty-echo-history prompt: ${JSON.stringify(prompt)}.`);
308
320
  }
309
321
 
322
+ private async ptyStaticHistory(
323
+ input: ModelRequestInput,
324
+ prepared: PreparedModelRequest,
325
+ options: ModelRequestOptions,
326
+ ): Promise<ModelRequestOutput> {
327
+ const prompt = lastUserMessage(input.messages);
328
+ const historyMatch = /^PTY_STATIC_HISTORY_([1-4])$/u.exec(prompt);
329
+ if (historyMatch !== null) {
330
+ const turn = historyMatch[1];
331
+ return textOutput(
332
+ prepared,
333
+ [
334
+ turn === "1"
335
+ ? "PTY_STATIC_HISTORY_EARLY_SENTINEL"
336
+ : `PTY_STATIC_HISTORY_${turn}`,
337
+ ...Array.from(
338
+ { length: 8 },
339
+ (_, index) => `- settled PTY history ${turn}.${index + 1}`,
340
+ ),
341
+ `PTY_STATIC_HISTORY_${turn}_DONE`,
342
+ ].join("\n"),
343
+ );
344
+ }
345
+ if (prompt !== "PTY_STATIC_LIVE") {
346
+ throw new Error(
347
+ `Unexpected pty-static-history prompt: ${JSON.stringify(prompt)}.`,
348
+ );
349
+ }
350
+
351
+ requireTools(input, ["Bash"]);
352
+ const bash = toolMessagesAfterLastUser(input.messages).find(
353
+ (message) => message.name === "Bash",
354
+ );
355
+ await Bun.sleep(200);
356
+ options.signal.throwIfAborted();
357
+ if (bash === undefined) {
358
+ return toolCallOutput(prepared, options, "Bash", {
359
+ command:
360
+ 'index=1; while [ "$index" -le 20 ]; do printf \'PTY_STATIC_LIVE_LINE_%s\\n\' "$index"; index=$((index + 1)); done; sleep 0.2',
361
+ description: "Exercise static history live tail",
362
+ });
363
+ }
364
+ if (!bash.content.includes("PTY_STATIC_LIVE_LINE_20")) {
365
+ throw new Error("PTY static-history Bash output was incomplete.");
366
+ }
367
+ return textOutput(prepared, "PTY_STATIC_LIVE_DONE");
368
+ }
369
+
370
+ private async ptyIncrementalOutput(
371
+ input: ModelRequestInput,
372
+ prepared: PreparedModelRequest,
373
+ options: ModelRequestOptions,
374
+ ): Promise<ModelRequestOutput> {
375
+ const prompt = lastUserMessage(input.messages);
376
+ if (prompt !== "PTY_INCREMENTAL_OUTPUT") {
377
+ throw new Error(
378
+ `Unexpected pty-incremental-output prompt: ${JSON.stringify(prompt)}.`,
379
+ );
380
+ }
381
+
382
+ const chunks = [
383
+ "## PTY incremental first\nPTY_INCREMENTAL_EARLY_SENTINEL\n\n## PTY incre",
384
+ "mental second\n",
385
+ "PTY_INCREMENTAL_SECOND_BODY\n\n## PTY incremental final\n",
386
+ "PTY_INCREMENTAL_FINAL_SENTINEL",
387
+ ] as const;
388
+ options.onTextDelta?.(chunks[0]);
389
+ await Bun.sleep(50);
390
+ options.signal.throwIfAborted();
391
+ options.onTextDelta?.(chunks[1]);
392
+ await Bun.sleep(700);
393
+ options.signal.throwIfAborted();
394
+ options.onTextDelta?.(chunks[2]);
395
+ await Bun.sleep(100);
396
+ options.signal.throwIfAborted();
397
+ options.onTextDelta?.(chunks[3]);
398
+ await Bun.sleep(100);
399
+ options.signal.throwIfAborted();
400
+ return textOutput(prepared, chunks.join(""));
401
+ }
402
+
403
+ private ptyResumeLayout(
404
+ input: ModelRequestInput,
405
+ prepared: PreparedModelRequest,
406
+ options: ModelRequestOptions,
407
+ ): ModelRequestOutput {
408
+ const prompt = lastUserMessage(input.messages);
409
+ if (/^PTY_RESUME_LAYOUT_PAD_\d+$/u.test(prompt)) {
410
+ return textOutput(prepared, `${prompt}_DONE`);
411
+ }
412
+ const match = /^PTY_RESUME_LAYOUT_([1-3])$/u.exec(prompt);
413
+ if (match === null) {
414
+ throw new Error(
415
+ `Unexpected pty-resume-layout prompt: ${JSON.stringify(prompt)}.`,
416
+ );
417
+ }
418
+
419
+ requireTools(input, ["Read"]);
420
+ const turn = Number(match[1]);
421
+ const targetToolCount = [8, 17, 4][turn - 1];
422
+ const finalLineCount = [31, 47, 8][turn - 1];
423
+ if (targetToolCount === undefined || finalLineCount === undefined) {
424
+ throw new Error(`Invalid pty-resume-layout turn: ${turn}.`);
425
+ }
426
+
427
+ const completedReads = toolMessagesAfterLastUser(input.messages).filter(
428
+ (message) => message.name === "Read",
429
+ ).length;
430
+ if (completedReads < targetToolCount) {
431
+ return toolCallOutput(prepared, options, "Read", {
432
+ file_path: "resume-layout.txt",
433
+ });
434
+ }
435
+
436
+ return textOutput(
437
+ prepared,
438
+ Array.from(
439
+ { length: finalLineCount },
440
+ (_, index) =>
441
+ `PTY_RESUME_LAYOUT_${turn}_FINAL_${String(index + 1).padStart(2, "0")}`,
442
+ ).join("\n"),
443
+ );
444
+ }
445
+
310
446
  private ptyCancelThenEcho(
311
447
  input: ModelRequestInput,
312
448
  prepared: PreparedModelRequest,
@@ -407,6 +543,60 @@ export class FakeModelClient implements ModelClient {
407
543
  return textOutput(prepared, "PTY_TOOL_CHAIN_DONE");
408
544
  }
409
545
 
546
+ private ptyTurnUndo(
547
+ input: ModelRequestInput,
548
+ prepared: PreparedModelRequest,
549
+ options: ModelRequestOptions,
550
+ ): ModelRequestOutput {
551
+ requireTools(input, ["Read", "Write", "Delete"]);
552
+ const prompt = lastUserMessage(input.messages);
553
+ if (prompt !== "PTY_UNDO_MUTATE") {
554
+ throw new Error(`Unexpected pty-turn-undo prompt: ${JSON.stringify(prompt)}.`);
555
+ }
556
+
557
+ const tools = toolMessagesAfterLastUser(input.messages);
558
+ const read = tools.find((message) => message.name === "Read");
559
+ if (read === undefined) {
560
+ return toolCallOutput(prepared, options, "Read", {
561
+ file_path: "pty-undo-modified.txt",
562
+ });
563
+ }
564
+ if (!read.content.includes("Read succeeded")) {
565
+ throw new Error("PTY undo Read tool did not succeed.");
566
+ }
567
+
568
+ const writes = tools.filter((message) => message.name === "Write");
569
+ if (writes.length === 0) {
570
+ return toolCallOutput(prepared, options, "Write", {
571
+ file_path: "pty-undo-modified.txt",
572
+ content: "after undo turn\n",
573
+ });
574
+ }
575
+ if (!writes[0]?.content.includes("Write succeeded")) {
576
+ throw new Error("PTY undo modifying Write did not succeed.");
577
+ }
578
+ if (writes.length === 1) {
579
+ return toolCallOutput(prepared, options, "Write", {
580
+ file_path: "pty-undo-created/nested.txt",
581
+ content: "created by undo turn\n",
582
+ });
583
+ }
584
+ if (!writes[1]?.content.includes("Write succeeded")) {
585
+ throw new Error("PTY undo creating Write did not succeed.");
586
+ }
587
+
588
+ const deletion = tools.find((message) => message.name === "Delete");
589
+ if (deletion === undefined) {
590
+ return toolCallOutput(prepared, options, "Delete", {
591
+ file_path: "pty-undo-deleted.bin",
592
+ });
593
+ }
594
+ if (!deletion.content.includes("Delete succeeded")) {
595
+ throw new Error("PTY undo Delete tool did not succeed.");
596
+ }
597
+ return textOutput(prepared, "PTY_UNDO_MUTATIONS_DONE");
598
+ }
599
+
410
600
  private ptyBackgroundTask(
411
601
  input: ModelRequestInput,
412
602
  prepared: PreparedModelRequest,
@@ -36,6 +36,7 @@ export type ModelMessageProtocol = {
36
36
 
37
37
  export type ModelRequestOptions = {
38
38
  signal: AbortSignal;
39
+ onTextDelta?: (content: string) => void;
39
40
  identity?: {
40
41
  iteration: IterationIdentity;
41
42
  runtimeSession: RuntimeSessionContext;
@@ -127,6 +128,8 @@ export type ProviderResponseErrorCode =
127
128
  | "reasoning_only_assistant"
128
129
  | "invalid_provider_response"
129
130
  | "invalid_provider_stream"
131
+ | "provider_rate_limited"
132
+ | "provider_unavailable"
130
133
  | "provider_request_error";
131
134
 
132
135
  export type ProviderResponseDiagnostics = {
@@ -11,7 +11,11 @@ import {
11
11
  import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
12
12
  import type { ModelContextBudget } from "./model-context-profile";
13
13
  import type { InputTokenEstimator } from "./input-token-estimator";
14
- import { ModelRequestMediaAggregateError, ProviderResponseError } from "./model-client";
14
+ import {
15
+ ModelRequestMediaAggregateError,
16
+ ProviderResponseError,
17
+ type ProviderResponseErrorCode,
18
+ } from "./model-client";
15
19
  import type {
16
20
  MaterializedModelRequest,
17
21
  ModelClient,
@@ -30,7 +34,7 @@ import {
30
34
  toOpenAIChatMessages,
31
35
  toOpenAIChatTools,
32
36
  } from "./openai-chat-mapping";
33
- import { accumulateOpenAIChatCompletionChunks } from "./openai-chat-stream";
37
+ import { OpenAIChatCompletionStreamAccumulator } from "./openai-chat-stream";
34
38
  import { MoonshotInputTokenEstimator } from "./moonshot-input-token-estimator";
35
39
  import { sha256, stableJsonStringify } from "./model-request-preflight";
36
40
 
@@ -86,7 +90,9 @@ export class OpenAIChatModelClient implements ModelClient {
86
90
  apiKey: options.apiKey,
87
91
  baseURL: options.baseURL,
88
92
  timeout: options.timeoutMs ?? OPENAI_CHAT_TIMEOUT_MS,
89
- ...(supportsImages ? { maxRetries: 0 } : {}),
93
+ // Retries are orchestrated by the agent loop so they surface as
94
+ // cancellable, observable runtime events instead of hidden SDK waits.
95
+ maxRetries: 0,
90
96
  fetch: options.fetch,
91
97
  });
92
98
  if (options.tokenEstimator !== undefined) {
@@ -235,10 +241,7 @@ export class OpenAIChatModelClient implements ModelClient {
235
241
  }
236
242
 
237
243
  const response = this.stream
238
- ? accumulateOpenAIChatCompletionChunks(
239
- await this.collectStreamingChunks(prepared, options.signal),
240
- { provider: this.provider, model: this.options.model },
241
- )
244
+ ? await this.requestStreaming(prepared, options)
242
245
  : await this.requestNonStreaming(prepared, options.signal);
243
246
 
244
247
  return fromOpenAIChatCompletion(response, {
@@ -248,21 +251,30 @@ export class OpenAIChatModelClient implements ModelClient {
248
251
  });
249
252
  }
250
253
 
251
- private async collectStreamingChunks(
254
+ private async requestStreaming(
252
255
  prepared: PreparedModelRequest,
253
- signal: AbortSignal,
254
- ): Promise<unknown[]> {
256
+ options: ModelRequestOptions,
257
+ ): Promise<Record<string, unknown>> {
258
+ const accumulator = new OpenAIChatCompletionStreamAccumulator({
259
+ provider: this.provider,
260
+ model: this.options.model,
261
+ });
255
262
  try {
256
263
  const stream = await this.client.chat.completions.create(
257
264
  prepared.payload as ChatCompletionCreateParamsStreaming,
258
- { signal },
265
+ { signal: options.signal },
259
266
  );
260
- const chunks: unknown[] = [];
261
267
  for await (const chunk of stream) {
262
- chunks.push(chunk);
268
+ const content = accumulator.push(chunk);
269
+ if (content !== undefined && content !== "") {
270
+ options.onTextDelta?.(content);
271
+ }
263
272
  }
264
- return chunks;
273
+ return accumulator.finish();
265
274
  } catch (error) {
275
+ if (error instanceof ProviderResponseError) {
276
+ throw error;
277
+ }
266
278
  throw sanitizedProviderError(error, this.provider, this.options.model);
267
279
  }
268
280
  }
@@ -531,9 +543,36 @@ function sanitizedProviderError(
531
543
  )
532
544
  .replace(/Bearer\s+[A-Za-z0-9._~+/-]+/giu, "Bearer [redacted]");
533
545
  return new ProviderResponseError(
534
- "provider_request_error",
546
+ providerErrorCode(error),
535
547
  sanitized,
536
548
  { provider, model },
537
549
  { cause: error },
538
550
  );
539
551
  }
552
+
553
+ function providerErrorCode(error: unknown): ProviderResponseErrorCode {
554
+ const status = providerErrorStatus(error);
555
+ if (status === 429) {
556
+ return "provider_rate_limited";
557
+ }
558
+ if (status === 500 || status === 502 || status === 503 || status === 504) {
559
+ return "provider_unavailable";
560
+ }
561
+ if (status === undefined && isProviderConnectionError(error)) {
562
+ return "provider_unavailable";
563
+ }
564
+ return "provider_request_error";
565
+ }
566
+
567
+ function providerErrorStatus(error: unknown): number | undefined {
568
+ if (typeof error !== "object" || error === null || !("status" in error)) {
569
+ return undefined;
570
+ }
571
+ const status = (error as { status?: unknown }).status;
572
+ return typeof status === "number" ? status : undefined;
573
+ }
574
+
575
+ function isProviderConnectionError(error: unknown): boolean {
576
+ // APIConnectionTimeoutError extends APIConnectionError in the SDK.
577
+ return error instanceof OpenAI.APIConnectionError;
578
+ }
@@ -14,62 +14,61 @@ type ToolCallAccumulator = {
14
14
  * chat.completion-shaped object so the result can be validated and mapped by
15
15
  * fromOpenAIChatCompletion exactly like a non-streaming response.
16
16
  */
17
- export function accumulateOpenAIChatCompletionChunks(
18
- chunks: readonly unknown[],
19
- options: ProviderContext,
20
- ): Record<string, unknown> {
21
- if (chunks.length === 0) {
22
- throw providerStreamError(options, "chunks", "must not be empty");
23
- }
17
+ export class OpenAIChatCompletionStreamAccumulator {
18
+ private chunkCount = 0;
19
+ private role: "assistant" | undefined;
20
+ private content: string | undefined;
21
+ private reasoningContent: string | undefined;
22
+ private finishReason: string | undefined;
23
+ private resolvedModel: string | undefined;
24
+ private usage: Record<string, unknown> | undefined;
25
+ private readonly toolCalls: ToolCallAccumulator[] = [];
24
26
 
25
- let role: "assistant" | undefined;
26
- let content: string | undefined;
27
- let reasoningContent: string | undefined;
28
- let finishReason: string | undefined;
29
- let resolvedModel: string | undefined;
30
- let usage: Record<string, unknown> | undefined;
31
- const toolCalls: ToolCallAccumulator[] = [];
27
+ constructor(private readonly options: ProviderContext) {}
32
28
 
33
- chunks.forEach((chunk, chunkIndex) => {
29
+ push(chunk: unknown): string | undefined {
30
+ const chunkIndex = this.chunkCount;
31
+ this.chunkCount += 1;
34
32
  const path = `chunk[${chunkIndex}]`;
35
- const record = requireRecord(chunk, path, options);
33
+ const record = requireRecord(chunk, path, this.options);
34
+ let chunkContent: string | undefined;
36
35
 
37
36
  if (record.model !== undefined && record.model !== null) {
38
37
  if (typeof record.model !== "string" || record.model.trim() === "") {
39
- throw providerStreamError(options, `${path}.model`, "must be a string");
38
+ throw providerStreamError(this.options, `${path}.model`, "must be a string");
40
39
  }
41
- if (resolvedModel !== undefined && resolvedModel !== record.model) {
40
+ if (this.resolvedModel !== undefined && this.resolvedModel !== record.model) {
42
41
  throw providerStreamError(
43
- options,
42
+ this.options,
44
43
  `${path}.model`,
45
- `conflicts with previously streamed model ${JSON.stringify(resolvedModel)}`,
44
+ `conflicts with previously streamed model ${JSON.stringify(this.resolvedModel)}`,
46
45
  );
47
46
  }
48
- resolvedModel = record.model;
47
+ this.resolvedModel = record.model;
49
48
  }
50
49
 
51
50
  if (record.usage !== undefined && record.usage !== null) {
52
- usage = requireRecord(record.usage, `${path}.usage`, options);
51
+ this.usage = requireRecord(record.usage, `${path}.usage`, this.options);
53
52
  }
54
53
 
55
54
  if (record.choices === undefined || record.choices === null) {
56
- return;
55
+ return chunkContent;
57
56
  }
58
57
  if (!Array.isArray(record.choices)) {
59
- throw providerStreamError(options, `${path}.choices`, "must be an array");
58
+ throw providerStreamError(this.options, `${path}.choices`, "must be an array");
60
59
  }
61
60
  // The usage-only final chunk from stream_options.include_usage has empty choices.
62
61
  for (const [choiceIndex, rawChoice] of record.choices.entries()) {
63
62
  const choicePath = `${path}.choices[${choiceIndex}]`;
64
- const choice = requireRecord(rawChoice, choicePath, options);
63
+ const choice = requireRecord(rawChoice, choicePath, this.options);
65
64
  if (choice.index !== 0) {
66
- throw providerStreamError(options, `${choicePath}.index`, "must be 0");
65
+ throw providerStreamError(this.options, `${choicePath}.index`, "must be 0");
67
66
  }
68
67
  if (typeof choice.finish_reason === "string") {
69
- finishReason = choice.finish_reason;
68
+ this.finishReason = choice.finish_reason;
70
69
  } else if (choice.finish_reason !== undefined && choice.finish_reason !== null) {
71
70
  throw providerStreamError(
72
- options,
71
+ this.options,
73
72
  `${choicePath}.finish_reason`,
74
73
  "must be a string or null",
75
74
  );
@@ -79,82 +78,106 @@ export function accumulateOpenAIChatCompletionChunks(
79
78
  continue;
80
79
  }
81
80
  const deltaPath = `${choicePath}.delta`;
82
- const delta = asRecord(choice.delta, deltaPath, options);
81
+ const delta = asRecord(choice.delta, deltaPath, this.options);
83
82
  if (delta.role !== undefined && delta.role !== null) {
84
83
  if (delta.role !== "assistant") {
85
84
  throw providerStreamError(
86
- options,
85
+ this.options,
87
86
  `${deltaPath}.role`,
88
87
  'must be "assistant"',
89
88
  );
90
89
  }
91
- role = delta.role;
90
+ this.role = delta.role;
92
91
  }
93
- content = appendOptionalText(
94
- content,
92
+ const contentDelta = appendOptionalText(
93
+ undefined,
95
94
  delta.content,
96
95
  `${deltaPath}.content`,
97
- options,
96
+ this.options,
98
97
  );
99
- reasoningContent = appendOptionalText(
100
- reasoningContent,
98
+ if (contentDelta !== undefined) {
99
+ this.content = (this.content ?? "") + contentDelta;
100
+ chunkContent = (chunkContent ?? "") + contentDelta;
101
+ }
102
+ this.reasoningContent = appendOptionalText(
103
+ this.reasoningContent,
101
104
  delta.reasoning_content,
102
105
  `${deltaPath}.reasoning_content`,
103
- options,
106
+ this.options,
104
107
  );
105
108
  if (delta.tool_calls === undefined || delta.tool_calls === null) {
106
109
  continue;
107
110
  }
108
111
  if (!Array.isArray(delta.tool_calls)) {
109
112
  throw providerStreamError(
110
- options,
113
+ this.options,
111
114
  `${deltaPath}.tool_calls`,
112
115
  "must be an array",
113
116
  );
114
117
  }
115
118
  for (const [fragmentIndex, rawFragment] of delta.tool_calls.entries()) {
116
119
  mergeToolCallFragment(
117
- toolCalls,
120
+ this.toolCalls,
118
121
  rawFragment,
119
122
  `${deltaPath}.tool_calls[${fragmentIndex}]`,
120
- options,
123
+ this.options,
121
124
  );
122
125
  }
123
126
  }
124
- });
127
+ return chunkContent;
128
+ }
125
129
 
126
- // Only fields the provider actually streamed are emitted; the strict
127
- // non-streaming mapper rejects a missing role, id, type, or name.
128
- const message: Record<string, unknown> = {
129
- ...(role === undefined ? {} : { role }),
130
- content: content ?? null,
131
- ...(reasoningContent === undefined ? {} : { reasoning_content: reasoningContent }),
132
- ...(toolCalls.length === 0
133
- ? {}
134
- : {
135
- tool_calls: toolCalls.map((call) => ({
136
- ...(call.id === undefined ? {} : { id: call.id }),
137
- ...(call.type === undefined ? {} : { type: call.type }),
138
- function: {
139
- ...(call.name === undefined ? {} : { name: call.name }),
140
- arguments: call.arguments,
141
- },
142
- })),
143
- }),
144
- };
130
+ finish(): Record<string, unknown> {
131
+ if (this.chunkCount === 0) {
132
+ throw providerStreamError(this.options, "chunks", "must not be empty");
133
+ }
145
134
 
146
- return {
147
- object: "chat.completion",
148
- choices: [
149
- {
150
- index: 0,
151
- message,
152
- finish_reason: finishReason ?? null,
153
- },
154
- ],
155
- ...(usage === undefined ? {} : { usage }),
156
- ...(resolvedModel === undefined ? {} : { model: resolvedModel }),
157
- };
135
+ // Only fields the provider actually streamed are emitted; the strict
136
+ // non-streaming mapper rejects a missing role, id, type, or name.
137
+ const message: Record<string, unknown> = {
138
+ ...(this.role === undefined ? {} : { role: this.role }),
139
+ content: this.content ?? null,
140
+ ...(this.reasoningContent === undefined
141
+ ? {}
142
+ : { reasoning_content: this.reasoningContent }),
143
+ ...(this.toolCalls.length === 0
144
+ ? {}
145
+ : {
146
+ tool_calls: this.toolCalls.map((call) => ({
147
+ ...(call.id === undefined ? {} : { id: call.id }),
148
+ ...(call.type === undefined ? {} : { type: call.type }),
149
+ function: {
150
+ ...(call.name === undefined ? {} : { name: call.name }),
151
+ arguments: call.arguments,
152
+ },
153
+ })),
154
+ }),
155
+ };
156
+
157
+ return {
158
+ object: "chat.completion",
159
+ choices: [
160
+ {
161
+ index: 0,
162
+ message,
163
+ finish_reason: this.finishReason ?? null,
164
+ },
165
+ ],
166
+ ...(this.usage === undefined ? {} : { usage: this.usage }),
167
+ ...(this.resolvedModel === undefined ? {} : { model: this.resolvedModel }),
168
+ };
169
+ }
170
+ }
171
+
172
+ export function accumulateOpenAIChatCompletionChunks(
173
+ chunks: readonly unknown[],
174
+ options: ProviderContext,
175
+ ): Record<string, unknown> {
176
+ const accumulator = new OpenAIChatCompletionStreamAccumulator(options);
177
+ for (const chunk of chunks) {
178
+ accumulator.push(chunk);
179
+ }
180
+ return accumulator.finish();
158
181
  }
159
182
 
160
183
  function mergeToolCallFragment(
@@ -1,6 +1,7 @@
1
1
  import type { ToolCall } from "../agent/types";
2
2
  import type {
3
3
  BashRawResult,
4
+ DeleteFileRawResult,
4
5
  EditFileRawResult,
5
6
  GenericToolRawResult,
6
7
  GlobRawResult,
@@ -42,6 +43,8 @@ export class ObservationBuilder {
42
43
  return { content: renderWriteObservation(input.raw) };
43
44
  case "edit":
44
45
  return { content: renderEditObservation(input.raw) };
46
+ case "delete":
47
+ return { content: renderDeleteObservation(input.raw) };
45
48
  case "bash":
46
49
  return { content: renderBashObservation(input.raw) };
47
50
  case "task_list":
@@ -308,6 +311,14 @@ function renderEditObservation(raw: EditFileRawResult): string {
308
311
  ].join("\n");
309
312
  }
310
313
 
314
+ function renderDeleteObservation(raw: DeleteFileRawResult): string {
315
+ if (!raw.ok) {
316
+ return `Delete failed for ${raw.filePath || "(unknown path)"}: ${raw.error ?? "Unknown error."}`;
317
+ }
318
+
319
+ return `Delete succeeded for ${raw.filePath}.`;
320
+ }
321
+
311
322
  function renderBashObservation(raw: BashRawResult): string {
312
323
  if (raw.taskId === "" && !raw.ok) {
313
324
  return `Bash failed: ${raw.error ?? "Unknown error."}`;
@@ -4838,6 +4838,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
4838
4838
  "read",
4839
4839
  "write",
4840
4840
  "edit",
4841
+ "delete",
4841
4842
  "glob",
4842
4843
  "grep",
4843
4844
  "bash",