tinker-agent 1.2.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.3.0] - 2026-07-22
9
+
10
+ ### Added
11
+
12
+ - Recover automatically when an OpenAI-compatible provider completes a response
13
+ with reasoning only and no final answer or tool call: Tinker retries that exact
14
+ request once without adding the invalid response to session history.
15
+
16
+ ## [1.2.1] - 2026-07-22
17
+
18
+ ### Fixed
19
+
20
+ - Ignore unrecognized top-level Agent Skill frontmatter fields while continuing to
21
+ validate Tinker's supported fields, so harmless extensions such as `version` no
22
+ longer prevent the CLI from starting. Unknown fields remain available in the
23
+ original `SKILL.md` content when the skill is activated.
24
+
8
25
  ## [1.2.0] - 2026-07-21
9
26
 
10
27
  ### Added
@@ -31,6 +48,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
31
48
  - First formal npm release under the `tinker-agent` package name with the `tinker`
32
49
  executable.
33
50
 
34
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.2.0...HEAD
51
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.3.0...HEAD
52
+ [1.3.0]: https://github.com/ishowshao/tinker/releases/tag/v1.3.0
53
+ [1.2.1]: https://github.com/ishowshao/tinker/releases/tag/v1.2.1
35
54
  [1.2.0]: https://github.com/ishowshao/tinker/releases/tag/v1.2.0
36
55
  [1.1.0]: https://www.npmjs.com/package/tinker-agent/v/1.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/agent/loop.ts CHANGED
@@ -2,7 +2,9 @@ import {
2
2
  materializeModelRequest,
3
3
  type MaterializedModelRequest,
4
4
  type ModelClient,
5
+ type ModelRequestOutput,
5
6
  type PreparedModelRequest,
7
+ ProviderResponseError,
6
8
  } from "../model/model-client";
7
9
  import type { ImageAssetStore } from "../image/image-asset-store";
8
10
  import {
@@ -83,6 +85,8 @@ export class FatalAgentTurnError extends Error {
83
85
  }
84
86
  }
85
87
 
88
+ const MODEL_REQUEST_MAX_ATTEMPTS = 2 as const;
89
+
86
90
  export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
87
91
  let lastIteration: IterationIdentity | undefined;
88
92
  const committedPrefixAuditor =
@@ -202,21 +206,15 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
202
206
  await input.runtimeSession.append({
203
207
  type: "model.request.started",
204
208
  ...iteration,
205
- data: {},
209
+ data: {
210
+ attemptNumber: 1,
211
+ maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
212
+ },
206
213
  });
207
214
 
