tinker-agent 1.2.1 → 1.4.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 (39) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +217 -72
  3. package/bin/tinker.js +75 -25
  4. package/package.json +8 -3
  5. package/src/agent/loop.ts +115 -11
  6. package/src/agent/runtime-session.ts +34 -15
  7. package/src/cli/command-line.ts +291 -0
  8. package/src/cli/config.ts +131 -264
  9. package/src/cli/index.ts +33 -21
  10. package/src/cli/main.ts +213 -0
  11. package/src/cli/model-profiles.ts +143 -72
  12. package/src/cli/output.ts +113 -0
  13. package/src/cli/package-metadata.ts +36 -0
  14. package/src/cli/prompt-source.ts +229 -0
  15. package/src/cli/public-cli-contract.ts +69 -0
  16. package/src/cli/public-config-contract.ts +650 -0
  17. package/src/cli/run-runner.ts +17 -12
  18. package/src/cli/runner-dependencies.ts +100 -0
  19. package/src/cli/tui-runner.tsx +52 -49
  20. package/src/events/observation-text-log.ts +2 -0
  21. package/src/events/stdout-event-printer.ts +7 -1
  22. package/src/events/types.ts +27 -3
  23. package/src/mcp/mcp-manager.ts +2 -19
  24. package/src/mcp/mcp-tool-executor.ts +3 -4
  25. package/src/model/model-client.ts +29 -0
  26. package/src/model/model-context-profile.ts +0 -30
  27. package/src/model/openai-chat-mapping.ts +54 -15
  28. package/src/model/openai-chat-model-client.ts +46 -27
  29. package/src/model/openai-chat-stream.ts +6 -2
  30. package/src/tools/bash.ts +8 -25
  31. package/src/tools/grep.ts +9 -1
  32. package/src/tools/registry.ts +15 -1
  33. package/src/tools/ripgrep.ts +24 -27
  34. package/src/tools/web-fetch/index.ts +2 -15
  35. package/src/tui/app.tsx +3 -0
  36. package/src/tui/components/prompt-input.tsx +6 -3
  37. package/src/tui/event-store.ts +24 -7
  38. package/src/tui/slash-commands.ts +76 -24
  39. package/src/tui/workspace-file-search.ts +78 -71
