From 7a5c14598fa112d690283ff645e07562bbb4860c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 18 Jun 2026 18:55:41 +0800 Subject: [PATCH 1/7] fix(ui): clearer terminal notice when safe recovery fails after a tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #1358. When safe recovery exhausts its budget after a completed side-effecting tool (e.g. a posted GitHub comment), the turn previously ended with a weak grey caption while the prominent item stayed the completed tool card — reading as if the tool were still stuck. - notice.tsx renders a separated, titled status row (stroked icon + ink title + explanation), not the old single-line caption. No semantic colour flood: the operation usually succeeded, so it must not read as an error. - Copy adapts to context: a completed tool earlier in the turn means an external side effect already landed, so the notice reassures it is done ("Action completed" / 操作已完成) and does not nudge a redo; otherwise the reply simply never started ("Reply incomplete" / 回复未完成). Both attribute the failure to the network or model provider (not PawWork) and give a next step (retry / switch models). - i18n: split the flat safeRetryFailed key into sideEffect/default title+body for en/zh/zht; drop the stale flat key from the dormant locales (runtime loads en + zh, others fall back to en). - session-safe-retry-contract.test.ts rewritten to lock the new presentation and copy intent. - recovery-presentation snap (fixture + target) renders both cases through the real AssistantParts → tool card + notice pipeline as durable regression coverage. Visual check: bun run snap recovery-presentation (docs/design/preview/screenshots/recovery-presentation.png). Claude-Session: https://claude.ai/code/session_01UUsFz2KqaDQpF8TzBJoBZF --- .../recovery-presentation-snap-fixture.tsx | 131 ++++++++++++++++++ .../e2e/snap/recovery-presentation.snap.ts | 57 ++++++++ .../components/message-part/parts/notice.css | 37 +++++ .../components/message-part/parts/notice.tsx | 37 ++++- .../session-safe-retry-contract.test.ts | 58 ++++++-- packages/ui/src/i18n/ar.ts | 1 - packages/ui/src/i18n/br.ts | 1 - packages/ui/src/i18n/bs.ts | 1 - packages/ui/src/i18n/da.ts | 1 - packages/ui/src/i18n/de.ts | 1 - packages/ui/src/i18n/en.ts | 7 +- packages/ui/src/i18n/es.ts | 1 - packages/ui/src/i18n/fr.ts | 1 - packages/ui/src/i18n/ja.ts | 1 - packages/ui/src/i18n/ko.ts | 1 - packages/ui/src/i18n/no.ts | 1 - packages/ui/src/i18n/pl.ts | 1 - packages/ui/src/i18n/ru.ts | 1 - packages/ui/src/i18n/th.ts | 1 - packages/ui/src/i18n/tr.ts | 1 - packages/ui/src/i18n/zh.ts | 7 +- packages/ui/src/i18n/zht.ts | 7 +- 22 files changed, 321 insertions(+), 34 deletions(-) create mode 100644 packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx create mode 100644 packages/app/e2e/snap/recovery-presentation.snap.ts diff --git a/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx b/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx new file mode 100644 index 000000000..be27c1de9 --- /dev/null +++ b/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx @@ -0,0 +1,131 @@ +import { render } from "solid-js/web" +import type { AssistantMessage, NoticePart, TextPart, ToolPart } from "@opencode-ai/sdk/v2" +import { DataProvider, I18nProvider } from "@opencode-ai/ui/context" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { dict as zh } from "@opencode-ai/ui/i18n/zh" +import { AssistantParts } from "@opencode-ai/ui/message-part" +import type { UiI18nKey, UiI18nParams } from "@opencode-ai/ui/context/i18n" + +// The #1358 terminal notice rendered through the real component pipeline +// (AssistantParts → tool.tsx card + notice.tsx). Two columns cover the adaptive +// copy: a turn that ran a side-effecting tool (operation already landed) vs. a +// turn whose reply never started (no tool). +const SESSION = "ses_recovery_presentation" + +function assistant(id: string): AssistantMessage { + return { + id, + role: "assistant", + sessionID: SESSION, + parentID: "msg_recovery_user", + modelID: "test-model", + providerID: "test-provider", + mode: "build", + agent: "build", + path: { cwd: "/Users/yuhan/PawWork", root: "/Users/yuhan/PawWork" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, completed: 1 }, + } +} + +function text(messageID: string): TextPart { + return { + id: `${messageID}_text`, + sessionID: SESSION, + messageID, + type: "text", + text: "我帮你在 issue #1358 下留了一条评论。", + time: { start: 0, end: 1 }, + } +} + +function bashTool(messageID: string): ToolPart { + return { + id: `${messageID}_bash`, + sessionID: SESSION, + messageID, + type: "tool", + callID: `${messageID}_call`, + tool: "bash", + state: { + status: "completed", + input: { command: 'gh issue comment 1358 --body "已按方案排期,明天开工。"', description: "在 #1358 下留言" }, + output: "https://github.com/Astro-Han/pawwork/issues/1358#issuecomment-3920481", + title: "在 #1358 下留言", + metadata: {}, + time: { start: 0, end: 1 }, + }, + } +} + +function notice(messageID: string): NoticePart { + return { + id: `${messageID}_notice`, + sessionID: SESSION, + messageID, + type: "notice", + kind: "safe_retry_failed", + time: { created: 1 }, + } +} + +const i18n = { + locale: () => "zh", + t: (key: UiI18nKey, params?: UiI18nParams) => { + const template = zh[key] ?? String(key) + return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => String(params?.[String(rawKey)] ?? "")) + }, +} + +// AssistantParts reads parts from the DataProvider store by messageID; the real +// notice.tsx reads the same store to pick the side-effect vs. default copy. +function Turn(props: { message: AssistantMessage; parts: (TextPart | ToolPart | NoticePart)[] }) { + return ( + + + + + + ) +} + +function RecoveryPresentationSnapFixture() { + const sideEffect = assistant("msg_side_effect") + const reply = assistant("msg_reply") + return ( + + + {/* Opaque full-viewport cover at max z-index so the app's dev chrome + (debug bar, server-health toast) renders behind the captured grid. */} +
+
+ +
+
+ +
+
+
+
+ ) +} + +export function mountRecoveryPresentationSnapFixture(root: HTMLElement) { + render(() => , root) +} diff --git a/packages/app/e2e/snap/recovery-presentation.snap.ts b/packages/app/e2e/snap/recovery-presentation.snap.ts new file mode 100644 index 000000000..847eff0a7 --- /dev/null +++ b/packages/app/e2e/snap/recovery-presentation.snap.ts @@ -0,0 +1,57 @@ +import { expect, type Locator, type Page } from "@playwright/test" +import { fileURLToPath } from "node:url" +import { test } from "../fixtures" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +test.use({ viewport: { width: 840, height: 420 }, deviceScaleFactor: 2 }) + +const fixturePath = fileURLToPath(new URL("./fixtures/recovery-presentation-snap-fixture.tsx", import.meta.url)) + +async function waitForThemeBoot(page: Page): Promise { + await page.waitForFunction( + () => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0, + null, + { timeout: 30_000 }, + ) +} + +async function capture(name: string, block: Locator): Promise { + await expect(block).toBeVisible({ timeout: 30_000 }) + return { name, buf: await block.screenshot() } +} + +test("recovery-presentation", async ({ page }) => { + test.setTimeout(180_000) + + await page.goto("/") + await waitForThemeBoot(page) + await page.evaluate(async (path) => { + const mod = await import(path) + // Wipe the booted app shell so its dev chrome can't bleed into the capture. + document.body.replaceChildren() + const root = document.createElement("div") + document.body.appendChild(root) + mod.mountRecoveryPresentationSnapFixture(root) + }, `/@fs/${fixturePath}`) + + const sideEffect = page.locator('[data-snap="side-effect"]') + const fallback = page.locator('[data-snap="default"]') + + // Side-effect turn: completed bash tool card above, reassuring side-effect copy. + await expect(sideEffect).toContainText("在 #1358 下留言", { timeout: 30_000 }) + await expect(sideEffect.locator('[data-kind="safe_retry_failed"][data-variant="side-effect"]')).toBeVisible() + await expect(sideEffect).toContainText("操作已完成") + + // No-tool turn: default copy, no tool card. + await expect(fallback.locator('[data-kind="safe_retry_failed"][data-variant="default"]')).toBeVisible({ + timeout: 30_000, + }) + await expect(fallback).toContainText("回复未完成") + + const out = snapOutputPath("recovery-presentation") + await composeGrid( + [await capture("after side-effecting tool", sideEffect), await capture("reply never started", fallback)], + out, + ) + process.stdout.write(`\n[snap] recovery-presentation grid -> ${out}\n\n`) +}) diff --git a/packages/ui/src/components/message-part/parts/notice.css b/packages/ui/src/components/message-part/parts/notice.css index b5af8bd36..a889660ab 100644 --- a/packages/ui/src/components/message-part/parts/notice.css +++ b/packages/ui/src/components/message-part/parts/notice.css @@ -13,3 +13,40 @@ padding-top: 12px; border-top: 1px solid var(--border-weaker); } + +/* + * safe_retry_failed reads as its own terminal step, separate from the completed + * tool card above it (#1358): a stroked status icon in ink (no semantic color + * flood — the operation usually succeeded, so it must not read as an error), + * an ink title, then the explanation. Typography sets size/weight/line-height + * only, never font-family, so the CJK stack is inherited. + */ +[data-component="notice-part"][data-kind="safe_retry_failed"] { + display: flex; + gap: 9px; + align-items: flex-start; +} + +[data-component="notice-part"] [data-slot="notice-icon"] { + flex: 0 0 auto; + margin-top: 1px; + color: var(--fg-weak); +} + +[data-component="notice-part"] [data-slot="notice-text"] { + min-width: 0; +} + +[data-component="notice-part"] [data-slot="notice-title"] { + font-size: var(--font-size-body); + font-weight: var(--font-weight-emphasis); + line-height: var(--line-height-h3); + color: var(--fg-strong); +} + +[data-component="notice-part"] [data-slot="notice-body"] { + margin-top: 2px; + font-size: var(--font-size-body); + line-height: var(--line-height-h3); + color: var(--fg-base); +} diff --git a/packages/ui/src/components/message-part/parts/notice.tsx b/packages/ui/src/components/message-part/parts/notice.tsx index e8ba0e0f7..ab93ab2e7 100644 --- a/packages/ui/src/components/message-part/parts/notice.tsx +++ b/packages/ui/src/components/message-part/parts/notice.tsx @@ -1,18 +1,49 @@ -import { Match, Switch } from "solid-js" +import { Match, Switch, createMemo } from "solid-js" import type { NoticePart } from "@opencode-ai/sdk/v2" +import { useData } from "../../../context" import { useI18n } from "../../../context/i18n" +import { Icon } from "../../icon" import { registerPartComponent } from "../registry" import "./notice.css" registerPartComponent("notice", function NoticePartDisplay(props) { const i18n = useI18n() + const data = useData() const part = () => props.part as NoticePart + // A completed tool earlier in the same turn means an external side effect + // already landed (e.g. a posted comment). Reassure the user it is done and + // not to redo it; otherwise the model's reply simply never started. Any + // completed tool qualifies — this can only over-apply the reassuring copy to + // a read-only turn (harmless), never miss a real side effect. + const afterToolRun = createMemo(() => { + const parts = data.store.part?.[part().messageID] + return Array.isArray(parts) && parts.some((p) => p.type === "tool" && p.state.status === "completed") + }) + return ( -
- {i18n.t("ui.sessionTurn.notice.safeRetryFailed")} +
+ + + +
+
+ {afterToolRun() + ? i18n.t("ui.sessionTurn.notice.safeRetryFailed.sideEffect.title") + : i18n.t("ui.sessionTurn.notice.safeRetryFailed.default.title")} +
+
+ {afterToolRun() + ? i18n.t("ui.sessionTurn.notice.safeRetryFailed.sideEffect.body") + : i18n.t("ui.sessionTurn.notice.safeRetryFailed.default.body")} +
+
diff --git a/packages/ui/src/components/session-safe-retry-contract.test.ts b/packages/ui/src/components/session-safe-retry-contract.test.ts index ad1bd9095..35e5b0ccb 100644 --- a/packages/ui/src/components/session-safe-retry-contract.test.ts +++ b/packages/ui/src/components/session-safe-retry-contract.test.ts @@ -22,28 +22,58 @@ test("recovery retry uses a lightweight status row instead of the error card", ( expect(retry).toContain('') }) -test("safe retry failure renders as a dedicated notice part", () => { +test("safe retry failure renders a titled notice that adapts to a prior tool side effect", () => { expect(notice).toContain('registerPartComponent("notice"') expect(notice).toContain('part().kind === "safe_retry_failed"') expect(notice).toContain('data-kind="safe_retry_failed"') - expect(notice).toContain('i18n.t("ui.sessionTurn.notice.safeRetryFailed")') + // Separated, calm presentation (#1358): a stroked status icon + ink title, + // not the old weak single-line caption. + expect(notice).toContain('data-variant=') + expect(notice).toContain(' { - expect(en).toContain('"ui.sessionTurn.retry.recovery": "Recovering..."') - expect(en).toContain( - '"ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models."', - ) - expect(zh).toContain('"ui.sessionTurn.retry.recovery": "正在恢复…"') - expect(zh).toContain('"ui.sessionTurn.notice.safeRetryFailed": "恢复失败。你可以稍后再试,或换一个模型。"') - expect(zht).toContain('"ui.sessionTurn.retry.recovery": "正在恢復…"') - expect(zht).toContain('"ui.sessionTurn.notice.safeRetryFailed": "恢復失敗。你可以稍後再試,或換一個模型。"') +test("safe-retry notice copy names an external cause and a next step, without nudging a redo", () => { + // Side-effect case reassures the action already ran; default case does not + // claim an action happened. Both attribute the failure outside PawWork and + // give a next step. + expect(en).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "Action completed"') + expect(en).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "Reply incomplete"') + expect(en).toMatch(/already went through[\s\S]*network or model provider[\s\S]*switch models/) + + expect(zh).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成"') + expect(zh).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "回复未完成"') + expect(zh).toMatch(/上一项操作已执行[\s\S]*网络或模型服务商[\s\S]*请稍后重试,或换一个模型/) + expect(zh).toMatch(/模型回复未能生成[\s\S]*网络或模型服务商/) + + expect(zht).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成"') + expect(zht).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "回覆未完成"') + expect(zht).toMatch(/網路或模型服務商/) }) -test("all locale safe retry failure copy uses recovery wording", () => { +test("the old single safe-retry notice key is gone everywhere", () => { + // Split into sideEffect/default title+body; the runtime locales (en, zh) carry + // the split copy, other locale files fall back to en, and the old flat key is + // removed so no stale string lingers. for (const [path, source] of Object.entries(localeFiles)) { - expect(source, path).toContain('"ui.sessionTurn.notice.safeRetryFailed": "') - expect(source, path).toMatch(/Recovery failed|恢复失败|恢復失敗/) + expect(source, path).not.toContain('"ui.sessionTurn.notice.safeRetryFailed":') expect(source, path).not.toContain("Network connection dropped. Automatic retry did not complete.") } + for (const variant of ["sideEffect", "default"] as const) { + for (const slot of ["title", "body"] as const) { + const key = `"ui.sessionTurn.notice.safeRetryFailed.${variant}.${slot}":` + expect(en, "en").toContain(key) + expect(zh, "zh").toContain(key) + } + } }) diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 44f86e316..c45347408 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini مزدحم حاليا", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "تم تجاوز حد الاستخدام المجاني", "ui.sessionTurn.error.addCredits": "إضافة رصيد", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 04ea02105..9a56ea135 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini está muito sobrecarregado agora", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Limite de uso gratuito excedido", "ui.sessionTurn.error.addCredits": "Adicionar créditos", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 3f65369b8..172004246 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -49,7 +49,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini je trenutno preopterećen", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Besplatna upotreba premašena", "ui.sessionTurn.error.addCredits": "Dodaj kredite", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 862def0e0..3694097c7 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -44,7 +44,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini er meget overbelastet lige nu", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Gratis forbrug overskredet", "ui.sessionTurn.error.addCredits": "Tilføj kreditter", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 0a6849f35..e05900d89 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -50,7 +50,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini ist gerade sehr überlastet", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Kostenloses Nutzungslimit überschritten", "ui.sessionTurn.error.addCredits": "Guthaben aufladen", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index ec2c6cb20..459538985 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -76,7 +76,12 @@ export const dict: Record = { "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Recovering...", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "Action completed", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": + "The previous action already went through. The network or model provider may be having connection issues. Try again later, or switch models.", + "ui.sessionTurn.notice.safeRetryFailed.default.title": "Reply incomplete", + "ui.sessionTurn.notice.safeRetryFailed.default.body": + "The model's reply couldn't be generated. The network or model provider may be having connection issues. Try again later, or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 119dd0195..f1bfce8b2 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini está demasiado saturado", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Límite de uso gratuito excedido", "ui.sessionTurn.error.addCredits": "Añadir créditos", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index e56a58a00..1109d86f6 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini est en surchauffe", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Limite d'utilisation gratuite dépassée", "ui.sessionTurn.error.addCredits": "Ajouter des crédits", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 5646fbd58..478c8da04 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -44,7 +44,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini が混雑しています", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "無料使用制限に達しました", "ui.sessionTurn.error.addCredits": "クレジットを追加", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 7202738f9..3dbedc987 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini가 현재 과부하 상태입니다", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "무료 사용량 초과", "ui.sessionTurn.error.addCredits": "크레딧 추가", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 044108a93..5c4856b79 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -48,7 +48,6 @@ export const dict: Record = { "ui.sessionTurn.retry.geminiHot": "gemini er veldig overbelastet nå", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Gratis bruk overskredet", "ui.sessionTurn.error.addCredits": "Legg til kreditt", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 13713f384..b0b510096 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -44,7 +44,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini jest teraz mocno przeciążony", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Przekroczono limit darmowego użytkowania", "ui.sessionTurn.error.addCredits": "Dodaj kredyty", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 17cf8291b..305c6ce2e 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -44,7 +44,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini сейчас перегружен", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Лимит бесплатного использования превышен", "ui.sessionTurn.error.addCredits": "Добавить кредиты", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index fa72f0bc2..691c550a3 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -46,7 +46,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini กำลังใช้งานหนาแน่นมาก", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "เกินขีดจำกัดการใช้งานฟรี", "ui.sessionTurn.error.addCredits": "เพิ่มเครดิต", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index f6291bcb9..ab1c13054 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -51,7 +51,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini şu anda aşırı yoğun", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Ücretsiz kullanım aşıldı", "ui.sessionTurn.error.addCredits": "Kredi ekle", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 3af95b5af..9ca59310b 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -79,7 +79,12 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini 当前过载", "ui.sessionTurn.retry.recovery": "正在恢复…", "ui.sessionTurn.retry.safeRecovery": "正在恢复…", - "ui.sessionTurn.notice.safeRetryFailed": "恢复失败。你可以稍后再试,或换一个模型。", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": + "上一项操作已执行。当前网络或模型服务商存在连接问题,请稍后重试,或换一个模型。", + "ui.sessionTurn.notice.safeRetryFailed.default.title": "回复未完成", + "ui.sessionTurn.notice.safeRetryFailed.default.body": + "模型回复未能生成。当前网络或模型服务商存在连接问题,请稍后重试,或换一个模型。", "ui.sessionTurn.error.freeUsageExceeded": "免费使用额度已用完", "ui.sessionTurn.error.addCredits": "添加积分", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index bde333201..cf165cb9c 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -51,7 +51,12 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini 目前過載", "ui.sessionTurn.retry.recovery": "正在恢復…", "ui.sessionTurn.retry.safeRecovery": "正在恢復…", - "ui.sessionTurn.notice.safeRetryFailed": "恢復失敗。你可以稍後再試,或換一個模型。", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": + "上一項操作已執行。目前網路或模型服務商存在連線問題,請稍後重試,或換一個模型。", + "ui.sessionTurn.notice.safeRetryFailed.default.title": "回覆未完成", + "ui.sessionTurn.notice.safeRetryFailed.default.body": + "模型回覆未能生成。目前網路或模型服務商存在連線問題,請稍後重試,或換一個模型。", "ui.sessionTurn.error.freeUsageExceeded": "免費使用額度已用完", "ui.sessionTurn.error.addCredits": "新增點數", From dcdf6c73ea28bf9deba5469cfc6b36edc4ac3967 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 18 Jun 2026 19:20:50 +0800 Subject: [PATCH 2/7] feat(ui): split connecting from thinking before first provider progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the provider sends its first chunk, the turn showed "Thinking" / "思考中" even though the model had not started responding — building the request, connecting, or waiting for the stream to be accepted all read as reasoning (#1358). Split the turn status by whether any provider-output part (text / reasoning / tool — the UI mirror of the backend isProviderProgressEvent set) exists yet: - no such part: "Connecting" / "连接中" (data-phase="connecting") - after the first one: "Thinking" / "思考中" (data-phase="thinking") A step-start part is intentionally excluded; it can precede the first provider chunk and would otherwise make a connection wait read as reasoning. Safe recovery now names the retry attempt ("Recovering... attempt #N" / "正在恢复…第 N 次") so the row reads as model-recovery progress, not a stuck tool. Terminal failure before first progress is already covered by the safe_retry_failed notice ("Reply incomplete" / "回复未完成"). UI-only: the assistant message carries zero parts until the first provider progress event creates one, so no backend status/schema change is needed. Verify: - packages/ui: typecheck, full unit suite (732 pass), lint clean - packages/app: typecheck - e2e @smoke W1 connecting indicator (real session-turn memo, hang path) passed - snap turn-status-phase grid reviewed (连接中 / 思考中 / 正在恢复…第 2 次) Claude-Session: https://claude.ai/code/session_01UUsFz2KqaDQpF8TzBJoBZF --- .../e2e/session/session-w1-contracts.spec.ts | 14 ++- .../turn-status-phase-snap-fixture.tsx | 92 +++++++++++++++++++ .../app/e2e/snap/turn-status-phase.snap.ts | 61 ++++++++++++ packages/ui/src/components/session-retry.tsx | 4 +- .../session-thinking-phase-contract.test.ts | 43 +++++++++ packages/ui/src/components/session-turn.tsx | 28 +++++- packages/ui/src/i18n/en.ts | 2 + packages/ui/src/i18n/zh.ts | 2 + packages/ui/src/i18n/zht.ts | 2 + 9 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 packages/app/e2e/snap/fixtures/turn-status-phase-snap-fixture.tsx create mode 100644 packages/app/e2e/snap/turn-status-phase.snap.ts create mode 100644 packages/ui/src/components/session-thinking-phase-contract.test.ts diff --git a/packages/app/e2e/session/session-w1-contracts.spec.ts b/packages/app/e2e/session/session-w1-contracts.spec.ts index 9227a9177..bb49ab538 100644 --- a/packages/app/e2e/session/session-w1-contracts.spec.ts +++ b/packages/app/e2e/session/session-w1-contracts.spec.ts @@ -22,6 +22,7 @@ const TROW_RESULT_BODY = `${TROW_BLOCK} [data-slot="trow-result-body"]` const TROW_INNER_TRIGGER = `${TROW_BLOCK} [data-slot="trow-body"] [data-component="tool-trigger"]` const BASH_SCROLL = `${TROW_BLOCK} [data-slot="bash-scroll"]` const THINKING = '[data-slot="session-turn-thinking"]' +const CONNECTING = '[data-slot="session-turn-thinking"][data-phase="connecting"]' const USER_TEXT = '[data-component="user-message"] [data-slot="user-message-text"]' const AGENT_PROSE = '[data-component="text-part"]' const AGENT_REASONING = '[data-component="reasoning-body"]' @@ -170,7 +171,7 @@ test("@smoke W1 rendered turn locks chevron, selectability, and trow typography" }) }) -test("@smoke W1 thinking indicator shows while the turn is working with nothing visible", async ({ +test("@smoke W1 connecting indicator shows before first provider progress (nothing visible)", async ({ page, project, assistant, @@ -181,7 +182,7 @@ test("@smoke W1 thinking indicator shows while the turn is working with nothing // Submit by hand: project.prompt() waits for the session to go idle, which // never happens while the reply hangs. Type + Enter and only wait for the - // thinking shimmer to surface. + // status shimmer to surface. const text = "Hold the turn open with nothing rendered yet." const prompt = page.locator(promptSelector).first() await expect(prompt).toBeVisible() @@ -191,9 +192,12 @@ test("@smoke W1 thinking indicator shows while the turn is working with nothing await expect.poll(async () => (await prompt.textContent())?.replace(/\u200B/g, "").trim()).toBe(text) await page.keyboard.press("Enter") - const thinking = page.locator(THINKING) - await expect(thinking).toBeVisible({ timeout: 30_000 }) - await expect(thinking.locator('[data-component="text-shimmer"]')).toBeVisible() + // The reply hangs with no provider progress (#1358), so the status reads as + // "connecting", not "thinking" \u2014 the model hasn't started responding yet. + const connecting = page.locator(CONNECTING) + await expect(connecting).toBeVisible({ timeout: 30_000 }) + await expect(connecting.locator('[data-component="text-shimmer"]')).toBeVisible() + await expect(page.locator('[data-slot="session-turn-thinking"][data-phase="thinking"]')).toHaveCount(0) // Manual submit bypasses project.prompt(), so register the session the UI // created. Otherwise teardown only drops the project directory and leaves the diff --git a/packages/app/e2e/snap/fixtures/turn-status-phase-snap-fixture.tsx b/packages/app/e2e/snap/fixtures/turn-status-phase-snap-fixture.tsx new file mode 100644 index 000000000..71499b16c --- /dev/null +++ b/packages/app/e2e/snap/fixtures/turn-status-phase-snap-fixture.tsx @@ -0,0 +1,92 @@ +import { render } from "solid-js/web" +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" +import { I18nProvider } from "@opencode-ai/ui/context" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { SessionRetry } from "@opencode-ai/ui/session-retry" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" +import { dict as zh } from "@opencode-ai/ui/i18n/zh" +import type { UiI18nKey, UiI18nParams } from "@opencode-ai/ui/context/i18n" + +// The #1358 turn-status split, rendered through the real components. Before the +// provider sends its first chunk the wait reads as "connecting", not "thinking"; +// safe recovery names the retry attempt. SessionRetry is the production recovery +// row; TextShimmer is the production status shimmer — the same markup the turn +// uses (`session-turn-thinking` + `data-phase`). +const i18n = { + locale: () => "zh", + t: (key: UiI18nKey, params?: UiI18nParams) => { + const template = zh[key] ?? String(key) + return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => String(params?.[String(rawKey)] ?? "")) + }, +} + +const recoveryStatus: SessionStatus = { + type: "retry", + attempt: 2, + message: "", + next: 0, + presentation: "safe_recovery", +} + +// The visible token values of `[data-slot="session-turn-thinking"]` in +// session-turn.css. Applied inline because that rule is scoped under a +// full-height `[data-component="session-turn"]` flex container that would fight +// an isolated snap tile; the shimmer itself is the real TextShimmer. +const thinkingRow = { + display: "flex", + "align-items": "center", + gap: "8px", + color: "var(--fg-weak)", + "font-family": "var(--font-family-sans)", + "font-size": "var(--font-size-body)", + "font-weight": "var(--font-weight-emphasis)", + "line-height": "20px", +} + +function StatusRow(props: { phase: "connecting" | "thinking"; labelKey: UiI18nKey }) { + return ( +
+ +
+ ) +} + +function TurnStatusPhaseSnapFixture() { + return ( + + + {/* Opaque full-viewport cover at max z-index so the app's dev chrome + (debug bar, server-health toast) renders behind the captured grid. */} +
+
+ +
+
+ +
+
+ +
+
+
+
+ ) +} + +export function mountTurnStatusPhaseSnapFixture(root: HTMLElement) { + render(() => , root) +} diff --git a/packages/app/e2e/snap/turn-status-phase.snap.ts b/packages/app/e2e/snap/turn-status-phase.snap.ts new file mode 100644 index 000000000..8fc4b3d50 --- /dev/null +++ b/packages/app/e2e/snap/turn-status-phase.snap.ts @@ -0,0 +1,61 @@ +import { expect, type Locator, type Page } from "@playwright/test" +import { fileURLToPath } from "node:url" +import { test } from "../fixtures" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +test.use({ viewport: { width: 920, height: 200 }, deviceScaleFactor: 2 }) + +const fixturePath = fileURLToPath(new URL("./fixtures/turn-status-phase-snap-fixture.tsx", import.meta.url)) + +async function waitForThemeBoot(page: Page): Promise { + await page.waitForFunction( + () => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0, + null, + { timeout: 30_000 }, + ) +} + +async function capture(name: string, block: Locator): Promise { + await expect(block).toBeVisible({ timeout: 30_000 }) + return { name, buf: await block.screenshot() } +} + +test("turn-status-phase", async ({ page }) => { + test.setTimeout(180_000) + + await page.goto("/") + await waitForThemeBoot(page) + await page.evaluate(async (path) => { + const mod = await import(path) + // Wipe the booted app shell so its dev chrome can't bleed into the capture. + document.body.replaceChildren() + const root = document.createElement("div") + document.body.appendChild(root) + mod.mountTurnStatusPhaseSnapFixture(root) + }, `/@fs/${fixturePath}`) + + const connecting = page.locator('[data-snap="connecting"]') + const thinking = page.locator('[data-snap="thinking"]') + const recovery = page.locator('[data-snap="recovery"]') + + // Before first provider progress: connecting, not thinking. + await expect(connecting.locator('[data-phase="connecting"]')).toBeVisible({ timeout: 30_000 }) + await expect(connecting).toContainText("连接中") + // After provider progress: thinking. + await expect(thinking.locator('[data-phase="thinking"]')).toBeVisible() + await expect(thinking).toContainText("思考中") + // Safe recovery names the attempt. + await expect(recovery.locator('[data-slot="session-turn-safe-retry"]')).toBeVisible() + await expect(recovery).toContainText("正在恢复…第 2 次") + + const out = snapOutputPath("turn-status-phase") + await composeGrid( + [ + await capture("before first provider progress", connecting), + await capture("provider responding", thinking), + await capture("safe recovery, attempt 2", recovery), + ], + out, + ) + process.stdout.write(`\n[snap] turn-status-phase grid -> ${out}\n\n`) +}) diff --git a/packages/ui/src/components/session-retry.tsx b/packages/ui/src/components/session-retry.tsx index 4cd97ba92..f132bf79e 100644 --- a/packages/ui/src/components/session-retry.tsx +++ b/packages/ui/src/components/session-retry.tsx @@ -113,7 +113,9 @@ export function SessionRetry(props: {
- {i18n.t("ui.sessionTurn.retry.recovery")} + {current().attempt > 0 + ? i18n.t("ui.sessionTurn.retry.recoveryAttempt", { attempt: current().attempt }) + : i18n.t("ui.sessionTurn.retry.recovery")}
)} diff --git a/packages/ui/src/components/session-thinking-phase-contract.test.ts b/packages/ui/src/components/session-thinking-phase-contract.test.ts new file mode 100644 index 000000000..175a5a694 --- /dev/null +++ b/packages/ui/src/components/session-thinking-phase-contract.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +const turn = readFileSync(new URL("./session-turn.tsx", import.meta.url), "utf8") +const retry = readFileSync(new URL("./session-retry.tsx", import.meta.url), "utf8") +const en = readFileSync(new URL("../i18n/en.ts", import.meta.url), "utf8") +const zh = readFileSync(new URL("../i18n/zh.ts", import.meta.url), "utf8") +const zht = readFileSync(new URL("../i18n/zht.ts", import.meta.url), "utf8") + +test("the pre-first-progress wait reads as connecting, not thinking (#1358)", () => { + // Provider output parts (text / reasoning / tool) mirror the backend's + // `isProviderProgressEvent` set: their presence is the UI proxy for "the + // provider has started responding". A `step-start` part must NOT flip the + // phase — it can land before the first provider chunk. + expect(turn).toContain('part.type === "text" || part.type === "reasoning" || part.type === "tool"') + expect(turn).not.toContain('part.type === "step-start"') + // The thinking slot carries the phase so the split is observable without + // depending on copy/locale. + expect(turn).toContain('data-phase={providerStarted() ? "thinking" : "connecting"}') + // Both labels are wired; connecting is the pre-progress copy, thinking the + // post-progress copy. + expect(turn).toContain('i18n.t("ui.sessionTurn.status.connecting")') + expect(turn).toContain('i18n.t("ui.sessionTurn.status.thinking")') +}) + +test("safe recovery shows the retry attempt, not just a generic recovering label", () => { + // Recovery-in-progress should make clear PawWork is retrying the model + // response (attempt N), not re-running a tool. Falls back to the plain + // recovering label when no attempt count is available. + expect(retry).toContain('i18n.t("ui.sessionTurn.retry.recoveryAttempt", { attempt: current().attempt })') + expect(retry).toContain('i18n.t("ui.sessionTurn.retry.recovery")') +}) + +test("connecting and recovery-attempt copy exists in the runtime locales", () => { + expect(en).toContain('"ui.sessionTurn.status.connecting": "Connecting"') + expect(en).toContain('"ui.sessionTurn.retry.recoveryAttempt": "Recovering... attempt #{{attempt}}"') + + expect(zh).toContain('"ui.sessionTurn.status.connecting": "连接中"') + expect(zh).toContain('"ui.sessionTurn.retry.recoveryAttempt": "正在恢复…第 {{attempt}} 次"') + + expect(zht).toContain('"ui.sessionTurn.status.connecting": "連線中"') + expect(zht).toContain('"ui.sessionTurn.retry.recoveryAttempt": "正在恢復…第 {{attempt}} 次"') +}) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 9019cea03..0817959c5 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -351,6 +351,21 @@ export function SessionTurn( } return visible }) + // Once any provider-output part exists (text / reasoning / tool — the same set + // the backend counts as `isProviderProgressEvent`), the provider has started + // responding, so the silent wait is real "thinking". Before that — building + // the request, connecting, waiting for the stream to be accepted, waiting for + // the first chunk — it is only "connecting" (#1358). A `step-start` part does + // not count: it can precede the first provider chunk and would otherwise make + // a connection wait read as model reasoning. + const providerStarted = createMemo(() => { + for (const message of visibleAssistantMessages()) { + for (const part of list(data.store.part?.[message.id], emptyParts)) { + if (part.type === "text" || part.type === "reasoning" || part.type === "tool") return true + } + } + return false + }) const showThinking = createMemo(() => { if (compactionDivider() === "pending") return false if (!working() || !!error()) return false @@ -462,8 +477,17 @@ export function SessionTurn(
-
- +
+
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 459538985..a2c888e1d 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -75,6 +75,7 @@ export const dict: Record = { "ui.sessionTurn.retry.attemptLine": "{{line}} - attempt #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", "ui.sessionTurn.retry.recovery": "Recovering...", + "ui.sessionTurn.retry.recoveryAttempt": "Recovering... attempt #{{attempt}}", "ui.sessionTurn.retry.safeRecovery": "Recovering...", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "Action completed", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": @@ -93,6 +94,7 @@ export const dict: Record = { "ui.sessionTurn.status.searchingWeb": "Searching the web", "ui.sessionTurn.status.makingEdits": "Making edits", "ui.sessionTurn.status.runningCommands": "Running commands", + "ui.sessionTurn.status.connecting": "Connecting", "ui.sessionTurn.status.thinking": "Thinking", "ui.sessionTurn.status.thinkingWithTopic": "Thinking - {{topic}}", "ui.sessionTurn.status.gatheringThoughts": "Gathering thoughts", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 9ca59310b..3ddb94084 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -78,6 +78,7 @@ export const dict = { "ui.sessionTurn.retry.attemptLine": "{{line}} - 第 {{attempt}} 次", "ui.sessionTurn.retry.geminiHot": "gemini 当前过载", "ui.sessionTurn.retry.recovery": "正在恢复…", + "ui.sessionTurn.retry.recoveryAttempt": "正在恢复…第 {{attempt}} 次", "ui.sessionTurn.retry.safeRecovery": "正在恢复…", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": @@ -96,6 +97,7 @@ export const dict = { "ui.sessionTurn.status.searchingWeb": "正在搜索网页", "ui.sessionTurn.status.makingEdits": "正在修改", "ui.sessionTurn.status.runningCommands": "正在运行命令", + "ui.sessionTurn.status.connecting": "连接中", "ui.sessionTurn.status.thinking": "思考中", "ui.sessionTurn.status.thinkingWithTopic": "思考:{{topic}}", "ui.sessionTurn.status.gatheringThoughts": "正在整理思路", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index cf165cb9c..c47828173 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -50,6 +50,7 @@ export const dict = { "ui.sessionTurn.retry.attemptLine": "{{line}} - 第 {{attempt}} 次", "ui.sessionTurn.retry.geminiHot": "gemini 目前過載", "ui.sessionTurn.retry.recovery": "正在恢復…", + "ui.sessionTurn.retry.recoveryAttempt": "正在恢復…第 {{attempt}} 次", "ui.sessionTurn.retry.safeRecovery": "正在恢復…", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": @@ -68,6 +69,7 @@ export const dict = { "ui.sessionTurn.status.searchingWeb": "正在搜尋網頁", "ui.sessionTurn.status.makingEdits": "正在修改", "ui.sessionTurn.status.runningCommands": "正在執行命令", + "ui.sessionTurn.status.connecting": "連線中", "ui.sessionTurn.status.thinking": "思考中", "ui.sessionTurn.status.thinkingWithTopic": "思考 - {{topic}}", "ui.sessionTurn.status.gatheringThoughts": "正在整理思緒", From 6164d910e75d9b64c0d56d9e16c4548fb2f7f44a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 18 Jun 2026 19:40:12 +0800 Subject: [PATCH 3/7] fix(ui): only side-effecting tools flip the safe-retry notice to "completed" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review (#1358) found the `afterToolRun` predicate over-claimed: any completed tool — including a read-only read/glob/grep/webfetch/tool_info — flipped the notice to "Action completed / 操作已完成". So a turn that only ran a grep before the stream dropped would falsely assert an external action landed, the exact honesty defect the side-effect copy exists to avoid. Gate on side-effecting tools only: exclude the backend READ_ONLY_TOOLS set (mirrors run-observability/sanitize.ts). Everything else — bash, apply_patch, or an unknown/custom tool — still counts, erring toward reassurance so a real side effect is never under-warned (a redo would repeat it, which #1358 calls out). Renamed the memo to `afterSideEffectingTool` for accuracy. The recovery-presentation snap gains a read-only column (completed grep) that proves the notice falls back to the default "回复未完成", not "操作已完成". Verify: - packages/ui: typecheck clean, full unit suite 732 pass, lint clean - packages/app: typecheck clean - snap recovery-presentation: 3 columns reviewed (side-effect 操作已完成 / read-only 回复未完成 / no-tool 回复未完成) Claude-Session: https://claude.ai/code/session_01UUsFz2KqaDQpF8TzBJoBZF --- .../recovery-presentation-snap-fixture.tsx | 39 ++++++++++++++++++- .../e2e/snap/recovery-presentation.snap.ts | 15 ++++++- .../components/message-part/parts/notice.tsx | 31 ++++++++++----- .../session-safe-retry-contract.test.ts | 9 +++-- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx b/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx index be27c1de9..086fa0dfe 100644 --- a/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx +++ b/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx @@ -60,6 +60,39 @@ function bashTool(messageID: string): ToolPart { } } +// A completed read-only tool (grep) carries no side effect: it must NOT flip +// the notice to the "操作已完成" reassurance. This column proves the predicate +// excludes read-only tools and falls back to the default "回复未完成" copy. +function grepTool(messageID: string): ToolPart { + return { + id: `${messageID}_grep`, + sessionID: SESSION, + messageID, + type: "tool", + callID: `${messageID}_call`, + tool: "grep", + state: { + status: "completed", + input: { pattern: "safe_retry_failed", include: "*.tsx" }, + output: "packages/ui/src/components/message-part/parts/notice.tsx", + title: "搜索 safe_retry_failed", + metadata: {}, + time: { start: 0, end: 1 }, + }, + } +} + +function searchText(messageID: string): TextPart { + return { + id: `${messageID}_text`, + sessionID: SESSION, + messageID, + type: "text", + text: "我先在代码里查了下相关实现。", + time: { start: 0, end: 1 }, + } +} + function notice(messageID: string): NoticePart { return { id: `${messageID}_notice`, @@ -93,6 +126,7 @@ function Turn(props: { message: AssistantMessage; parts: (TextPart | ToolPart | function RecoveryPresentationSnapFixture() { const sideEffect = assistant("msg_side_effect") + const readOnly = assistant("msg_read_only") const reply = assistant("msg_reply") return ( @@ -106,7 +140,7 @@ function RecoveryPresentationSnapFixture() { "z-index": "2147483647", overflow: "auto", display: "grid", - "grid-template-columns": "repeat(2, 360px)", + "grid-template-columns": "repeat(3, 360px)", "align-content": "start", gap: "24px", padding: "24px", @@ -117,6 +151,9 @@ function RecoveryPresentationSnapFixture() {
+
+ +
diff --git a/packages/app/e2e/snap/recovery-presentation.snap.ts b/packages/app/e2e/snap/recovery-presentation.snap.ts index 847eff0a7..364da1a74 100644 --- a/packages/app/e2e/snap/recovery-presentation.snap.ts +++ b/packages/app/e2e/snap/recovery-presentation.snap.ts @@ -35,6 +35,7 @@ test("recovery-presentation", async ({ page }) => { }, `/@fs/${fixturePath}`) const sideEffect = page.locator('[data-snap="side-effect"]') + const readOnly = page.locator('[data-snap="read-only"]') const fallback = page.locator('[data-snap="default"]') // Side-effect turn: completed bash tool card above, reassuring side-effect copy. @@ -42,6 +43,14 @@ test("recovery-presentation", async ({ page }) => { await expect(sideEffect.locator('[data-kind="safe_retry_failed"][data-variant="side-effect"]')).toBeVisible() await expect(sideEffect).toContainText("操作已完成") + // Read-only turn: a completed grep ran, but it carries no side effect, so the + // notice must NOT claim "操作已完成" — it falls back to the default copy. + await expect(readOnly.locator('[data-kind="safe_retry_failed"][data-variant="default"]')).toBeVisible({ + timeout: 30_000, + }) + await expect(readOnly).toContainText("回复未完成") + await expect(readOnly).not.toContainText("操作已完成") + // No-tool turn: default copy, no tool card. await expect(fallback.locator('[data-kind="safe_retry_failed"][data-variant="default"]')).toBeVisible({ timeout: 30_000, @@ -50,7 +59,11 @@ test("recovery-presentation", async ({ page }) => { const out = snapOutputPath("recovery-presentation") await composeGrid( - [await capture("after side-effecting tool", sideEffect), await capture("reply never started", fallback)], + [ + await capture("after side-effecting tool", sideEffect), + await capture("after read-only tool", readOnly), + await capture("reply never started", fallback), + ], out, ) process.stdout.write(`\n[snap] recovery-presentation grid -> ${out}\n\n`) diff --git a/packages/ui/src/components/message-part/parts/notice.tsx b/packages/ui/src/components/message-part/parts/notice.tsx index ab93ab2e7..b0a66f005 100644 --- a/packages/ui/src/components/message-part/parts/notice.tsx +++ b/packages/ui/src/components/message-part/parts/notice.tsx @@ -6,19 +6,30 @@ import { Icon } from "../../icon" import { registerPartComponent } from "../registry" import "./notice.css" +// Mirrors the backend READ_ONLY_TOOLS set (run-observability/sanitize.ts): these +// tools have no external side effect. Any other tool — bash, apply_patch, or an +// unknown/custom one — is treated as side-effecting. +const READ_ONLY_TOOLS = new Set(["read", "glob", "grep", "webfetch", "tool_info"]) + registerPartComponent("notice", function NoticePartDisplay(props) { const i18n = useI18n() const data = useData() const part = () => props.part as NoticePart - // A completed tool earlier in the same turn means an external side effect - // already landed (e.g. a posted comment). Reassure the user it is done and - // not to redo it; otherwise the model's reply simply never started. Any - // completed tool qualifies — this can only over-apply the reassuring copy to - // a read-only turn (harmless), never miss a real side effect. - const afterToolRun = createMemo(() => { + // A completed *side-effecting* tool earlier in the same turn means an external + // action already landed (e.g. a posted comment). Reassure the user it is done + // and not to redo it; otherwise the model's reply simply never started. Only + // non-read-only tools qualify: read/glob/grep/webfetch/tool_info carry no side + // effect (mirrors the backend READ_ONLY_TOOLS set in run-observability/ + // sanitize.ts), so a completed grep must not falsely claim an action landed. + // Everything else — bash, apply_patch, or an unknown/custom tool — counts, + // erring toward reassurance so a real side effect is never under-warned. + const afterSideEffectingTool = createMemo(() => { const parts = data.store.part?.[part().messageID] - return Array.isArray(parts) && parts.some((p) => p.type === "tool" && p.state.status === "completed") + return ( + Array.isArray(parts) && + parts.some((p) => p.type === "tool" && p.state.status === "completed" && !READ_ONLY_TOOLS.has(p.tool)) + ) }) return ( @@ -27,19 +38,19 @@ registerPartComponent("notice", function NoticePartDisplay(props) {
- {afterToolRun() + {afterSideEffectingTool() ? i18n.t("ui.sessionTurn.notice.safeRetryFailed.sideEffect.title") : i18n.t("ui.sessionTurn.notice.safeRetryFailed.default.title")}
- {afterToolRun() + {afterSideEffectingTool() ? i18n.t("ui.sessionTurn.notice.safeRetryFailed.sideEffect.body") : i18n.t("ui.sessionTurn.notice.safeRetryFailed.default.body")}
diff --git a/packages/ui/src/components/session-safe-retry-contract.test.ts b/packages/ui/src/components/session-safe-retry-contract.test.ts index 35e5b0ccb..9d9afecec 100644 --- a/packages/ui/src/components/session-safe-retry-contract.test.ts +++ b/packages/ui/src/components/session-safe-retry-contract.test.ts @@ -32,9 +32,12 @@ test("safe retry failure renders a titled notice that adapts to a prior tool sid expect(notice).toContain(' Date: Thu, 18 Jun 2026 19:44:35 +0800 Subject: [PATCH 4/7] fix(ui): side-effect notice points at regenerating the reply, not a redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review (#1358) found the side-effect body said "Try again later / 请稍后重试" right after "the previous action already went through". After a real side effect, that nudges the user to repeat it (re-comment, re-run a command) — the exact thing #1358 says to avoid. Side-effect copy now says the action already ran and must not be repeated, and points the next step at regenerating the *reply* (or switching models), never a plain retry. The default body is unchanged: nothing landed there, so "请稍后重试" stays safe. zh copy is the user's wording; en/zht mirror it. Full-width CJK punctuation. Verify: - packages/ui: typecheck clean, full unit suite 732 pass (contract test now locks the no-redo side-effect copy and the safe default retry) - snap recovery-presentation: side-effect column reads "无需重复 … 可稍后重新生成回复" Claude-Session: https://claude.ai/code/session_01UUsFz2KqaDQpF8TzBJoBZF --- .../session-safe-retry-contract.test.ts | 21 ++++++++++++------- packages/ui/src/i18n/en.ts | 2 +- packages/ui/src/i18n/zh.ts | 2 +- packages/ui/src/i18n/zht.ts | 2 +- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/session-safe-retry-contract.test.ts b/packages/ui/src/components/session-safe-retry-contract.test.ts index 9d9afecec..e876fca55 100644 --- a/packages/ui/src/components/session-safe-retry-contract.test.ts +++ b/packages/ui/src/components/session-safe-retry-contract.test.ts @@ -47,21 +47,28 @@ test("safe retry failure renders a titled notice that adapts to a prior tool sid }) test("safe-retry notice copy names an external cause and a next step, without nudging a redo", () => { - // Side-effect case reassures the action already ran; default case does not - // claim an action happened. Both attribute the failure outside PawWork and - // give a next step. + // Side-effect case reassures the action already ran AND tells the user not to + // repeat it — it points at regenerating the reply, never a plain "retry" that + // could redo the external action (#1358). Default case makes no completion + // claim, so a plain retry stays safe. Both attribute the failure outside PawWork. expect(en).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "Action completed"') expect(en).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "Reply incomplete"') - expect(en).toMatch(/already went through[\s\S]*network or model provider[\s\S]*switch models/) + expect(en).toMatch(/already went through[\s\S]*no need to repeat[\s\S]*regenerate the reply[\s\S]*switch models/) expect(zh).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成"') expect(zh).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "回复未完成"') - expect(zh).toMatch(/上一项操作已执行[\s\S]*网络或模型服务商[\s\S]*请稍后重试,或换一个模型/) - expect(zh).toMatch(/模型回复未能生成[\s\S]*网络或模型服务商/) + // Side-effect body: no redo nudge — "无需重复" + regenerate the reply, never "重试". + expect(zh).toContain( + "上一项操作已执行,无需重复。当前网络或模型服务商连接异常,可稍后重新生成回复,或更换模型。", + ) + // Default body still offers a safe retry (nothing landed). + expect(zh).toMatch(/模型回复未能生成[\s\S]*请稍后重试/) expect(zht).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成"') expect(zht).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "回覆未完成"') - expect(zht).toMatch(/網路或模型服務商/) + expect(zht).toContain( + "上一項操作已執行,無需重複。目前網路或模型服務商連線異常,可稍後重新生成回覆,或更換模型。", + ) }) test("the old single safe-retry notice key is gone everywhere", () => { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index a2c888e1d..03ad40b22 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -79,7 +79,7 @@ export const dict: Record = { "ui.sessionTurn.retry.safeRecovery": "Recovering...", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "Action completed", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": - "The previous action already went through. The network or model provider may be having connection issues. Try again later, or switch models.", + "The previous action already went through, so no need to repeat it. The network or model provider may be having connection issues. You can regenerate the reply later, or switch models.", "ui.sessionTurn.notice.safeRetryFailed.default.title": "Reply incomplete", "ui.sessionTurn.notice.safeRetryFailed.default.body": "The model's reply couldn't be generated. The network or model provider may be having connection issues. Try again later, or switch models.", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 3ddb94084..a35efd32e 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -82,7 +82,7 @@ export const dict = { "ui.sessionTurn.retry.safeRecovery": "正在恢复…", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": - "上一项操作已执行。当前网络或模型服务商存在连接问题,请稍后重试,或换一个模型。", + "上一项操作已执行,无需重复。当前网络或模型服务商连接异常,可稍后重新生成回复,或更换模型。", "ui.sessionTurn.notice.safeRetryFailed.default.title": "回复未完成", "ui.sessionTurn.notice.safeRetryFailed.default.body": "模型回复未能生成。当前网络或模型服务商存在连接问题,请稍后重试,或换一个模型。", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index c47828173..1a3dd5a2a 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -54,7 +54,7 @@ export const dict = { "ui.sessionTurn.retry.safeRecovery": "正在恢復…", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成", "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": - "上一項操作已執行。目前網路或模型服務商存在連線問題,請稍後重試,或換一個模型。", + "上一項操作已執行,無需重複。目前網路或模型服務商連線異常,可稍後重新生成回覆,或更換模型。", "ui.sessionTurn.notice.safeRetryFailed.default.title": "回覆未完成", "ui.sessionTurn.notice.safeRetryFailed.default.body": "模型回覆未能生成。目前網路或模型服務商存在連線問題,請稍後重試,或換一個模型。", From 04d5fd39168fe2f37d7d970a814079f78ba9fdf0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 18 Jun 2026 20:16:24 +0800 Subject: [PATCH 5/7] test(opencode): update @smoke inventory for the renamed W1 connecting test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e-smoke-tagging inventory locks the exact set of @smoke test titles. Renaming the W1 test ("thinking" → "connecting indicator", #1358) left the inventory stale, failing unit-opencode (and the unit-windows-opencode-* and the `check` aggregate gate). Updated the entry to the new title and kept the list in sorted order (connecting sorts before rendered). Verify: packages/opencode `bun test test/config/e2e-smoke-tagging.test.ts` — 2 pass, 0 fail. Claude-Session: https://claude.ai/code/session_01UUsFz2KqaDQpF8TzBJoBZF --- packages/opencode/test/config/e2e-smoke-tagging.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index f6b2315f0..23c55b0b9 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -27,8 +27,8 @@ const expectedSmokeTests = [ "packages/app/e2e/prompt/first-message-reply.spec.ts:@smoke first replied message in a new session renders without page errors", "packages/app/e2e/prompt/prompt.spec.ts:@smoke can send a prompt and receive a reply", "packages/app/e2e/release-notes/release-notes-toast.spec.ts:@smoke shows subtle toast when stored version is older than current", + "packages/app/e2e/session/session-w1-contracts.spec.ts:@smoke W1 connecting indicator shows before first provider progress (nothing visible)", "packages/app/e2e/session/session-w1-contracts.spec.ts:@smoke W1 rendered turn locks chevron, selectability, and trow typography", - "packages/app/e2e/session/session-w1-contracts.spec.ts:@smoke W1 thinking indicator shows while the turn is working with nothing visible", "packages/app/e2e/settings/settings-memory.spec.ts:@smoke memory settings exposes the raw MEMORY.md controls", "packages/app/e2e/settings/settings-shell.spec.ts:@smoke back-to-app button closes the settings shell", "packages/app/e2e/settings/settings-shell.spec.ts:@smoke escape closes the settings shell", From bd9d505dba148f27248f6ef4319b502f22006895 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 18 Jun 2026 21:32:09 +0800 Subject: [PATCH 6/7] fix(recovery): classify safe_retry_failed side effect in the backend (#1358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-tool model continuation runs as a NEW assistant message (the turn loop creates one message per step), so a completed side-effecting tool and the trailing safe_retry_failed notice land on DIFFERENT messages of the same turn. The UI sees one part at a time and cannot scan sibling messages, so the earlier UI-side scan of the notice's own message could never see the tool — it would always fall back to the default copy even after bash/apply_patch ran. Make the backend the single source of truth: when writing the notice, scan the whole turn via turnHasCompletedSideEffect() and stamp a `sideEffect` flag on the NoticePart. The UI now just reads the field — no useData, no part scan, no READ_ONLY_TOOLS duplication. Side-effect classification stays in one place (RunObservability.toolEffect): bash/apply_patch/unknown count as side effects, read-only tools do not. - opencode: new pure helper safe-retry-notice.ts (+6 unit tests covering the cross-message, read-only, no-tool, still-running, unknown-tool, and different-turn cases); processor stamps sideEffect; NoticePart schema gains the optional field. - sdk: regenerate NoticePart.sideEffect (scoped 1-line). - ui: notice.tsx reads part().sideEffect; contract test asserts the field drives the copy and the old UI scan is gone. - snap: recovery-presentation rebuilt as a real cross-message topology, zh/en side by side (中英对照). Claude-Session: https://claude.ai/code/session_01UUsFz2KqaDQpF8TzBJoBZF --- .../recovery-presentation-snap-fixture.tsx | 162 ++++++++++-------- .../e2e/snap/recovery-presentation.snap.ts | 55 +++--- packages/opencode/src/session/message-v2.ts | 6 + packages/opencode/src/session/processor.ts | 7 + .../src/session/safe-retry-notice.test.ts | 47 +++++ .../opencode/src/session/safe-retry-notice.ts | 35 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + .../components/message-part/parts/notice.tsx | 37 ++-- .../session-safe-retry-contract.test.ts | 15 +- 9 files changed, 232 insertions(+), 133 deletions(-) create mode 100644 packages/opencode/src/session/safe-retry-notice.test.ts create mode 100644 packages/opencode/src/session/safe-retry-notice.ts diff --git a/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx b/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx index 086fa0dfe..e23d0c01f 100644 --- a/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx +++ b/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx @@ -4,13 +4,17 @@ import { DataProvider, I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { dict as zh } from "@opencode-ai/ui/i18n/zh" +import { dict as en } from "@opencode-ai/ui/i18n/en" import { AssistantParts } from "@opencode-ai/ui/message-part" import type { UiI18nKey, UiI18nParams } from "@opencode-ai/ui/context/i18n" -// The #1358 terminal notice rendered through the real component pipeline -// (AssistantParts → tool.tsx card + notice.tsx). Two columns cover the adaptive -// copy: a turn that ran a side-effecting tool (operation already landed) vs. a -// turn whose reply never started (no tool). +// The #1358 terminal notice through the real pipeline (AssistantParts → +// tool.tsx card + notice.tsx), in the REAL cross-message topology: a +// side-effecting tool completes in one assistant message, and the trailing +// safe_retry_failed notice lands on the NEXT assistant message of the same turn +// (the post-tool continuation runs as a new message). The notice now carries the +// backend `sideEffect` flag, so the UI reads the field instead of scanning its +// own message. Three scenarios × two languages (中英对照). const SESSION = "ses_recovery_presentation" function assistant(id: string): AssistantMessage { @@ -30,15 +34,8 @@ function assistant(id: string): AssistantMessage { } } -function text(messageID: string): TextPart { - return { - id: `${messageID}_text`, - sessionID: SESSION, - messageID, - type: "text", - text: "我帮你在 issue #1358 下留了一条评论。", - time: { start: 0, end: 1 }, - } +function text(messageID: string, body: string): TextPart { + return { id: `${messageID}_text`, sessionID: SESSION, messageID, type: "text", text: body, time: { start: 0, end: 1 } } } function bashTool(messageID: string): ToolPart { @@ -51,7 +48,7 @@ function bashTool(messageID: string): ToolPart { tool: "bash", state: { status: "completed", - input: { command: 'gh issue comment 1358 --body "已按方案排期,明天开工。"', description: "在 #1358 下留言" }, + input: { command: 'gh issue comment 1358 --body "已按方案排期。"', description: "在 #1358 下留言" }, output: "https://github.com/Astro-Han/pawwork/issues/1358#issuecomment-3920481", title: "在 #1358 下留言", metadata: {}, @@ -60,9 +57,6 @@ function bashTool(messageID: string): ToolPart { } } -// A completed read-only tool (grep) carries no side effect: it must NOT flip -// the notice to the "操作已完成" reassurance. This column proves the predicate -// excludes read-only tools and falls back to the default "回复未完成" copy. function grepTool(messageID: string): ToolPart { return { id: `${messageID}_grep`, @@ -82,87 +76,111 @@ function grepTool(messageID: string): ToolPart { } } -function searchText(messageID: string): TextPart { - return { - id: `${messageID}_text`, - sessionID: SESSION, - messageID, - type: "text", - text: "我先在代码里查了下相关实现。", - time: { start: 0, end: 1 }, - } +// `sideEffect` is what the backend writes: true when a side-effecting tool +// completed earlier in the turn (bash here), false for read-only / no tool. +function notice(messageID: string, sideEffect: boolean): NoticePart { + return { id: `${messageID}_notice`, sessionID: SESSION, messageID, type: "notice", kind: "safe_retry_failed", sideEffect, time: { created: 1 } } } -function notice(messageID: string): NoticePart { +function makeI18n(dict: Record) { return { - id: `${messageID}_notice`, - sessionID: SESSION, - messageID, - type: "notice", - kind: "safe_retry_failed", - time: { created: 1 }, + locale: () => "x", + t: (key: UiI18nKey, params?: UiI18nParams) => { + const template = dict[key] ?? en[key] ?? String(key) + return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => String(params?.[String(rawKey)] ?? "")) + }, } } -const i18n = { - locale: () => "zh", - t: (key: UiI18nKey, params?: UiI18nParams) => { - const template = zh[key] ?? String(key) - return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => String(params?.[String(rawKey)] ?? "")) - }, -} +type MsgParts = { message: AssistantMessage; parts: (TextPart | ToolPart | NoticePart)[] } -// AssistantParts reads parts from the DataProvider store by messageID; the real -// notice.tsx reads the same store to pick the side-effect vs. default copy. -function Turn(props: { message: AssistantMessage; parts: (TextPart | ToolPart | NoticePart)[] }) { +// AssistantParts renders each message's parts in order, so a two-message turn +// shows the tool card (message A) above the notice (message B) — the real split. +function Turn(props: { messages: MsgParts[] }) { + const store = { + message: {}, + part: Object.fromEntries(props.messages.map((m) => [m.message.id, m.parts])), + } return ( - - + + m.message)} /> ) } -function RecoveryPresentationSnapFixture() { - const sideEffect = assistant("msg_side_effect") - const readOnly = assistant("msg_read_only") - const reply = assistant("msg_reply") +// Scenarios built fresh per band so each language's Turn gets an isolated store. +function sideEffectTurn(): MsgParts[] { + const a = assistant("msg_se_a") + const b = assistant("msg_se_b") + return [ + { message: a, parts: [text(a.id, "我帮你在 issue #1358 下留了一条评论。"), bashTool(a.id)] }, + { message: b, parts: [notice(b.id, true)] }, + ] +} +function readOnlyTurn(): MsgParts[] { + const a = assistant("msg_ro_a") + const b = assistant("msg_ro_b") + return [ + { message: a, parts: [text(a.id, "我先在代码里查了下相关实现。"), grepTool(a.id)] }, + { message: b, parts: [notice(b.id, false)] }, + ] +} +function noToolTurn(): MsgParts[] { + const b = assistant("msg_nt_b") + return [{ message: b, parts: [notice(b.id, false)] }] +} + +function Band(props: { dict: Record; label: string }) { return ( - - - {/* Opaque full-viewport cover at max z-index so the app's dev chrome - (debug bar, server-health toast) renders behind the captured grid. */} -
+ +
+
+ {props.label} +
+
- +
- +
- +
- +
) } +function RecoveryPresentationSnapFixture() { + return ( + + {/* Opaque full-viewport cover at max z-index so the app's dev chrome + (debug bar, server-health toast) renders behind the captured grid. */} +
+ + +
+
+ ) +} + export function mountRecoveryPresentationSnapFixture(root: HTMLElement) { render(() => , root) } diff --git a/packages/app/e2e/snap/recovery-presentation.snap.ts b/packages/app/e2e/snap/recovery-presentation.snap.ts index 364da1a74..3509d69d0 100644 --- a/packages/app/e2e/snap/recovery-presentation.snap.ts +++ b/packages/app/e2e/snap/recovery-presentation.snap.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url" import { test } from "../fixtures" import { composeGrid, snapOutputPath, type Shot } from "./_compose" -test.use({ viewport: { width: 840, height: 420 }, deviceScaleFactor: 2 }) +test.use({ viewport: { width: 1200, height: 760 }, deviceScaleFactor: 2 }) const fixturePath = fileURLToPath(new URL("./fixtures/recovery-presentation-snap-fixture.tsx", import.meta.url)) @@ -34,37 +34,36 @@ test("recovery-presentation", async ({ page }) => { mod.mountRecoveryPresentationSnapFixture(root) }, `/@fs/${fixturePath}`) - const sideEffect = page.locator('[data-snap="side-effect"]') - const readOnly = page.locator('[data-snap="read-only"]') - const fallback = page.locator('[data-snap="default"]') + const zh = page.locator('[data-lang="中文"]') + const en = page.locator('[data-lang="English"]') - // Side-effect turn: completed bash tool card above, reassuring side-effect copy. - await expect(sideEffect).toContainText("在 #1358 下留言", { timeout: 30_000 }) - await expect(sideEffect.locator('[data-kind="safe_retry_failed"][data-variant="side-effect"]')).toBeVisible() - await expect(sideEffect).toContainText("操作已完成") + // Side-effect, REAL cross-message topology: the bash card lives on message A, + // the notice on message B; the backend `sideEffect` flag still drives the + // reassuring copy that names "no redo". + const zhSide = zh.locator('[data-snap="side-effect"]') + await expect(zhSide).toContainText("在 #1358 下留言", { timeout: 30_000 }) + await expect(zhSide.locator('[data-kind="safe_retry_failed"][data-variant="side-effect"]')).toBeVisible() + await expect(zhSide).toContainText("操作已完成") + await expect(zhSide).toContainText("无需重复") - // Read-only turn: a completed grep ran, but it carries no side effect, so the - // notice must NOT claim "操作已完成" — it falls back to the default copy. - await expect(readOnly.locator('[data-kind="safe_retry_failed"][data-variant="default"]')).toBeVisible({ - timeout: 30_000, - }) - await expect(readOnly).toContainText("回复未完成") - await expect(readOnly).not.toContainText("操作已完成") + // Read-only turn: a grep ran, but it carries no side effect, so the backend + // sets sideEffect=false and the notice falls back to the default copy. + const zhRead = zh.locator('[data-snap="read-only"]') + await expect(zhRead.locator('[data-kind="safe_retry_failed"][data-variant="default"]')).toBeVisible() + await expect(zhRead).toContainText("回复未完成") + await expect(zhRead).not.toContainText("操作已完成") - // No-tool turn: default copy, no tool card. - await expect(fallback.locator('[data-kind="safe_retry_failed"][data-variant="default"]')).toBeVisible({ - timeout: 30_000, - }) - await expect(fallback).toContainText("回复未完成") + // No-tool turn: default copy. + await expect(zh.locator('[data-snap="default"] [data-variant="default"]')).toBeVisible() + + // English mirrors the same three scenarios. + const enSide = en.locator('[data-snap="side-effect"]') + await expect(enSide.locator('[data-variant="side-effect"]')).toBeVisible({ timeout: 30_000 }) + await expect(enSide).toContainText("Action completed") + await expect(en.locator('[data-snap="read-only"] [data-variant="default"]')).toBeVisible() + await expect(en.locator('[data-snap="read-only"]')).toContainText("Reply incomplete") const out = snapOutputPath("recovery-presentation") - await composeGrid( - [ - await capture("after side-effecting tool", sideEffect), - await capture("after read-only tool", readOnly), - await capture("reply never started", fallback), - ], - out, - ) + await composeGrid([await capture("中文", zh), await capture("English", en)], out) process.stdout.write(`\n[snap] recovery-presentation grid -> ${out}\n\n`) }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ffc89124c..ec45707ff 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -259,6 +259,12 @@ export type ReasoningPart = z.infer export const NoticePart = PartBase.extend({ type: z.literal("notice"), kind: z.literal("safe_retry_failed"), + // True when a side-effecting tool already completed earlier in this turn — + // possibly on a sibling assistant message, since the post-tool continuation + // runs as a new message (#1358). The backend is the single source of truth so + // the UI need not (and cannot reliably) scan sibling messages or reclassify + // tools. Drives the "Action completed — don't repeat it" notice copy. + sideEffect: z.boolean().optional(), time: z.object({ created: z.number(), }), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 9f0999e34..94f9521f7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -28,6 +28,7 @@ import { TurnChange } from "./turn-change" import { LLMTrace } from "./llm-trace" import type { RunIncident } from "./run-incident" import { RunObservability } from "./run-observability" +import { turnHasCompletedSideEffect } from "./safe-retry-notice" import { currentLifecycleCloseAction, isLifecycleClosing, @@ -1764,12 +1765,18 @@ export const layer: Layer.Layer< ) { ctx.streamError = true yield* removeReasoningForAttempt(attemptID) + // Scan the whole turn (not just this message): the side-effecting tool + // ran in an earlier step's assistant message, while this notice lands + // on the failed continuation's message (#1358). + const parentID = ctx.assistantMessage.parentID + const sideEffect = parentID ? turnHasCompletedSideEffect(turnMessages(parentID), parentID) : false yield* session.updatePart({ id: PartID.ascending(), sessionID: ctx.sessionID, messageID: ctx.assistantMessage.id, type: "notice", kind: "safe_retry_failed", + sideEffect, time: { created: Date.now() }, } satisfies MessageV2.NoticePart) yield* status.set(ctx.sessionID, { type: "idle" }) diff --git a/packages/opencode/src/session/safe-retry-notice.test.ts b/packages/opencode/src/session/safe-retry-notice.test.ts new file mode 100644 index 000000000..08a2e5631 --- /dev/null +++ b/packages/opencode/src/session/safe-retry-notice.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test" +import type { MessageV2 } from "./message-v2" +import { turnHasCompletedSideEffect } from "./safe-retry-notice" + +const PARENT = "msg_user_turn" as unknown as NonNullable + +// Minimal structural fixtures: the helper only reads info.role, info.parentID, +// part.type, part.state.status, and part.tool. +function assistant(parentID: string, parts: unknown[]): MessageV2.WithParts { + return { info: { role: "assistant", parentID }, parts } as unknown as MessageV2.WithParts +} +function tool(name: string, status: string) { + return { type: "tool", tool: name, state: { status } } +} +function notice() { + return { type: "notice", kind: "safe_retry_failed" } +} + +test("side-effecting tool completed on a sibling message of the turn → true", () => { + // #1358 real topology: bash completed in message A; the notice lands on B. + const messages = [assistant(PARENT, [tool("bash", "completed")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(true) +}) + +test("only a completed read-only tool → false (no side effect to claim)", () => { + const messages = [assistant(PARENT, [tool("grep", "completed")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(false) +}) + +test("no tool, only the notice → false", () => { + expect(turnHasCompletedSideEffect([assistant(PARENT, [notice()])], PARENT)).toBe(false) +}) + +test("side-effecting tool still running (not completed) → false", () => { + const messages = [assistant(PARENT, [tool("bash", "running")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(false) +}) + +test("unknown/custom tool counts as side-effecting (errs toward reassurance)", () => { + const messages = [assistant(PARENT, [tool("deploy_thing", "completed")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(true) +}) + +test("a completed side-effecting tool from a different turn is ignored", () => { + const messages = [assistant("other_turn", [tool("bash", "completed")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(false) +}) diff --git a/packages/opencode/src/session/safe-retry-notice.ts b/packages/opencode/src/session/safe-retry-notice.ts new file mode 100644 index 000000000..0f85e6701 --- /dev/null +++ b/packages/opencode/src/session/safe-retry-notice.ts @@ -0,0 +1,35 @@ +import type { MessageV2 } from "./message-v2" +import { RunObservability } from "./run-observability" + +/** + * True when a side-effecting tool completed anywhere in the turn — possibly on a + * sibling assistant message. + * + * #1358: the post-tool model continuation runs as a NEW assistant message (the + * turn loop in prompt.ts creates one message per step), so a completed + * side-effecting tool and the trailing `safe_retry_failed` notice land on + * DIFFERENT messages of the same turn. The notice's own message therefore can't + * be scanned for the tool — the whole turn must be. Only the backend can do this + * reliably: the UI sees one part at a time and must not reclassify tools. + * + * "Side-effecting" is the backend's own classification (`toolEffect().unsafe`): + * bash / apply_patch / unknown count; read-only tools (read/glob/grep/webfetch/ + * tool_info) do not. Unknown errs toward side-effecting so a real side effect is + * never under-warned. Drives the "Action completed — don't repeat it" copy. + */ +export function turnHasCompletedSideEffect( + messages: readonly MessageV2.WithParts[], + parentID: NonNullable, +): boolean { + return messages.some( + (message) => + message.info.role === "assistant" && + message.info.parentID === parentID && + message.parts.some( + (part) => + part.type === "tool" && + part.state.status === "completed" && + RunObservability.toolEffect(part.tool).unsafe, + ), + ) +} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6197cc57d..668b5a125 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1018,6 +1018,7 @@ export type NoticePart = { messageID: string type: "notice" kind: "safe_retry_failed" + sideEffect?: boolean time: { created: number } diff --git a/packages/ui/src/components/message-part/parts/notice.tsx b/packages/ui/src/components/message-part/parts/notice.tsx index b0a66f005..091a816fe 100644 --- a/packages/ui/src/components/message-part/parts/notice.tsx +++ b/packages/ui/src/components/message-part/parts/notice.tsx @@ -1,56 +1,39 @@ -import { Match, Switch, createMemo } from "solid-js" +import { Match, Switch } from "solid-js" import type { NoticePart } from "@opencode-ai/sdk/v2" -import { useData } from "../../../context" import { useI18n } from "../../../context/i18n" import { Icon } from "../../icon" import { registerPartComponent } from "../registry" import "./notice.css" -// Mirrors the backend READ_ONLY_TOOLS set (run-observability/sanitize.ts): these -// tools have no external side effect. Any other tool — bash, apply_patch, or an -// unknown/custom one — is treated as side-effecting. -const READ_ONLY_TOOLS = new Set(["read", "glob", "grep", "webfetch", "tool_info"]) - registerPartComponent("notice", function NoticePartDisplay(props) { const i18n = useI18n() - const data = useData() const part = () => props.part as NoticePart - // A completed *side-effecting* tool earlier in the same turn means an external - // action already landed (e.g. a posted comment). Reassure the user it is done - // and not to redo it; otherwise the model's reply simply never started. Only - // non-read-only tools qualify: read/glob/grep/webfetch/tool_info carry no side - // effect (mirrors the backend READ_ONLY_TOOLS set in run-observability/ - // sanitize.ts), so a completed grep must not falsely claim an action landed. - // Everything else — bash, apply_patch, or an unknown/custom tool — counts, - // erring toward reassurance so a real side effect is never under-warned. - const afterSideEffectingTool = createMemo(() => { - const parts = data.store.part?.[part().messageID] - return ( - Array.isArray(parts) && - parts.some((p) => p.type === "tool" && p.state.status === "completed" && !READ_ONLY_TOOLS.has(p.tool)) - ) - }) - + // `sideEffect` is set by the backend when a side-effecting tool already + // completed earlier in this turn — possibly on a sibling assistant message, + // since the post-tool continuation runs as a new message (#1358). The UI can't + // see sibling messages and must not reclassify tools, so it trusts the field: + // true → reassure the action landed and not to redo it; false → the reply + // simply never started. return (
- {afterSideEffectingTool() + {part().sideEffect ? i18n.t("ui.sessionTurn.notice.safeRetryFailed.sideEffect.title") : i18n.t("ui.sessionTurn.notice.safeRetryFailed.default.title")}
- {afterSideEffectingTool() + {part().sideEffect ? i18n.t("ui.sessionTurn.notice.safeRetryFailed.sideEffect.body") : i18n.t("ui.sessionTurn.notice.safeRetryFailed.default.body")}
diff --git a/packages/ui/src/components/session-safe-retry-contract.test.ts b/packages/ui/src/components/session-safe-retry-contract.test.ts index e876fca55..600f2dae1 100644 --- a/packages/ui/src/components/session-safe-retry-contract.test.ts +++ b/packages/ui/src/components/session-safe-retry-contract.test.ts @@ -32,12 +32,15 @@ test("safe retry failure renders a titled notice that adapts to a prior tool sid expect(notice).toContain(' Date: Thu, 18 Jun 2026 22:05:45 +0800 Subject: [PATCH 7/7] test(recovery): prove safe_retry_failed copy by rendering, not source grep (#1358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the P3 review note: the contract test asserted the notice's behavior by grepping its source for `part().sideEffect`, "no useData", "no READ_ONLY_TOOLS", etc. — brittle, and it never actually rendered anything. Replace that block with a real render (the repo's Vite-SSR fixture pattern): mount the notice with ONLY the backend `sideEffect` flag — no tool part, no DataProvider, no turn context — and assert the rendered copy: - sideEffect=true → "Action completed" / 操作已完成, variant=side-effect, no redo - sideEffect=false → "Reply incomplete" / 回复未完成, variant=default - sideEffect=undefined (older notices) → safe default Because nothing tool-shaped is ever mounted, a correct copy proves the UI reads the field alone and never scans or classifies tools. The same render covers the zh locale, so a copy regression in either variant fails the test. The contract file keeps only the locale-content guards (no-redo wording, en/zh/ zht parity, old flat key removed) and the retry status-row check; the brittle component source grep is gone. Claude-Session: https://claude.ai/code/session_01UUsFz2KqaDQpF8TzBJoBZF --- .../message-part/parts/notice-render.test.tsx | 105 ++++++++++++++++++ .../session-safe-retry-contract.test.ts | 30 +---- .../test/fixtures/notice-render.fixture.tsx | 69 ++++++++++++ 3 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/components/message-part/parts/notice-render.test.tsx create mode 100644 packages/ui/test/fixtures/notice-render.fixture.tsx diff --git a/packages/ui/src/components/message-part/parts/notice-render.test.tsx b/packages/ui/src/components/message-part/parts/notice-render.test.tsx new file mode 100644 index 000000000..5dda1acf2 --- /dev/null +++ b/packages/ui/src/components/message-part/parts/notice-render.test.tsx @@ -0,0 +1,105 @@ +import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test" +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { createRequire } from "module" +import { createServer, type ViteDevServer } from "vite" +import solidPlugin from "vite-plugin-solid" + +// Real render of the notice part (#1358): the side-effect/default copy must be +// driven by the backend `sideEffect` field alone. The notice carries no tool +// part and no DataProvider, so a correct copy proves the UI never needs to scan +// or classify tools — replacing the earlier brittle source-string assertions. + +const require = createRequire(import.meta.url) +const solidWeb = require.resolve("solid-js/web/dist/web.js") +const solidCore = require.resolve("solid-js/dist/solid.js") + +let server: ViteDevServer | undefined +let registeredDom = false + +beforeAll(async () => { + if (typeof document === "undefined" || typeof window === "undefined") { + GlobalRegistrator.register() + registeredDom = true + } + + server = await createServer({ + root: new URL("../../../..", import.meta.url).pathname, + configFile: false, + plugins: [solidPlugin({ solid: { generate: "dom" } })], + resolve: { + alias: { + "solid-js/web": solidWeb, + "solid-js": solidCore, + }, + }, + server: { middlewareMode: true }, + appType: "custom", + logLevel: "silent", + ssr: { noExternal: ["@kobalte/core", "solid-js"] }, + }) +}) + +beforeEach(() => { + document.body.textContent = "" +}) + +afterAll(async () => { + await server?.close() + if (registeredDom) GlobalRegistrator.unregister() +}) + +async function loadFixture(): Promise { + if (!server) throw new Error("Vite server not initialized") + return (await server.ssrLoadModule( + "/test/fixtures/notice-render.fixture.tsx", + )) as typeof import("../../../../test/fixtures/notice-render.fixture") +} + +test("sideEffect=true renders the reassuring 'action completed' copy, with no tool in sight", async () => { + const { mountNotice } = await loadFixture() + const view = mountNotice(true) + + expect(view.variant()).toBe("side-effect") + expect(view.title()).toBe("Action completed") + expect(view.body()).toContain("no need to repeat it") + // The copy was chosen from the field, not from any rendered/scanned tool. + expect(view.toolCard()).toBeNull() + + view.dispose() +}) + +test("sideEffect=false falls back to the default 'reply incomplete' copy", async () => { + const { mountNotice } = await loadFixture() + const view = mountNotice(false) + + expect(view.variant()).toBe("default") + expect(view.title()).toBe("Reply incomplete") + expect(view.body()).toContain("couldn't be generated") + + view.dispose() +}) + +test("a missing sideEffect field (older notices) is treated as the safe default", async () => { + const { mountNotice } = await loadFixture() + const view = mountNotice(undefined) + + expect(view.variant()).toBe("default") + expect(view.title()).toBe("Reply incomplete") + + view.dispose() +}) + +test("the same field drives localized copy (zh)", async () => { + const { mountNotice, dicts } = await loadFixture() + + const side = mountNotice(true, dicts.zh) + expect(side.variant()).toBe("side-effect") + expect(side.title()).toBe("操作已完成") + expect(side.body()).toContain("无需重复") + side.dispose() + + const plain = mountNotice(false, dicts.zh) + expect(plain.variant()).toBe("default") + expect(plain.title()).toBe("回复未完成") + plain.dispose() +}) diff --git a/packages/ui/src/components/session-safe-retry-contract.test.ts b/packages/ui/src/components/session-safe-retry-contract.test.ts index 600f2dae1..c2f0ce38d 100644 --- a/packages/ui/src/components/session-safe-retry-contract.test.ts +++ b/packages/ui/src/components/session-safe-retry-contract.test.ts @@ -2,7 +2,6 @@ import { expect, test } from "bun:test" import { readdirSync, readFileSync } from "node:fs" const retry = readFileSync(new URL("./session-retry.tsx", import.meta.url), "utf8") -const notice = readFileSync(new URL("./message-part/parts/notice.tsx", import.meta.url), "utf8") const en = readFileSync(new URL("../i18n/en.ts", import.meta.url), "utf8") const zh = readFileSync(new URL("../i18n/zh.ts", import.meta.url), "utf8") const zht = readFileSync(new URL("../i18n/zht.ts", import.meta.url), "utf8") @@ -22,32 +21,9 @@ test("recovery retry uses a lightweight status row instead of the error card", ( expect(retry).toContain('') }) -test("safe retry failure renders a titled notice that adapts to a prior tool side effect", () => { - expect(notice).toContain('registerPartComponent("notice"') - expect(notice).toContain('part().kind === "safe_retry_failed"') - expect(notice).toContain('data-kind="safe_retry_failed"') - // Separated, calm presentation (#1358): a stroked status icon + ink title, - // not the old weak single-line caption. - expect(notice).toContain('data-variant=') - expect(notice).toContain(' { // Side-effect case reassures the action already ran AND tells the user not to diff --git a/packages/ui/test/fixtures/notice-render.fixture.tsx b/packages/ui/test/fixtures/notice-render.fixture.tsx new file mode 100644 index 000000000..a71bf8eb9 --- /dev/null +++ b/packages/ui/test/fixtures/notice-render.fixture.tsx @@ -0,0 +1,69 @@ +import type { Message, NoticePart } from "@opencode-ai/sdk/v2" +import { Dynamic, render } from "solid-js/web" +import { I18nProvider, type UiI18n, type UiI18nKey, type UiI18nParams } from "../../src/context/i18n" +import { dict as en } from "../../src/i18n/en" +import { dict as zh } from "../../src/i18n/zh" +import { PART_MAPPING } from "../../src/components/message-part/registry" +// Importing the part module registers the "notice" component into PART_MAPPING. +import "../../src/components/message-part/parts/notice" + +export const dicts = { en, zh } + +function i18nFor(dict: Record): UiI18n { + return { + locale: () => "test", + t: (key: UiI18nKey, params?: UiI18nParams) => { + const template = dict[key] ?? en[key] ?? String(key) + return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, raw) => String(params?.[String(raw)] ?? "")) + }, + } +} + +// A notice carrying ONLY the backend `sideEffect` flag — no tool part, no +// DataProvider, no turn context. If the UI still picks the right copy, it must +// be reading the field alone, not scanning/classifying tools (#1358). +function notice(sideEffect: boolean | undefined): NoticePart { + return { + id: "prt_notice", + sessionID: "ses_test", + messageID: "msg_test", + type: "notice", + kind: "safe_retry_failed", + ...(sideEffect === undefined ? {} : { sideEffect }), + time: { created: 1 }, + } +} + +// The component reads only props.part; message is required by the prop type but +// never touched, so a stub stands in. +const MESSAGE = { id: "msg_test" } as unknown as Message + +export function mountNotice(sideEffect: boolean | undefined, dict: Record = en) { + const Comp = PART_MAPPING["notice"] + if (!Comp) throw new Error("notice part component is not registered") + const host = document.createElement("div") + document.body.append(host) + + const dispose = render( + () => ( + + + + ), + host, + ) + + const root = () => host.querySelector("[data-component='notice-part']") as HTMLElement | null + return { + host, + variant: () => root()?.getAttribute("data-variant") ?? null, + title: () => host.querySelector("[data-slot='notice-title']")?.textContent ?? null, + body: () => host.querySelector("[data-slot='notice-body']")?.textContent ?? null, + // No tool card is ever mounted — proves the copy did not come from a tool scan. + toolCard: () => host.querySelector("[data-component='tool']"), + dispose: () => { + dispose() + host.remove() + }, + } +}