208
- let modelOutput;
209
215
  try {
210
216
  throwIfTurnCancelled(input.signal);
211
217
  input.runtimeSession.prepareModelDispatch?.({ iteration, built });
212
- modelOutput = await input.model.request(request, {
213
- signal: input.signal,
214
- identity: {
215
- iteration,
216
- runtimeSession: input.runtimeSession,
217
- },
218
- });
219
- throwIfTurnCancelled(input.signal);
220
218
  } catch (error) {
221
219
  if (input.signal.aborted) {
222
220
  return cancelledResult(
@@ -228,6 +226,82 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
228
226
  return failedResult(error, iteration);
229
227
  }
230
228
 
229
+ const requestOptions = {
230
+ signal: input.signal,
231
+ identity: {
232
+ iteration,
233
+ runtimeSession: input.runtimeSession,
234
+ },
235
+ };
236
+ let modelOutput: ModelRequestOutput | undefined;
237
+ let successfulAttempt: 1 | 2 | undefined;
238
+ for (const attemptNumber of [1, 2] as const) {
239
+ if (input.signal.aborted) {
240
+ return cancelledResult(
241
+ cancellation(input.signal, iteration, "model_request"),
242
+ iteration,
243
+ );
244
+ }
245
+ if (attemptNumber === 2) {
246
+ await input.runtimeSession.append({
247
+ type: "model.request.started",
248
+ ...iteration,
249
+ data: {
250
+ attemptNumber,
251
+ maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
252
+ },
253
+ });
254
+ }
255
+
256
+ try {
257
+ throwIfTurnCancelled(input.signal);
258
+ modelOutput = await input.model.request(request, requestOptions);
259
+ throwIfTurnCancelled(input.signal);
260
+ successfulAttempt = attemptNumber;
261
+ break;
262
+ } catch (error) {
263
+ if (input.signal.aborted) {
264
+ return cancelledResult(
265
+ cancellation(input.signal, iteration, "model_request"),
266
+ iteration,
267
+ );
268
+ }
269
+
270
+ const reasoningOnly = isReasoningOnlyProviderError(error);
271
+ const retryDisposition = reasoningOnly
272
+ ? attemptNumber === 1
273
+ ? "scheduled"
274
+ : "exhausted"
275
+ : "not_retryable";
276
+ await input.runtimeSession.append({
277
+ type: "model.request.failed",
278
+ ...iteration,
279
+ data: modelRequestFailureData({
280
+ error,
281
+ request,
282
+ attemptNumber,
283
+ retryDisposition,
284
+ }),
285
+ });
286
+
287
+ if (retryDisposition === "scheduled") {
288
+ continue;
289
+ }
290
+ if (retryDisposition === "exhausted") {
291
+ return failedResult(
292
+ new Error(
293
+ `Provider returned reasoning without final text or tool calls in both attempts (provider=${request.provider}, model=${request.model}).`,
294
+ ),
295
+ iteration,
296
+ );
297
+ }
298
+ return failedResult(error, iteration);
299
+ }
300
+ }
301
+ if (modelOutput === undefined || successfulAttempt === undefined) {
302
+ throw new Error("Model request attempt state completed without an output.");
303
+ }
304
+
231
305
  try {
232
306
  input.ledger.appendAssistant({
233
307
  iteration,
@@ -245,7 +319,11 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
245
319
  await input.runtimeSession.append({
246
320
  type: "model.request.finished",
247
321
  ...iteration,
248
- data: { output: modelOutput },
322
+ data: {
323
+ attemptNumber: successfulAttempt,
324
+ maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
325
+ output: modelOutput,
326
+ },
249
327
  });
250
328
  const measured = input.contextMeter.recordProviderUsage(request, modelOutput);
251
329
  await input.runtimeSession.append({
@@ -617,6 +695,32 @@ function failedResult(
617
695
  };
618
696
  }
619
697
 
698
+ function isReasoningOnlyProviderError(error: unknown): error is ProviderResponseError {
699
+ return (
700
+ error instanceof ProviderResponseError && error.code === "reasoning_only_assistant"
701
+ );
702
+ }
703
+
704
+ function modelRequestFailureData(input: {
705
+ error: unknown;
706
+ request: PreparedModelRequest;
707
+ attemptNumber: 1 | 2;
708
+ retryDisposition: "scheduled" | "not_retryable" | "exhausted";
709
+ }) {
710
+ const providerError =
711
+ input.error instanceof ProviderResponseError ? input.error : undefined;
712
+ return {
713
+ attemptNumber: input.attemptNumber,
714
+ maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
715
+ code: providerError?.code ?? ("provider_request_error" as const),
716
+ retryDisposition: input.retryDisposition,
717
+ provider: input.request.provider,
718
+ model: input.request.model,
719
+ error: errorMessage(input.error),
720
+ ...(providerError === undefined ? {} : { diagnostics: providerError.diagnostics }),
721
+ };
722
+ }
723
+
620
724
  function errorMessage(error: unknown): string {
621
725
  return error instanceof Error ? error.message : String(error);
622
726
  }
@@ -45,6 +45,8 @@ export function renderObservationLogEvent(event: AgentEvent): string | undefined
45
45
  return renderTurnCancelled(event);
46
46
  case "turn.failed":
47
47
  return renderTurnFailed(event.data.error);
48
+ case "model.request.failed":
49
+ return undefined;
48
50
  default:
49
51
  return undefined;
50
52
  }
@@ -43,7 +43,13 @@ export class StdoutEventPrinter implements EventSink {
43
43
  );
44
44
  break;
45
45
  case "model.request.started":
46
- this.stdout.write(`model.request.started iteration=${event.iterationNumber}\n`);
46
+ if (event.data.attemptNumber === 1) {
47
+ this.stdout.write(
48
+ `model.request.started iteration=${event.iterationNumber}\n`,
49
+ );
50
+ }
51
+ break;
52
+ case "model.request.failed":
47
53
  break;
48
54
  case "model.request.finished":
49
55
  this.stdout.write(
@@ -13,7 +13,11 @@ import type {
13
13
  ToolCallId,
14
14
  TurnId,
15
15
  } from "../ids/runtime-id";
16
- import type { ModelRequestOutput } from "../model/model-client";
16
+ import type {
17
+ ModelRequestOutput,
18
+ ProviderResponseDiagnostics,
19
+ ProviderResponseErrorCode,
20
+ } from "../model/model-client";
17
21
  import type {
18
22
  ModelContextBudget,
19
23
  ModelContextProfile,
@@ -280,6 +284,22 @@ export type TurnFinishedData = {
280
284
  messageCount: number;
281
285
  };
282
286
 
287
+ export type ModelRequestAttemptData = {
288
+ attemptNumber: 1 | 2;
289
+ maxAttempts: 2;
290
+ };
291
+
292
+ export type ModelRequestFailureCode = ProviderResponseErrorCode;
293
+
294
+ export type ModelRequestFailedData = ModelRequestAttemptData & {
295
+ code: ModelRequestFailureCode;
296
+ retryDisposition: "scheduled" | "not_retryable" | "exhausted";
297
+ provider: string;
298
+ model: string;
299
+ error: string;
300
+ diagnostics?: ProviderResponseDiagnostics;
301
+ };
302
+
283
303
  export type AgentEventDataMap = {
284
304
  "session.started": SessionStartedData;
285
305
  "session.resumed": SessionResumedData;
@@ -290,8 +310,11 @@ export type AgentEventDataMap = {
290
310
  "turn.failed": { error: string };
291
311
  "turn.cancelled": { cancellation: TurnCancellation };
292
312
  "agent.iteration.started": { iterationNumber: number };
293
- "model.request.started": Record<string, never>;
294
- "model.request.finished": { output: ModelRequestOutput };
313
+ "model.request.started": ModelRequestAttemptData;
314
+ "model.request.failed": ModelRequestFailedData;
315
+ "model.request.finished": ModelRequestAttemptData & {
316
+ output: ModelRequestOutput;
317
+ };
295
318
  "context.usage.updated": ContextUsageUpdatedData;
296
319
  "context.revision.started": ContextRevisionStartedData;
297
320
  "context.revision.finished": ContextRevisionFinishedData;
@@ -385,6 +408,7 @@ export type AgentEventInput =
385
408
  | IterationEventInput<
386
409
  | "agent.iteration.started"
387
410
  | "model.request.started"
411
+ | "model.request.failed"
388
412
  | "model.request.finished"
389
413
  | "context.usage.updated"
390
414
  | "context.shadow.planned"
@@ -122,3 +122,32 @@ export type ModelUsage = {
122
122
  promptCacheMissTokens?: number;
123
123
  reasoningTokens?: number;
124
124
  };
125
+
126
+ export type ProviderResponseErrorCode =
127
+ | "reasoning_only_assistant"
128
+ | "invalid_provider_response"
129
+ | "invalid_provider_stream"
130
+ | "provider_request_error";
131
+
132
+ export type ProviderResponseDiagnostics = {
133
+ provider: string;
134
+ model: string;
135
+ path?: string;
136
+ finishReason?: string;
137
+ contentChars?: number;
138
+ reasoningChars?: number;
139
+ toolCallCount?: number;
140
+ usage?: ModelUsage;
141
+ };
142
+
143
+ export class ProviderResponseError extends Error {
144
+ constructor(
145
+ readonly code: ProviderResponseErrorCode,
146
+ message: string,
147
+ readonly diagnostics: ProviderResponseDiagnostics,
148
+ options?: ErrorOptions,
149
+ ) {
150
+ super(message, options);
151
+ this.name = "ProviderResponseError";
152
+ }
153
+ }
@@ -5,7 +5,12 @@ import type {
5
5
  UserMessage,
6
6
  } from "../agent/types";
7
7
  import type { RuntimeSessionContext } from "../agent/runtime-session";
8
- import type { ModelRequestOutput, ModelUsage } from "./model-client";
8
+ import {
9
+ ProviderResponseError,
10
+ type ModelRequestOutput,
11
+ type ModelUsage,
12
+ type ProviderResponseDiagnostics,
13
+ } from "./model-client";
9
14
  import type { ToolDefinition } from "../tools/types";
10
15
  import type {
11
16
  ChatCompletionAssistantMessageParam,
@@ -178,21 +183,45 @@ export function fromOpenAIChatCompletion(
178
183
  "choices[0].message.content",
179
184
  options,
180
185
  );
186
+ const finishReason = optionalString(
187
+ choice.finish_reason,
188
+ "choices[0].finish_reason",
189
+ options,
190
+ );
191
+ const usage = parseUsage(completion.usage, options);
192
+ const reasoningContent = normalizeContent(
193
+ message.reasoning_content,
194
+ "choices[0].message.reasoning_content",
195
+ options,
196
+ );
197
+ const diagnostics = responseDiagnostics(options, {
198
+ path: "choices[0].message",
199
+ finishReason,
200
+ contentChars: content?.length ?? 0,
201
+ reasoningChars: reasoningContent?.length ?? 0,
202
+ toolCallCount: rawToolCalls.length,
203
+ usage,
204
+ });
181
205
  if ((content === null || content.trim() === "") && rawToolCalls.length === 0) {
206
+ if (
207
+ finishReason === "stop" &&
208
+ reasoningContent !== null &&
209
+ reasoningContent.trim() !== ""
210
+ ) {
211
+ throw new ProviderResponseError(
212
+ "reasoning_only_assistant",
213
+ `Invalid provider response (provider=${options.provider}, model=${options.model}): choices[0].message contains reasoning but neither non-empty final text nor tool calls.`,
214
+ diagnostics,
215
+ );
216
+ }
182
217
  throw providerResponseError(
183
218
  options,
184
219
  "choices[0].message",
185
220
  "has neither non-empty text nor tool calls",
221
+ diagnostics,
186
222
  );
187
223
  }
188
224
 
189
- const finishReason = optionalString(
190
- choice.finish_reason,
191
- "choices[0].finish_reason",
192
- options,
193
- );
194
- const usage = parseUsage(completion.usage, options);
195
-
196
225
  if (rawToolCalls.length > 0 && options.identity === undefined) {
197
226
  throw providerResponseError(
198
227
  options,
@@ -208,11 +237,7 @@ export function fromOpenAIChatCompletion(
208
237
  message: {
209
238
  role: "assistant",
210
239
  content,
211
- reasoningContent: normalizeContent(
212
- message.reasoning_content,
213
- "choices[0].message.reasoning_content",
214
- options,
215
- ),
240
+ reasoningContent,
216
241
  toolCalls: toolCalls.length === 0 ? undefined : toolCalls,
217
242
  },
218
243
  finishReason,
@@ -513,8 +538,22 @@ function providerResponseError(
513
538
  options: { provider: string; model: string },
514
539
  path: string,
515
540
  detail: string,
516
- ): Error {
517
- return new Error(
541
+ diagnostics: ProviderResponseDiagnostics = responseDiagnostics(options, { path }),
542
+ ): ProviderResponseError {
543
+ return new ProviderResponseError(
544
+ "invalid_provider_response",
518
545
  `Invalid provider response (provider=${options.provider}, model=${options.model}): ${path} ${detail}.`,
546
+ { ...diagnostics, path },
519
547
  );
520
548
  }
549
+
550
+ function responseDiagnostics(
551
+ options: { provider: string; model: string },
552
+ diagnostics: Omit<ProviderResponseDiagnostics, "provider" | "model">,
553
+ ): ProviderResponseDiagnostics {
554
+ return {
555
+ provider: options.provider,
556
+ model: options.model,
557
+ ...diagnostics,
558
+ };
559
+ }
@@ -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
  }
@@ -86,15 +86,6 @@ type ScopeRoot = {
86
86
  canonicalRoot: string;
87
87
  };
88
88
 
89
- const SKILL_FIELDS = Object.freeze([
90
- "name",
91
- "description",
92
- "license",
93
- "compatibility",
94
- "metadata",
95
- "allowed-tools",
96
- ] as const);
97
-
98
89
  const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
99
90
 
100
91
  export async function loadSkillCatalog(input: {
@@ -519,17 +510,6 @@ function parseSkillDocument(
519
510
  if (!isPlainRecord(value)) {
520
511
  throw frontmatterError(scope, directoryName, "root must be a plain mapping");
521
512
  }
522
- const unknown = Object.keys(value).filter(
523
- (key) => !(SKILL_FIELDS as readonly string[]).includes(key),
524
- );
525
- if (unknown.length > 0) {
526
- throw fieldError(
527
- scope,
528
- directoryName,
529
- `contains unknown field ${JSON.stringify(unknown.sort(compareText)[0])}`,
530
- );
531
- }
532
-
533
513
  const name = requireStringField(value, "name", scope, directoryName);
534
514
  const description = requireStringField(value, "description", scope, directoryName);
535
515
  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,