Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions crates/product/ironclaw_webui/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}।",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}。",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/uk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from "vitest";

import {
CONNECTION_LOST_RUN_FAILURE_KEY,
NO_PROGRESS_RUN_FAILURE_KEY,
failureMessageForRequestError,
failureMessageForRunStatus,
failureMessageForStreamError,
Expand All @@ -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}.",
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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("_", " "),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import vm from "node:vm";

import {
isTerminalToolStatus,
messagesFromTimeline,
toolCardFromActivity,
toolCardFromPreview,
} from "./history-messages";
Expand Down Expand Up @@ -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`.",
Expand Down Expand Up @@ -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();

Expand Down
Loading
Loading