diff --git a/crates/product/ironclaw_webui/CONTRACT.md b/crates/product/ironclaw_webui/CONTRACT.md index 62dfd95fb9a..003dd9a490b 100644 --- a/crates/product/ironclaw_webui/CONTRACT.md +++ b/crates/product/ironclaw_webui/CONTRACT.md @@ -275,8 +275,11 @@ route (tenant/user-scoped tool-approval settings), not an operator route. assistant item instead of replacing an earlier utterance from the same run. The SPA marks the prior phase as no longer streaming, retains it as intermediate text, and upgrades only the latest phase when the durable final - reply arrives. These phase items remain live-projection/session state rather - than durable transcript records. + reply arrives. If the run fails before a final reply, the SPA removes the + still-streaming unfinished phase and shows the typed, actionable run failure + instead, while preserving earlier completed phases; a late projection frame + cannot restore the unfinished draft. These phase items remain + live-projection/session state rather than durable transcript records. - Active assistant phases render accumulated Markdown through Streamdown's incomplete-Markdown-aware streaming mode. The product projection boundary publishes cumulative text at most once per 16 ms browser-paint interval, diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts index 92913637d5e..af319dc55a6 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts @@ -1466,6 +1466,7 @@ registerPack("ar", { "chat.failure.request": "فشل الطلب قبل أن يتم إرساله.", "chat.failure.requestDetail": "فشل الطلب: {detail}.", "chat.failure.runCategory": "فشل التشغيل: {detail}.", + "chat.failure.noProgress": "توقف التشغيل لأنه كرر العمل دون إحراز تقدم. حاول مرة أخرى بتعليمات أوضح أو نطاق أضيق.", "chat.failure.recoveryRequired": "ينتظر التشغيل الاسترداد؛ أبلغت الواجهة الخلفية عن `recovery_required`.", "chat.failure.run": "فشل التشغيل قبل إنتاج رد.", "chat.failure.streamRetryable": "واجه تدفق المحادثة خطأً قابلاً لإعادة المحاولة: {detail}.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts index d3ce0e06030..5803e751d9f 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts @@ -1466,6 +1466,7 @@ registerPack("de", { "chat.failure.request": "Die Anfrage ist fehlgeschlagen, bevor sie gesendet werden konnte.", "chat.failure.requestDetail": "Die Anfrage ist fehlgeschlagen: {detail}.", "chat.failure.runCategory": "Die Ausführung ist fehlgeschlagen: {detail}.", + "chat.failure.noProgress": "Die Ausführung wurde beendet, weil Arbeit ohne Fortschritt wiederholt wurde. Versuche es mit einer klareren Anweisung oder einem engeren Umfang erneut.", "chat.failure.recoveryRequired": "Die Ausführung wartet auf Wiederherstellung; das Backend meldete `recovery_required`.", "chat.failure.run": "Die Ausführung ist fehlgeschlagen, bevor eine Antwort erzeugt wurde.", "chat.failure.streamRetryable": "Im Chat-Stream ist ein wiederholbarer Fehler aufgetreten: {detail}.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/en.ts b/crates/product/ironclaw_webui/frontend/src/i18n/en.ts index 4a0f570989b..01f14511a8a 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/en.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/en.ts @@ -1519,6 +1519,7 @@ registerPack("en", { "chat.failure.request": "The request failed before it could be sent.", "chat.failure.requestDetail": "The request failed: {detail}.", "chat.failure.runCategory": "The run failed: {detail}.", + "chat.failure.noProgress": "The run stopped because it repeated work without making progress. Retry with a clearer instruction or narrower scope.", "chat.failure.recoveryRequired": "The run is awaiting recovery — backend reported `recovery_required`.", "chat.failure.run": "The run failed before producing a reply.", "chat.failure.streamRetryable": "The chat stream hit a retryable error: {detail}.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts index aef565f6fdc..286a200c94b 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts @@ -1467,6 +1467,7 @@ registerPack("es", { "chat.failure.request": "La solicitud falló antes de poder enviarse.", "chat.failure.requestDetail": "La solicitud falló: {detail}.", "chat.failure.runCategory": "La ejecución falló: {detail}.", + "chat.failure.noProgress": "La ejecución se detuvo porque repitió trabajo sin avanzar. Inténtalo de nuevo con una instrucción más clara o un alcance más limitado.", "chat.failure.recoveryRequired": "La ejecución está esperando recuperación; el backend informó `recovery_required`.", "chat.failure.run": "La ejecución falló antes de generar una respuesta.", "chat.failure.streamRetryable": "El flujo del chat encontró un error reintentable: {detail}.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts index eaf291572ad..a3bfc0a1d49 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts @@ -1466,6 +1466,7 @@ registerPack("fr", { "chat.failure.request": "La requête a échoué avant de pouvoir être envoyée.", "chat.failure.requestDetail": "La requête a échoué : {detail}.", "chat.failure.runCategory": "L'exécution a échoué : {detail}.", + "chat.failure.noProgress": "L'exécution s'est arrêtée car elle répétait des actions sans progresser. Réessayez avec une instruction plus claire ou un périmètre plus restreint.", "chat.failure.recoveryRequired": "L'exécution attend une récupération ; le backend a signalé `recovery_required`.", "chat.failure.run": "L'exécution a échoué avant de produire une réponse.", "chat.failure.streamRetryable": "Le flux du chat a rencontré une erreur réessayable : {detail}.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts index 440ac877e01..e14f3309c27 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts @@ -1466,6 +1466,7 @@ registerPack("hi", { "chat.failure.request": "अनुरोध भेजे जाने से पहले विफल हो गया।", "chat.failure.requestDetail": "अनुरोध विफल हुआ: {detail}।", "chat.failure.runCategory": "रन विफल हुआ: {detail}।", + "chat.failure.noProgress": "रन रुक गया क्योंकि काम बिना प्रगति के दोहराया जा रहा था। अधिक स्पष्ट निर्देश या सीमित दायरे के साथ फिर प्रयास करें।", "chat.failure.recoveryRequired": "रन रिकवरी की प्रतीक्षा कर रहा है; बैकएंड ने `recovery_required` रिपोर्ट किया।", "chat.failure.run": "उत्तर बनने से पहले रन विफल हो गया।", "chat.failure.streamRetryable": "चैट स्ट्रीम में फिर से प्रयास योग्य त्रुटि आई: {detail}।", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts index 5de5860d71d..b1ec5aae13d 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts @@ -1466,6 +1466,7 @@ registerPack("ja", { "chat.failure.request": "リクエストは送信前に失敗しました。", "chat.failure.requestDetail": "リクエストに失敗しました: {detail}。", "chat.failure.runCategory": "実行に失敗しました: {detail}。", + "chat.failure.noProgress": "進展のない作業が繰り返されたため、実行を停止しました。より明確な指示または狭い範囲で再試行してください。", "chat.failure.recoveryRequired": "実行は復旧待ちです。バックエンドから `recovery_required` が報告されました。", "chat.failure.run": "返信を生成する前に実行が失敗しました。", "chat.failure.streamRetryable": "チャットストリームで再試行可能なエラーが発生しました: {detail}。", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts index 7d3d49811ff..538b9d96895 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts @@ -1466,6 +1466,7 @@ registerPack("ko", { "chat.failure.request": "요청을 보내기 전에 실패했습니다.", "chat.failure.requestDetail": "요청에 실패했습니다: {detail}.", "chat.failure.runCategory": "실행에 실패했습니다: {detail}.", + "chat.failure.noProgress": "진전 없이 작업이 반복되어 실행이 중지되었습니다. 더 명확한 지시나 좁은 범위로 다시 시도하세요.", "chat.failure.recoveryRequired": "실행이 복구를 기다리고 있습니다. 백엔드에서 `recovery_required`를 보고했습니다.", "chat.failure.run": "응답을 생성하기 전에 실행이 실패했습니다.", "chat.failure.streamRetryable": "채팅 스트림에 재시도 가능한 오류가 발생했습니다: {detail}.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts index 0a749b4c740..968f1982ce4 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts @@ -1466,6 +1466,7 @@ registerPack("pt-BR", { "chat.failure.request": "A solicitação falhou antes de ser enviada.", "chat.failure.requestDetail": "A solicitação falhou: {detail}.", "chat.failure.runCategory": "A execução falhou: {detail}.", + "chat.failure.noProgress": "A execução parou porque repetiu trabalho sem avançar. Tente novamente com uma instrução mais clara ou um escopo mais restrito.", "chat.failure.recoveryRequired": "A execução está aguardando recuperação; o backend informou `recovery_required`.", "chat.failure.run": "A execução falhou antes de produzir uma resposta.", "chat.failure.streamRetryable": "O fluxo do chat encontrou um erro que permite nova tentativa: {detail}.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts index ceff8b3ebab..df704705d64 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts @@ -1466,6 +1466,7 @@ registerPack("uk", { "chat.failure.request": "Не вдалося надіслати запит.", "chat.failure.requestDetail": "Запит завершився помилкою: {detail}.", "chat.failure.runCategory": "Виконання завершилося помилкою: {detail}.", + "chat.failure.noProgress": "Виконання зупинено, оскільки робота повторювалася без прогресу. Повторіть спробу з чіткішою вказівкою або вужчим обсягом.", "chat.failure.recoveryRequired": "Виконання очікує відновлення; сервер повідомив `recovery_required`.", "chat.failure.run": "Виконання завершилося помилкою до створення відповіді.", "chat.failure.streamRetryable": "У потоці чату сталася помилка, яку можна повторити: {detail}.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts index fb21611107e..452fa540ed0 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts @@ -1464,6 +1464,7 @@ registerPack("zh-CN", { "chat.failure.request": "请求在发送前失败。", "chat.failure.requestDetail": "请求失败:{detail}。", "chat.failure.runCategory": "运行失败:{detail}。", + "chat.failure.noProgress": "运行因重复执行却没有进展而停止。请使用更清晰的指令或缩小任务范围后重试。", "chat.failure.recoveryRequired": "运行正在等待恢复——后端报告了 `recovery_required`。", "chat.failure.run": "运行在生成回复前失败。", "chat.failure.streamRetryable": "聊天流遇到可重试错误:{detail}。", diff --git a/crates/product/ironclaw_webui/frontend/src/lib/i18n.test.ts b/crates/product/ironclaw_webui/frontend/src/lib/i18n.test.ts index 2f55a4937a0..712c2d9105f 100644 --- a/crates/product/ironclaw_webui/frontend/src/lib/i18n.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/lib/i18n.test.ts @@ -372,6 +372,7 @@ test("locale packs include client-generated chat failure copy", () => { "chat.failure.request", "chat.failure.requestDetail", "chat.failure.runCategory", + "chat.failure.noProgress", "chat.failure.recoveryRequired", "chat.failure.run", "chat.failure.streamRetryable", diff --git a/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/failureMessages.test.ts b/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/failureMessages.test.ts index a8f5d55573e..74295d03579 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/failureMessages.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/failureMessages.test.ts @@ -3,6 +3,7 @@ import { test } from "vitest"; import { CONNECTION_LOST_RUN_FAILURE_KEY, + NO_PROGRESS_RUN_FAILURE_KEY, failureMessageForRequestError, failureMessageForRunStatus, failureMessageForStreamError, @@ -21,6 +22,8 @@ const ENGLISH_FAILURE_COPY = { "chat.failure.recoveryRequired": "The run is awaiting recovery — backend reported `recovery_required`.", "chat.failure.run": "The run failed before producing a reply.", + [NO_PROGRESS_RUN_FAILURE_KEY]: + "The run stopped because it repeated work without making progress. Retry with a clearer instruction or narrower scope.", "chat.failure.streamRetryable": "The chat stream hit a retryable error: {detail}.", "chat.failure.stream": "The chat stream failed: {detail}.", @@ -59,6 +62,17 @@ test("failureMessageForRunStatus formats category underscores", () => { ); }); +test("failureMessageForRunStatus makes no-progress failures actionable without a summary", () => { + assert.equal( + failureMessageForRunStatus({ + status: "failed", + failureCategory: "no_progress_detected", + failureSummary: null, + }, t), + "The run stopped because it repeated work without making progress. Retry with a clearer instruction or narrower scope.", + ); +}); + test("failureMessageForRunStatus uses recovery_required fallback", () => { assert.equal( failureMessageForRunStatus({ diff --git a/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/failureMessages.ts b/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/failureMessages.ts index 70591c5774c..b3c09b00f02 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/failureMessages.ts +++ b/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/failureMessages.ts @@ -6,6 +6,7 @@ import { } from "./message-types"; export const CONNECTION_LOST_RUN_FAILURE_KEY = "chat.failure.connectionLost"; +export const NO_PROGRESS_RUN_FAILURE_KEY = "chat.failure.noProgress"; const REQUEST_FAILURE_FALLBACK_KEY = "chat.failure.request"; type Translate = ( @@ -69,6 +70,9 @@ export function failureMessageForRunStatus({ if (typeof failureSummary === "string" && failureSummary.trim()) { return failureSummary.trim(); } + if (normalizeLowerText(failureCategory) === "no_progress_detected") { + return t(NO_PROGRESS_RUN_FAILURE_KEY); + } if (typeof failureCategory === "string" && failureCategory.trim()) { return t("chat.failure.runCategory", { detail: failureCategory.trim().replaceAll("_", " "), diff --git a/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.test.ts b/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.test.ts index fdc14c94dda..79b1116dfb7 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.test.ts @@ -6,6 +6,7 @@ import vm from "node:vm"; import { isTerminalToolStatus, + messagesFromTimeline, toolCardFromActivity, toolCardFromPreview, } from "./history-messages"; @@ -44,6 +45,8 @@ const ENGLISH_FAILURE_COPY = { "chat.failure.connectionLost": "Connection to the server was lost. Please reconnect and try again.", "chat.failure.run": "The run failed before producing a reply.", + "chat.failure.noProgress": + "The run stopped because it repeated work without making progress. Retry with a clearer instruction or narrower scope.", "chat.failure.runCategory": "The run failed: {detail}.", "chat.failure.recoveryRequired": "The run is awaiting recovery — backend reported `recovery_required`.", @@ -2689,6 +2692,145 @@ test("useChatEvents: terminal failure settles the run as not successful", () => assert.deepEqual(harness.settledRuns, [{ runId: "run-1", success: false }]); }); +function assertNoProgressFailureClearsDraft(terminalStatus) { + const harness = createUseChatEventsHarness({ failureMessageForRunStatus }); + + harness.handleEvent({ + type: "projection_update", + frame: { + state: { + items: [ + { run_status: { run_id: "run-1", status: "running" } }, + { + text: { + id: "text:run-1:1", + run_id: "run-1", + body: "I will inspect the available tools.", + }, + }, + { + text: { + id: "text:run-1:2", + run_id: "run-1", + body: "Let me check what capabilities are available to provide more useful", + }, + }, + ], + }, + }, + }); + + harness.handleEvent({ + type: "projection_update", + frame: { + state: { + items: [ + { + run_status: { + run_id: "run-1", + status: terminalStatus, + failure_category: "no_progress_detected", + }, + }, + ], + }, + }, + }); + + assert.deepEqual( + Array.from(harness.messages, (message) => message.role), + ["assistant", "error"], + "a terminal failure must not leave an unfinished assistant draft presented as a reply", + ); + assert.equal(harness.messages[0].content, "I will inspect the available tools."); + assert.equal(harness.messages[0].isStreaming, false); + assert.equal(harness.messages[1].id, "err-run-1"); + assert.equal( + harness.messages[1].content, + "The run stopped because it repeated work without making progress. Retry with a clearer instruction or narrower scope.", + ); + + harness.handleEvent({ + type: "projection_update", + frame: { + state: { + items: [ + { + text: { + id: "text:run-1:2", + run_id: "run-1", + body: "a late replay of the unfinished draft", + }, + }, + ], + }, + }, + }); + + assert.deepEqual( + Array.from(harness.messages, (message) => message.role), + ["assistant", "error"], + "a late projection must not restore the unfinished draft after failure", + ); +} + +for (const terminalStatus of ["failed", "recovery_required"]) { + test(`useChatEvents: no-progress ${terminalStatus} replaces an unfinished assistant draft`, () => { + assertNoProgressFailureClearsDraft(terminalStatus); + }); +} + +test("useChatEvents: run failure preserves completed durable timeline phases", () => { + const harness = createUseChatEventsHarness({ failureMessageForRunStatus }); + const durableMessages = messagesFromTimeline([ + { + message_id: "tool-result-1", + kind: "tool_result", + status: "finalized", + content: "The tool completed before the run failed.", + turn_run_id: "run-1", + }, + ]); + harness.replaceMessages([ + ...durableMessages, + { + id: "text-run-1:draft", + role: "assistant", + content: "Let me check what capabilities are available to provide more useful", + turnRunId: "run-1", + isFinalReply: false, + isStreaming: true, + }, + ]); + + harness.handleEvent({ + type: "projection_update", + frame: { + state: { + items: [ + { + run_status: { + run_id: "run-1", + status: "failed", + failure_category: "no_progress_detected", + }, + }, + ], + }, + }, + }); + + assert.deepEqual( + Array.from(harness.messages, (message) => message.id), + ["msg-tool-result-1", "err-run-1"], + "a failure must remove only the live draft, not a completed durable phase", + ); + assert.equal( + harness.messages[0].content, + "The tool completed before the run failed.", + ); +}); + test("useChatEvents: terminal cancellation settles the run as not successful", () => { const harness = createUseChatEventsHarness(); diff --git a/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.ts b/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.ts index 222bebb344f..5e3ccdadc49 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.ts +++ b/crates/product/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.ts @@ -356,6 +356,7 @@ const TERMINAL_RUN_STATUSES = new Set([ ]); const SUCCESS_RUN_STATUSES = new Set(["completed", "succeeded"]); +const FAILURE_RUN_STATUSES = new Set(["failed", "recovery_required"]); const PROMPT_RUN_STATUSES = new Set([ "blocked_auth", "blocked_approval", @@ -627,7 +628,23 @@ function applyProjectionItems({ // truth for clearing pendingGate/processing. const messageId = `text-${item.text.id}`; const textRunId = item.text.run_id || null; + if ( + textRunId && + FAILURE_RUN_STATUSES.has(batchRunStatusByRunId.get(textRunId)) + ) { + continue; + } setMessages((prev) => { + if ( + textRunId && + prev.some( + (message) => + isErrorChatMessage(message) && + message.id === `${RUN_FAILURE_ID_PREFIX}${textRunId}`, + ) + ) { + return prev; + } const phaseAware = textRunId ? prev.map((message) => message?.role === "assistant" && @@ -848,7 +865,8 @@ function appendRunFailureMessage( ? connectionContextForRunFailure(runId) || {} : {}; setMessages((prev) => { - const existing = prev.findIndex((m) => m.id === messageId); + const visibleMessages = withoutStreamingAssistantPhaseForRun(prev, runId); + const existing = visibleMessages.findIndex((m) => m.id === messageId); const content = failureMessageForRunStatus({ status, failureCategory, @@ -857,8 +875,13 @@ function appendRunFailureMessage( }, t); if (existing >= 0) { const hasUsefulUpdate = Boolean(failureSummary || failureCategory); - if (!hasUsefulUpdate || prev[existing].content === content) return prev; - const next = [...prev]; + if ( + !hasUsefulUpdate || + visibleMessages[existing].content === content + ) { + return visibleMessages; + } + const next = [...visibleMessages]; next[existing] = { ...next[existing], content, @@ -869,20 +892,20 @@ function appendRunFailureMessage( }; return next; } - const lastMessage = prev[prev.length - 1]; + const lastMessage = visibleMessages[visibleMessages.length - 1]; if (isAdjacentDuplicateRunFailure(lastMessage, content)) { const replacement = promotedRunFailureMessage( lastMessage, messageId, runId, ); - if (replacement === lastMessage) return prev; - const next = [...prev]; + if (replacement === lastMessage) return visibleMessages; + const next = [...visibleMessages]; next[next.length - 1] = replacement; return next; } return [ - ...prev, + ...visibleMessages, createErrorChatMessage({ id: messageId, content, @@ -900,6 +923,20 @@ function appendRunFailureMessage( }); } +function withoutStreamingAssistantPhaseForRun(messages, runId) { + if (!runId) return messages; + const next = messages.filter( + (message) => + !( + message?.role === "assistant" && + message.turnRunId === runId && + message.isFinalReply === false && + message.isStreaming === true + ), + ); + return next.length === messages.length ? messages : next; +} + // A projection can report an unknown run failure before the send response maps // the optimistic message to a concrete run id. Keep adjacent run failures // deduped, and promote `err-unknown` to `err-` once the id arrives.