@@ -11,7 +11,7 @@ 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 } from "./model-client";
14
+ import { ModelRequestMediaAggregateError, ProviderResponseError } from "./model-client";
15
15
  import type {
16
16
  MaterializedModelRequest,
17
17
  ModelClient,
@@ -234,17 +234,12 @@ export class OpenAIChatModelClient implements ModelClient {
234
234
  throw new Error("Image request must be materialized before provider dispatch.");
235
235
  }
236
236
 
237
- let response;
238
- try {
239
- response = this.stream
240
- ? await this.requestStreaming(prepared, options.signal)
241
- : await this.client.chat.completions.create(
242
- prepared.payload as ChatCompletionCreateParamsNonStreaming,
243
- { signal: options.signal },
244
- );
245
- } catch (error) {
246
- throw sanitizedProviderError(error);
247
- }
237
+ const response = this.stream
238
+ ? accumulateOpenAIChatCompletionChunks(
239
+ await this.collectStreamingChunks(prepared, options.signal),
240
+ { provider: this.provider, model: this.options.model },
241
+ )
242
+ : await this.requestNonStreaming(prepared, options.signal);
248
243
 
249
244
  return fromOpenAIChatCompletion(response, {
250
245
  identity: options.identity,
@@ -253,22 +248,37 @@ export class OpenAIChatModelClient implements ModelClient {
253
248
  });
254
249
  }
255
250
 
256
- private async requestStreaming(
251
+ private async collectStreamingChunks(
257
252
  prepared: PreparedModelRequest,
258
253
  signal: AbortSignal,
259
- ): Promise<Record<string, unknown>> {
260
- const stream = await this.client.chat.completions.create(
261
- prepared.payload as ChatCompletionCreateParamsStreaming,
262
- { signal },
263
- );
264
- const chunks: unknown[] = [];
265
- for await (const chunk of stream) {
266
- chunks.push(chunk);
254
+ ): Promise<unknown[]> {
255
+ try {
256
+ const stream = await this.client.chat.completions.create(
257
+ prepared.payload as ChatCompletionCreateParamsStreaming,
258
+ { signal },
259
+ );
260
+ const chunks: unknown[] = [];
261
+ for await (const chunk of stream) {
262
+ chunks.push(chunk);
263
+ }
264
+ return chunks;
265
+ } catch (error) {
266
+ throw sanitizedProviderError(error, this.provider, this.options.model);
267
+ }
268
+ }
269
+
270
+ private async requestNonStreaming(
271
+ prepared: PreparedModelRequest,
272
+ signal: AbortSignal,
273
+ ) {
274
+ try {
275
+ return await this.client.chat.completions.create(
276
+ prepared.payload as ChatCompletionCreateParamsNonStreaming,
277
+ { signal },
278
+ );
279
+ } catch (error) {
280
+ throw sanitizedProviderError(error, this.provider, this.options.model);
267
281
  }
268
- return accumulateOpenAIChatCompletionChunks(chunks, {
269
- provider: this.provider,
270
- model: this.options.model,
271
- });
272
282
  }
273
283
 
274
284
  private assistantReplaySegments(message: AssistantMessage): PreparedPromptSegment[] {
@@ -508,7 +518,11 @@ function deepFreeze<T>(value: T): T {
508
518
  return Object.freeze(value);
509
519
  }
510
520
 
511
- function sanitizedProviderError(error: unknown): Error {
521
+ function sanitizedProviderError(
522
+ error: unknown,
523
+ provider: string,
524
+ model: string,
525
+ ): ProviderResponseError {
512
526
  const message = error instanceof Error ? error.message : String(error);
513
527
  const sanitized = message
514
528
  .replace(
@@ -516,5 +530,10 @@ function sanitizedProviderError(error: unknown): Error {
516
530
  "[redacted image data]",
517
531
  )
518
532
  .replace(/Bearer\s+[A-Za-z0-9._~+/-]+/giu, "Bearer [redacted]");
519
- return new Error(sanitized, { cause: error });
533
+ return new ProviderResponseError(
534
+ "provider_request_error",
535
+ sanitized,
536
+ { provider, model },
537
+ { cause: error },
538
+ );
520
539
  }
@@ -1,3 +1,5 @@
1
+ import { ProviderResponseError } from "./model-client";
2
+
1
3
  type ProviderContext = { provider: string; model: string };
2
4
 
3
5
  type ToolCallAccumulator = {
@@ -265,8 +267,10 @@ function providerStreamError(
265
267
  options: ProviderContext,
266
268
  path: string,
267
269
  detail: string,
268
- ): Error {
269
- return new Error(
270
+ ): ProviderResponseError {
271
+ return new ProviderResponseError(
272
+ "invalid_provider_stream",
270
273
  `Invalid provider stream (provider=${options.provider}, model=${options.model}): ${path} ${detail}.`,
274
+ { provider: options.provider, model: options.model, path },
271
275
  );
272
276
  }
package/src/tools/bash.ts CHANGED
@@ -11,6 +11,7 @@ import { buildOutputSnapshotFromText } from "./task-output-snapshot";
11
11
  import { defineToolExecutor } from "./types";
12
12
  import type { TaskOutputSnapshot } from "./task-output";
13
13
  import type { BashRawResult, ToolExecutionContext, ToolExecutor } from "./types";
14
+ import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
14
15
 
15
16
  type BashArgs = {
16
17
  command: string;
@@ -27,25 +28,16 @@ export type BashToolOptions = {
27
28
  maxTimeoutMs?: number;
28
29
  };
29
30
 
30
- const defaultForegroundTimeoutMs = 5_000;
31
- const defaultMaxForegroundTimeoutMs = 600_000;
32
-
33
31
  export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
34
32
  const maxTimeoutMs =
35
- options.maxTimeoutMs ??
36
- parsePositiveInteger(
37
- process.env.TINKER_BASH_MAX_TIMEOUT_MS,
38
- defaultMaxForegroundTimeoutMs,
39
- );
33
+ options.maxTimeoutMs ?? DEFAULT_PUBLIC_TOOLING_CONFIG.bashMaxTimeoutMs;
40
34
  const defaultTimeoutMs =
41
- options.defaultTimeoutMs ??
42
- Math.min(
43
- parsePositiveInteger(
44
- process.env.TINKER_BASH_DEFAULT_TIMEOUT_MS,
45
- defaultForegroundTimeoutMs,
46
- ),
47
- maxTimeoutMs,
35
+ options.defaultTimeoutMs ?? DEFAULT_PUBLIC_TOOLING_CONFIG.bashDefaultTimeoutMs;
36
+ if (defaultTimeoutMs > maxTimeoutMs) {
37
+ throw new Error(
38
+ `Bash default timeout must not exceed max timeout; received ${defaultTimeoutMs} > ${maxTimeoutMs}.`,
48
39
  );
40
+ }
49
41
 
50
42
  return defineToolExecutor("bash", {
51
43
  definition: {
@@ -161,7 +153,7 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
161
153
 
162
154
  export function parseBashArgs(
163
155
  args: unknown,
164
- maxTimeoutMs = defaultMaxForegroundTimeoutMs,
156
+ maxTimeoutMs = DEFAULT_PUBLIC_TOOLING_CONFIG.bashMaxTimeoutMs,
165
157
  ): { ok: true; value: BashArgs } | { ok: false; error: string } {
166
158
  if (!isRecord(args)) {
167
159
  return { ok: false, error: "Bash arguments must be an object." };
@@ -401,15 +393,6 @@ function parseOptionalTimeout(
401
393
  return { ok: true, value };
402
394
  }
403
395
 
404
- function parsePositiveInteger(value: string | undefined, fallback: number): number {
405
- if (value === undefined) {
406
- return fallback;
407
- }
408
-
409
- const parsed = Number(value);
410
- return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
411
- }
412
-
413
396
  function lastPipelineCommandName(command: string): string | undefined {
414
397
  const segments = command.split("|");
415
398
  const lastSegment = segments.at(-1)?.trim();
package/src/tools/grep.ts CHANGED
@@ -32,6 +32,11 @@ type GrepArgs = {
32
32
  export type GrepToolOptions = {
33
33
  workspaceRoot: string;
34
34
  cwdState: CwdState;
35
+ ripgrep?: {
36
+ command?: string;
37
+ timeoutMs?: number;
38
+ maxBufferBytes?: number;
39
+ };
35
40
  };
36
41
 
37
42
  const ignoredDirectories = [
@@ -174,7 +179,10 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
174
179
  }
175
180
 
176
181
  const rgArgs = buildRipgrepArgs(input, mode, absoluteSearchPath);
177
- const rg = await ripGrep(rgArgs, { signal: context.signal });
182
+ const rg = await ripGrep(rgArgs, {
183
+ signal: context.signal,
184
+ ...options.ripgrep,
185
+ });
178
186
 
179
187
  if (!rg.ok) {
180
188
  return grepFailure({
@@ -31,6 +31,10 @@ import { ToolExecutionFatalError } from "./types";
31
31
  import type { SkillCatalogSnapshot } from "../skills/skill-loader";
32
32
  import type { SkillActivationCoordinator } from "../skills/skill-context";
33
33
  import { createSkillToolExecutor } from "../skills/skill-tool";
34
+ import {
35
+ DEFAULT_PUBLIC_TOOLING_CONFIG,
36
+ type PublicToolingConfig,
37
+ } from "../cli/public-config-contract";
34
38
 
35
39
  export class ToolRegistry {
36
40
  private readonly tools = new Map<string, ToolExecutor>();
@@ -128,10 +132,12 @@ export function createDefaultTooling(options: {
128
132
  taskStopGraceMs?: number;
129
133
  skillCatalog?: SkillCatalogSnapshot;
130
134
  skillCoordinator?: SkillActivationCoordinator;
135
+ toolingConfig?: PublicToolingConfig;
131
136
  }): DefaultTooling {
132
137
  const snapshots: FileSnapshotStore = new Map();
133
138
  const registry = new ToolRegistry();
134
139
  const runtimeSession = options.runtimeSession;
140
+ const toolingConfig = options.toolingConfig ?? DEFAULT_PUBLIC_TOOLING_CONFIG;
135
141
  const cwdState = createCwdState(options.workspaceRoot);
136
142
  const taskManager = new ShellTaskManager({
137
143
  workspaceRoot: options.workspaceRoot,
@@ -149,6 +155,11 @@ export function createDefaultTooling(options: {
149
155
  createGrepToolExecutor({
150
156
  workspaceRoot: options.workspaceRoot,
151
157
  cwdState,
158
+ ripgrep: {
159
+ command: toolingConfig.ripgrepPath,
160
+ timeoutMs: toolingConfig.grepTimeoutMs,
161
+ maxBufferBytes: toolingConfig.grepMaxBufferBytes,
162
+ },
152
163
  }),
153
164
  );
154
165
  registry.register(
@@ -192,13 +203,15 @@ export function createDefaultTooling(options: {
192
203
  workspaceRoot: options.workspaceRoot,
193
204
  cwdState,
194
205
  taskManager,
206
+ defaultTimeoutMs: toolingConfig.bashDefaultTimeoutMs,
207
+ maxTimeoutMs: toolingConfig.bashMaxTimeoutMs,
195
208
  }),
196
209
  );
197
210
  registry.register(createTaskListToolExecutor({ taskManager }));
198
211
  registry.register(createTaskOutputToolExecutor({ taskManager }));
199
212
  registry.register(createTaskStopToolExecutor({ taskManager }));
200
213
 
201
- const exaApiKey = options.exaApiKey ?? process.env.EXA_API_KEY;
214
+ const exaApiKey = options.exaApiKey ?? toolingConfig.exaApiKey;
202
215
  const hasExaKey = exaApiKey !== undefined && exaApiKey.trim() !== "";
203
216
 
204
217
  if (hasExaKey) {
@@ -209,6 +222,7 @@ export function createDefaultTooling(options: {
209
222
  createWebFetchToolExecutor({
210
223
  exaApiKey: hasExaKey ? exaApiKey : undefined,
211
224
  refiner: options.webFetchRefiner,
225
+ refineThreshold: toolingConfig.webFetchRefineThreshold,
212
226
  }),
213
227
  );
214
228
 
@@ -1,13 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
- import { rgPath } from "@vscode/ripgrep";
3
2
  import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
3
+ import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
4
4
 
5
5
  export const RIPGREP_MISSING_ERROR =
6
6
  "Tinker's bundled ripgrep executable is unavailable. Reinstall tinker-agent.";
7
7
 
8
- const defaultTimeoutMs = 20_000;
9
- const defaultMaxBufferBytes = 20_000_000;
10
-
11
8
  export type RipgrepResult = {
12
9
  ok: boolean;
13
10
  lines: string[];
@@ -18,12 +15,13 @@ export type RipgrepResult = {
18
15
 
19
16
  export type RipgrepOptions = {
20
17
  signal: AbortSignal;
18
+ command?: string;
21
19
  timeoutMs?: number;
22
20
  maxBufferBytes?: number;
23
21
  };
24
22
 
25
- export function findRipgrepCommand(): string {
26
- return process.env.TINKER_RIPGREP_PATH ?? rgPath;
23
+ export function findRipgrepCommand(command?: string): string {
24
+ return command ?? DEFAULT_PUBLIC_TOOLING_CONFIG.ripgrepPath;
27
25
  }
28
26
 
29
27
  export async function ripGrep(
@@ -31,21 +29,28 @@ export async function ripGrep(
31
29
  options: RipgrepOptions,
32
30
  ): Promise<RipgrepResult> {
33
31
  throwIfTurnCancelled(options.signal);
34
- const timeoutMs =
35
- options.timeoutMs ??
36
- parsePositiveInteger(process.env.TINKER_GREP_TIMEOUT_MS, defaultTimeoutMs);
32
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PUBLIC_TOOLING_CONFIG.grepTimeoutMs;
37
33
  const maxBufferBytes =
38
- options.maxBufferBytes ??
39
- parsePositiveInteger(
40
- process.env.TINKER_GREP_MAX_BUFFER_BYTES,
41
- defaultMaxBufferBytes,
42
- );
43
-
44
- const first = await runRipgrep(args, timeoutMs, maxBufferBytes, options.signal);
34
+ options.maxBufferBytes ?? DEFAULT_PUBLIC_TOOLING_CONFIG.grepMaxBufferBytes;
35
+ const command = findRipgrepCommand(options.command);
36
+
37
+ const first = await runRipgrep(
38
+ command,
39
+ args,
40
+ timeoutMs,
41
+ maxBufferBytes,
42
+ options.signal,
43
+ );
45
44
  if (first.retryWithSingleThread) {
46
45
  throwIfTurnCancelled(options.signal);
47
46
  return finalizeResult(
48
- await runRipgrep(["-j", "1", ...args], timeoutMs, maxBufferBytes, options.signal),
47
+ await runRipgrep(
48
+ command,
49
+ ["-j", "1", ...args],
50
+ timeoutMs,
51
+ maxBufferBytes,
52
+ options.signal,
53
+ ),
49
54
  );
50
55
  }
51
56
 
@@ -62,6 +67,7 @@ type RipgrepAttempt = {
62
67
  };
63
68
 
64
69
  function runRipgrep(
70
+ command: string,
65
71
  args: string[],
66
72
  timeoutMs: number,
67
73
  maxBufferBytes: number,
@@ -74,7 +80,7 @@ function runRipgrep(
74
80
  }
75
81
 
76
82
  execFile(
77
- findRipgrepCommand(),
83
+ command,
78
84
  args,
79
85
  { timeout: timeoutMs, maxBuffer: maxBufferBytes, signal },
80
86
  (error, stdout, stderr) => {
@@ -210,12 +216,3 @@ function isEagainError(
210
216
  stderr.includes("Resource temporarily unavailable")
211
217
  );
212
218
  }
213
-
214
- function parsePositiveInteger(value: string | undefined, fallback: number): number {
215
- if (value === undefined || value.trim() === "") {
216
- return fallback;
217
- }
218
-
219
- const parsed = Number(value);
220
- return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
221
- }
@@ -1,4 +1,5 @@
1
1
  import { cancellationError, throwIfTurnCancelled } from "../../agent/turn-cancellation";
2
+ import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../../cli/public-config-contract";
2
3
  import { defineToolExecutor } from "../types";
3
4
  import type { ToolExecutionContext, ToolExecutor, WebFetchRawResult } from "../types";
4
5
  import type { WebFetchBackend, WebFetchBackendResult, WebFetchRoute } from "./backend";
@@ -25,7 +26,6 @@ export type WebFetchToolOptions = {
25
26
  browserBackend?: WebFetchBackend | false;
26
27
  };
27
28
 
28
- export const WEB_FETCH_DEFAULT_REFINE_THRESHOLD = 2000;
29
29
  export const WEB_FETCH_DEFAULT_CACHE_TTL_MS = 15 * 60 * 1000;
30
30
 
31
31
  type CacheEntry = {
@@ -50,11 +50,7 @@ export function createWebFetchToolExecutor(
50
50
  : (options.browserBackend ??
51
51
  (isBrowserBackendAvailable() ? createBrowserWebFetchBackend() : undefined));
52
52
  const refineThreshold =
53
- options.refineThreshold ??
54
- parsePositiveInteger(
55
- process.env.TINKER_WEBFETCH_REFINE_THRESHOLD,
56
- WEB_FETCH_DEFAULT_REFINE_THRESHOLD,
57
- );
53
+ options.refineThreshold ?? DEFAULT_PUBLIC_TOOLING_CONFIG.webFetchRefineThreshold;
58
54
  const cacheTtlMs = options.cacheTtlMs ?? WEB_FETCH_DEFAULT_CACHE_TTL_MS;
59
55
  const cache = new Map<string, CacheEntry>();
60
56
 
@@ -284,15 +280,6 @@ function pruneCache(cache: Map<string, CacheEntry>): void {
284
280
  }
285
281
  }
286
282
 
287
- function parsePositiveInteger(value: string | undefined, fallback: number): number {
288
- if (value === undefined || value.trim() === "") {
289
- return fallback;
290
- }
291
-
292
- const parsed = Number(value);
293
- return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
294
- }
295
-
296
283
  function isRecord(value: unknown): value is Record<string, unknown> {
297
284
  return typeof value === "object" && value !== null && !Array.isArray(value);
298
285
  }
package/src/tui/app.tsx CHANGED
@@ -42,12 +42,14 @@ import type { SessionSummary } from "../session/session-catalog";
42
42
  import type { ModelProfile, ModelProfiles } from "../cli/model-profiles";
43
43
  import { loadViewFile, type ViewFile } from "./view-file";
44
44
  import { writeClipboardText } from "./clipboard";
45
+ import type { WorkspaceFileLister } from "./workspace-file-search";
45
46
 
46
47
  export type AppProps = {
47
48
  sessionController: TuiSessionController;
48
49
  readGitBranch?: (workspaceRoot: string) => Promise<string | undefined>;
49
50
  history?: PromptHistory;
50
51
  projectSlashCommands?: readonly ProjectSlashCommand[];
52
+ fileLister?: WorkspaceFileLister;
51
53
  profiles?: ModelProfiles;
52
54
  persistDefaultProfile?: (profileName: string) => Promise<void>;
53
55
  readViewFile?: (workspaceRoot: string, filePath: string) => Promise<ViewFile>;
@@ -647,6 +649,7 @@ export function App(props: AppProps) {
647
649
  isDisabled={isRunning || isSessionOperation || isCopying}
648
650
  history={props.history}
649
651
  commands={availableCommands}
652
+ fileLister={props.fileLister}
650
653
  importImage={binding.importImage}
651
654
  verifyImageAssets={binding.verifyImageAssets}
652
655
  onSubmit={onSubmit}
@@ -222,7 +222,8 @@ export function PromptInput(props: PromptInputProps) {
222
222
  if (mention === undefined || locked) {
223
223
  return;
224
224
  }
225
- if (props.importImage === undefined) {
225
+ const importImage = props.importImage;
226
+ if (importImage === undefined) {
226
227
  insertFilePath(filePath);
227
228
  return;
228
229
  }
@@ -236,8 +237,10 @@ export function PromptInput(props: PromptInputProps) {
236
237
  phase: { kind: "attaching", operationId },
237
238
  error: undefined,
238
239
  }));
239
- void props
240
- .importImage(filePath, controller.signal, captured.attachments.length + 1)
240
+ void Promise.resolve()
241
+ .then(() =>
242
+ importImage(filePath, controller.signal, captured.attachments.length + 1),
243
+ )
241
244
  .then((imported) => {
242
245
  setState((current) => {
243
246
  if (
@@ -153,13 +153,21 @@ export function reduceTuiProjection(
153
153
  }
154
154
  case "model.request.started":
155
155
  return updateActiveTurn(state, event, policy, (turn) =>
156
- appendTurnItem(turn, {
157
- id: `model-${event.iterationId}`,
158
- ref: modelRequestRef(event.iterationId),
159
- text: `model iteration ${event.iterationNumber}`,
160
- status: "running",
161
- }),
156
+ event.data.attemptNumber === 1
157
+ ? appendTurnItem(turn, {
158
+ id: `model-${event.iterationId}`,
159
+ ref: modelRequestRef(event.iterationId),
160
+ text: `model iteration ${event.iterationNumber}`,
161
+ status: "running",
162
+ })
163
+ : updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
164
+ ...item,
165
+ text: `model iteration ${event.iterationNumber} · retrying`,
166
+ status: "running",
167
+ })),
162
168
  );
169
+ case "model.request.failed":
170
+ return state;
163
171
  case "model.request.finished":
164
172
  return updateActiveTurn(state, event, policy, (turn) =>
165
173
  updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
@@ -333,7 +341,7 @@ export function reduceTuiProjection(
333
341
  }
334
342
  case "turn.failed": {
335
343
  const active = requireActiveTurn(state, event);
336
- const failed = appendTurnItem(active, {
344
+ const failed = appendTurnItem(markRunningItemsFailed(active), {
337
345
  id: `turn-${active.turnId}-failed-${event.eventSequence}`,
338
346
  label: "error",
339
347
  text: event.data.error,
@@ -467,6 +475,15 @@ function appendTurnItem(
467
475
  return { ...turn, items: [...turn.items, item] };
468
476
  }
469
477
 
478
+ function markRunningItemsFailed(turn: TuiTurnProjection): TuiTurnProjection {
479
+ return {
480
+ ...turn,
481
+ items: turn.items.map((item) =>
482
+ item.status === "running" ? { ...item, status: "failed" } : item,
483
+ ),
484
+ };
485
+ }
486
+
470
487
  function updateTurnItem(
471
488
  turn: TuiTurnProjection,
472
489
  ref: string,