diff --git a/packages/webui/src/client/features/transcript/contracts/transcript-view.ts b/packages/webui/src/client/features/transcript/contracts/transcript-view.ts index ee861649f..fef2615e2 100644 --- a/packages/webui/src/client/features/transcript/contracts/transcript-view.ts +++ b/packages/webui/src/client/features/transcript/contracts/transcript-view.ts @@ -79,9 +79,11 @@ export type WebuiTranscriptItem = readonly kind: "activity"; readonly messageId: string; readonly turnId?: string; - readonly activityType: "cognitive" | "compaction" | "delegation" | "agent_joined" | "asset_list"; + readonly activityType: "cognitive" | "compaction" | "delegation" | "agent_joined" | "asset_list" | "runtime_warning"; readonly text?: string; readonly detail?: Record; + /** Parsed `runtime.warning` envelope, carried structured so the renderer never re-parses JSON. */ + readonly warning?: WebuiRuntimeWarningView; readonly timestamp?: number; }; @@ -102,4 +104,27 @@ export type WebuiTranscriptActivityPart = | { readonly type: "tool"; readonly tool: Record } | { readonly type: "delegation"; readonly message: Record } | { readonly type: "agent_joined"; readonly agent: Record } - | { readonly type: "asset_list"; readonly assets: readonly Record[] }; + | { readonly type: "asset_list"; readonly assets: readonly Record[] } + | { readonly type: "runtime_warning"; readonly title: string; readonly meta?: string; readonly text: string }; + +/* Runtime warning surface — the humanized form of a `runtime.warning` + * SystemEvent envelope (see `agent-core/src/event-bridge/runtime-warning.ts` + * for the producer; the wire contract is untouched, only the display). */ + +/** The parsed envelope. `message` is the producer's human-readable reason. */ +export interface WebuiRuntimeWarningView { + readonly message: string; + /** Producer-owned category (e.g. `system-message`); consumers must not infer it from the message text. */ + readonly category?: string; + readonly code?: string; + readonly pluginName?: string; + readonly hookEvent?: string; + readonly source?: string; +} + +/** The composed row: what the reader sees instead of the JSON envelope. */ +export interface WebuiRuntimeWarningLabels { + readonly title: string; + readonly meta?: string; + readonly body: string; +} diff --git a/packages/webui/src/client/features/transcript/projection/message-parts.ts b/packages/webui/src/client/features/transcript/projection/message-parts.ts index 9369c8972..dbac3ffa2 100644 --- a/packages/webui/src/client/features/transcript/projection/message-parts.ts +++ b/packages/webui/src/client/features/transcript/projection/message-parts.ts @@ -14,6 +14,8 @@ import { basenamePath } from "../../../projection/common/paths.js"; import type { WebuiQuestionnaireResponseAnswer, WebuiQuestionnaireResponseSummary, + WebuiRuntimeWarningLabels, + WebuiRuntimeWarningView, } from "../contracts/transcript-view.js"; export interface WebuiMessageForParts { @@ -88,7 +90,12 @@ export type WebuiMessagePart = readonly type: "questionnaire_response"; readonly summary: WebuiQuestionnaireResponseSummary; } - | { readonly id: string; readonly type: "asset_list"; readonly assets: readonly WebuiDeliveredAsset[] }; + | { readonly id: string; readonly type: "asset_list"; readonly assets: readonly WebuiDeliveredAsset[] } + | { + readonly id: string; + readonly type: "runtime_warning"; + readonly warning: WebuiRuntimeWarningView; + }; export interface WebuiTurnMessageGroup { readonly key: string; @@ -235,6 +242,78 @@ function isTodoUpdatedEventMessage(message: WebuiMessageForParts): boolean { } } +/** + * Parse a serialized `runtime.warning` SystemEvent envelope. + * + * The runtime persists and fans out these events as an ordinary + * `MsgType.SystemEvent` whose `msg_content` is the JSON envelope built by + * `agent-core/src/event-bridge/runtime-warning.ts`. Left alone the transcript + * renders that whole string as an assistant message — the user reads wire + * JSON instead of the warning. Parsing here (the wire contract untouched) is + * what makes the envelope displayable. + * + * Detection is deliberately NOT a prefix match: the producer's `JSON.stringify` + * output is an implementation detail (field order, spacing), and a parser that + * hard-codes it fails silently the day the producer reformats — the raw JSON + * would come back with no error anywhere. Any trimmed content that starts with + * `{` is parsed and the parsed `eventType` is what decides, so a reordered or + * re-spaced envelope parses identically. `terminalSequence` is deliberately + * dropped: it carries allowlisted terminal bytes that only a live TUI surface + * may execute, and a browser must never render or act on them. + */ +export function parseRuntimeWarningEventMessage( + message: WebuiMessageForParts, +): WebuiRuntimeWarningView | undefined { + const content = message.msgContent?.trim(); + if (!content?.startsWith("{")) return undefined; + let parsed: Record | undefined; + try { + parsed = recordValue(JSON.parse(content)); + } catch { + return undefined; + } + if (parsed?.eventType !== "runtime.warning") return undefined; + const body = stringValue(parsed, "message")?.trim(); + if (!body) return undefined; + const category = stringValue(parsed, "category")?.trim(); + const code = stringValue(parsed, "code")?.trim(); + const pluginName = stringValue(parsed, "pluginName", "plugin_name")?.trim(); + const hookEvent = stringValue(parsed, "hookEvent", "hook_event")?.trim(); + const source = stringValue(parsed, "source")?.trim(); + return { + message: body, + ...(category ? { category } : {}), + ...(code ? { code } : {}), + ...(pluginName ? { pluginName } : {}), + ...(hookEvent ? { hookEvent } : {}), + ...(source ? { source } : {}), + }; +} + +/** + * Compose the warning row's human-readable labels. + * + * The title names the surface and the producer-owned category; the meta line + * names where it came from when the producer said (plugin + hook event). The + * body is the reason verbatim — the one field that is already prose, so it is + * never reworded, only trimmed. + */ +export function describeRuntimeWarning( + warning: WebuiRuntimeWarningView, +): WebuiRuntimeWarningLabels { + const title = warning.category + ? `运行时告警 · ${warning.category}` + : "运行时告警"; + const meta = [warning.pluginName, warning.hookEvent] + .filter((value): value is string => Boolean(value?.trim())) + .join(" · "); + return { + title, + ...(meta ? { meta } : {}), + body: warning.message, + }; +} + function decodeXmlAttribute(value: string): string { return value.replace(/"/giu, '"').replace(/'/giu, "'").replace(/</giu, "<").replace(/>/giu, ">").replace(/&/giu, "&"); } @@ -377,6 +456,14 @@ export function projectMessageParts( } return [...ordered, ...projectSyntheticParts(message)]; } + // A `runtime.warning` SystemEvent envelope is persisted JSON, but this one + // is the user's to read. Reached only on the msgContent path: the parts + // branch above has already returned, so a message that carries Desktop + // parts keeps its tool calls and thinking even if its content also looks + // like an envelope. + const runtimeWarning = parseRuntimeWarningEventMessage(message); + if (runtimeWarning) + return [{ id: `runtime-warning-${message.msgId}`, type: "runtime_warning", warning: runtimeWarning }]; const parts: WebuiMessagePart[] = []; if (message.thinkingContent?.trim()) { parts.push({ diff --git a/packages/webui/src/client/features/transcript/projection/message-projection.ts b/packages/webui/src/client/features/transcript/projection/message-projection.ts index 8ba317edc..ff65ab561 100644 --- a/packages/webui/src/client/features/transcript/projection/message-projection.ts +++ b/packages/webui/src/client/features/transcript/projection/message-projection.ts @@ -293,6 +293,19 @@ export function projectWebuiMessage( summary: part.summary, ...(normalized.timestamp !== undefined ? { timestamp: normalized.timestamp } : {}), }; + } else if (part.type === "runtime_warning") { + // A humanized warning row, not an assistant message: the envelope's + // `message` is the body, and the structured fields ride along so the + // renderer composes the title without re-parsing JSON. + item = { + kind: "activity", + activityType: "runtime_warning", + messageId: normalized.msgId, + ...turn, + text: part.warning.message, + warning: part.warning, + ...(normalized.timestamp !== undefined ? { timestamp: normalized.timestamp } : {}), + }; } if (item) orderedItems.push(item); } diff --git a/packages/webui/src/client/features/transcript/projection/transcript-projection.ts b/packages/webui/src/client/features/transcript/projection/transcript-projection.ts index e9af3cf56..fa7c08d37 100644 --- a/packages/webui/src/client/features/transcript/projection/transcript-projection.ts +++ b/packages/webui/src/client/features/transcript/projection/transcript-projection.ts @@ -5,6 +5,7 @@ import type { WebuiClientMessage, WebuiClientMessagePage, WebuiQueryCollapseView import type { WebuiTranscriptItem, WebuiTranscriptActivityPart, WebuiTranscriptProcessSegment } from "../contracts/transcript-view.js"; import type { WebuiStreamMessage } from "../../turn/contracts/stream-state.js"; import { readUsageNumber } from "../contracts/message-stream.js"; +import { describeRuntimeWarning } from "./message-parts.js"; /** A render block: one user bubble, or one assistant turn. The transcript * renderer iterates these groups; per-block fields tell it how long the turn @@ -179,6 +180,10 @@ export function projectWebuiProcessSegments( else if (activity.activityType === "asset_list") parts = [{ type: "asset_list", assets: Array.isArray(activity.detail?.assets) ? activity.detail.assets.flatMap((asset) => asset && typeof asset === "object" && !Array.isArray(asset) ? [asset as Record] : []) : [] }]; else if (activity.activityType === "cognitive") parts = [{ type: "cognitive", text: activity.text ?? "" }]; else if (activity.activityType === "compaction") parts = [{ type: "compaction", text: activity.text ?? "" }]; + else if (activity.activityType === "runtime_warning" && activity.warning) { + const labels = describeRuntimeWarning(activity.warning); + parts = [{ type: "runtime_warning", title: labels.title, ...(labels.meta ? { meta: labels.meta } : {}), text: labels.body }]; + } else parts = []; } const next: WebuiTranscriptProcessSegment = item.kind === "thinking" diff --git a/packages/webui/src/client/features/transcript/ui/AssistantBody.tsx b/packages/webui/src/client/features/transcript/ui/AssistantBody.tsx index aad9ef33f..c6fe045ea 100644 --- a/packages/webui/src/client/features/transcript/ui/AssistantBody.tsx +++ b/packages/webui/src/client/features/transcript/ui/AssistantBody.tsx @@ -98,6 +98,8 @@ function renderActivityParts( } index -= 1; rows.push({ messageId, element: }); + } else if (part.type === "runtime_warning") { + rows.push({ messageId, element:
{part.title}{part.meta ? {part.meta} : null}
}); } else { rows.push({ messageId, element: }); } @@ -264,8 +266,14 @@ export function WebuiAssistantBody({ ); const activityEntryCount = orderedProcessEntries.filter((entry) => entry.part.type !== "text").length; const hasMergedActivity = activityEntryCount > 1; + // A runtime warning is a notification the reader came to see. Without this + // a turn whose only non-thinking activity is the warning renders it inside + // the collapsed process disclosure — delivered, but invisible. Any warning + // in the entries expands the disclosure, which is a superset of that case + // and the intended behaviour: a warning is never delivered collapsed. + const hasRuntimeWarning = orderedProcessEntries.some((entry) => entry.part.type === "runtime_warning"); const expandProcessByDefault = processInitiallyExpanded ?? ( - hasMergedActivity || orderedProcessEntries.filter((entry) => entry.part.type === "thinking").length === 1 + hasMergedActivity || hasRuntimeWarning || orderedProcessEntries.filter((entry) => entry.part.type === "thinking").length === 1 ); const hasProcessDuration = typeof processingStartedAtMs === "number" || diff --git a/packages/webui/src/client/features/transcript/ui/transcript.css b/packages/webui/src/client/features/transcript/ui/transcript.css index 94d49aac5..44d1e838f 100644 --- a/packages/webui/src/client/features/transcript/ui/transcript.css +++ b/packages/webui/src/client/features/transcript/ui/transcript.css @@ -495,6 +495,10 @@ .webui-tool-detail-label { overflow: hidden; color: var(--text_default_tertiary); font-size: 14px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } .webui-tool-detail-label.is-error { color: var(--text_status_error); } .webui-tool-detail-section pre { max-height: 180px; overflow: auto; margin: 0; color: var(--text_default_secondary); font-size: 14px; line-height: 20px; white-space: pre-wrap; word-break: break-all; } + .webui-runtime-warning { display: flex; min-width: 0; flex-direction: column; gap: var(--spacing_4); border-left: 1px solid var(--orange_200); padding-left: var(--spacing_12); color: var(--text_default_secondary); font-size: 14px; line-height: 20px; } + .webui-runtime-warning-title { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: var(--spacing_4); color: var(--orange_500); font-size: 14px; line-height: 20px; } + .webui-runtime-warning-icon { flex: 0 0 14px; } + .webui-runtime-warning-meta { color: var(--text_default_tertiary); font-size: 12px; line-height: 16px; } .webui-agent-delegation { display: flex; min-width: 0; flex-direction: column; gap: var(--spacing_4); border-left: 1px solid var(--border_default); padding-left: var(--spacing_12); color: var(--text_default_secondary); font-size: 14px; line-height: 20px; } .webui-agent-delegation-summary { display: flex; min-width: 0; align-items: center; gap: var(--spacing_4); color: var(--text_default_tertiary); font-size: 14px; line-height: 20px; } .webui-agent-delegation-avatar { display: inline-flex; width: 16px; height: 16px; flex: 0 0 16px; align-items: center; justify-content: center; border-radius: 50%; background: var(--bg_default_tertiary); color: var(--text_default_secondary); font-size: 10px; } diff --git a/packages/webui/test/unit/runtime-warning-rendering.test.tsx b/packages/webui/test/unit/runtime-warning-rendering.test.tsx new file mode 100644 index 000000000..95c061b5c --- /dev/null +++ b/packages/webui/test/unit/runtime-warning-rendering.test.tsx @@ -0,0 +1,293 @@ +// D-2 — a `runtime.warning` SystemEvent must read as a warning, not as wire JSON. +// +// The runtime emits hook failures (the lark plugin's SessionStart hook is the +// reported case) as an ordinary `MsgType.SystemEvent` whose `msg_content` is +// the JSON envelope `buildRuntimeWarningEvent` serializes +// (`agent-core/src/event-bridge/runtime-warning.ts`). The transcript used to +// project that string like any other assistant text, so the conversation +// showed `{"eventType":"runtime.warning","category":"system-message",…}` — +// machine data where a human sentence belongs. +// +// The wire contract is unchanged: eventType / message / source / category / +// code / pluginName / hookEvent still ship exactly as before. Only the WebUI +// display layer parses the envelope and renders its human fields. The TUI +// already renders this same event as a warning row +// (`packages/tui/src/tui/controller/projection/turn-hook-message-projection.ts`), +// so this is the WebUI catching up, not a new contract. +// +// Falsification: revert the `runtime_warning` early return in +// `projectMessageParts` and the projection assertions below go red — the +// envelope falls through to the text path and comes back as raw JSON. + +import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { WebuiAssistantBody } from "../../src/client/features/transcript/ui/AssistantBody.js"; +import { + describeRuntimeWarning, + parseRuntimeWarningEventMessage, +} from "../../src/client/features/transcript/projection/message-parts.js"; +import { projectWebuiMessage } from "../../src/client/features/transcript/projection/message-projection.js"; +import { projectWebuiProcessSegments } from "../../src/client/features/transcript/projection/transcript-projection.js"; +import type { + WebuiClientMessage, +} from "../../src/client/features/transcript/contracts/message-view.js"; +import type { + WebuiRuntimeWarningView, + WebuiTranscriptItem, +} from "../../src/client/features/transcript/contracts/transcript-view.js"; + +/** The lark SessionStart failure exactly as `buildRuntimeWarningEvent` + * serializes it: `JSON.stringify` over `{ eventType, message, source, + * category, hookEvent, code, pluginName }`, optional fields in the + * producer's spread order. The message text is the real + * `diagnosticWarningMessage` output for a HOOK_TIMEOUT on a non-tool event + * (`local-turn-plugin-hooks.ts`). */ +function larkHookWarningEnvelope(): string { + return JSON.stringify({ + eventType: "runtime.warning", + message: + 'Plugin "lark" Hook could not complete and was skipped during SessionStart. The conversation continued without this Hook.', + source: "plugin-hook", + category: "system-message", + hookEvent: "SessionStart", + code: "HOOK_TIMEOUT", + pluginName: "lark", + }); +} + +const LARK_WARNING_MESSAGE = + 'Plugin "lark" Hook could not complete and was skipped during SessionStart. The conversation continued without this Hook.'; + +/** The activity variant narrowed to the warning row. Spelled as a structural + * interface rather than `Extract<…>`: the variant's `activityType` is the + * full union, so an `Extract` on the literal resolves to `never`. */ +interface RuntimeWarningItem { + readonly kind: "activity"; + readonly activityType: "runtime_warning"; + readonly messageId: string; + readonly turnId?: string; + readonly text?: string; + readonly warning?: WebuiRuntimeWarningView; + readonly timestamp?: number; +} + +function warningItems( + items: readonly WebuiTranscriptItem[], +): readonly RuntimeWarningItem[] { + return items.filter( + (item): item is RuntimeWarningItem => + "activityType" in item && item.activityType === "runtime_warning", + ); +} + +describe("runtime.warning envelope projection", () => { + it("projects the envelope into a warning row whose text is the reason, not the JSON", () => { + const envelope = larkHookWarningEnvelope(); + const items = projectWebuiMessage({ + msgId: "evt-1", + turnId: "turn-1", + timestamp: 1_700_000_000_000, + msgContent: envelope, + } satisfies WebuiClientMessage); + + const warnings = warningItems(items); + expect(warnings).toHaveLength(1); + + // The assertion this whole test exists for. The body is the producer's + // human reason, and it is NOT the wire envelope: with the projection + // early return reverted, the envelope falls through to the text path, + // the item becomes an assistant message, and `text` is the raw JSON — + // both of these lines go red. + expect(warnings[0]!.text).toBe(LARK_WARNING_MESSAGE); + expect(warnings[0]!.text).not.toBe(envelope); + expect(warnings[0]!.text).not.toContain("eventType"); + + // The structured fields ride along so the renderer never re-parses JSON. + expect(warnings[0]!.warning).toEqual({ + message: LARK_WARNING_MESSAGE, + source: "plugin-hook", + category: "system-message", + hookEvent: "SessionStart", + code: "HOOK_TIMEOUT", + pluginName: "lark", + }); + }); + + it("parses a reordered or re-spaced envelope just the same", () => { + // The producer's JSON.stringify format (field order, spacing) is an + // implementation detail. The original detection hard-coded it as a + // `{"eventType":` prefix and would fail silently the day the producer + // reformatted — the raw JSON would come back with no error anywhere. + // Detection now parses any {…} content and reads the parsed eventType. + const envelope = JSON.stringify( + { + source: "plugin-hook", + category: "system-message", + message: LARK_WARNING_MESSAGE, + eventType: "runtime.warning", + }, + null, + 2, + ); + const parsed = parseRuntimeWarningEventMessage({ msgId: "evt-6", msgContent: envelope }); + expect(parsed?.message).toBe(LARK_WARNING_MESSAGE); + expect(parsed?.category).toBe("system-message"); + expect(parsed?.source).toBe("plugin-hook"); + }); + + it("keeps Desktop parts when the message carries them alongside envelope-looking content", () => { + // The claim sits on the msgContent path, after the parts branch has + // returned: a message with structured parts keeps its parts even when + // its msgContent also looks like an envelope (the original PR claimed + // before the parts branch and would have swallowed them). + const items = projectWebuiMessage({ + msgId: "evt-7", + msgContent: larkHookWarningEnvelope(), + parts: [{ type: "text", id: "p1", content: "结构化正文" }], + } satisfies WebuiClientMessage); + expect(warningItems(items)).toHaveLength(0); + expect(items).toEqual([ + expect.objectContaining({ kind: "assistant", text: "结构化正文" }), + ]); + }); + + it("keeps the allowlisted terminalSequence out of the browser surface", () => { + const envelope = JSON.stringify({ + eventType: "runtime.warning", + message: "Plugin \"lark\" asked the terminal to repaint.", + source: "plugin-hook", + category: "runtime-message", + // Only a live TUI surface may execute these bytes; a browser must + // never carry them, render them, or act on them. + terminalSequence: "\u001b[2J\u001b[H", + }); + const parsed = parseRuntimeWarningEventMessage({ msgId: "evt-2", msgContent: envelope }); + expect(parsed?.message).toBe('Plugin "lark" asked the terminal to repaint.'); + expect(parsed).not.toHaveProperty("terminalSequence"); + }); + + it("leaves a warning with no human reason to the ordinary path instead of inventing one", () => { + // An empty message means the producer had nothing to say; rendering an + // empty warning row would be decoration, so the envelope is not claimed + // and the existing text path keeps its current behaviour. + expect( + parseRuntimeWarningEventMessage({ + msgId: "evt-3", + msgContent: JSON.stringify({ eventType: "runtime.warning", message: " ", source: "plugin-hook" }), + }), + ).toBeUndefined(); + // Malformed JSON is likewise left alone rather than half-parsed. + expect( + parseRuntimeWarningEventMessage({ msgId: "evt-4", msgContent: '{"eventType":"runtime.warning","mess' }), + ).toBeUndefined(); + }); + + it("composes the row labels from the category and the provenance", () => { + const labels = describeRuntimeWarning({ + message: LARK_WARNING_MESSAGE, + category: "system-message", + pluginName: "lark", + hookEvent: "SessionStart", + }); + // 类别 in the title, 插件 + hook 事件 as the provenance line, reason verbatim. + expect(labels.title).toBe("运行时告警 · system-message"); + expect(labels.meta).toBe("lark · SessionStart"); + expect(labels.body).toBe(LARK_WARNING_MESSAGE); + + // A category-less warning still gets a readable title and no meta line. + const bare = describeRuntimeWarning({ message: "Something needs attention." }); + expect(bare.title).toBe("运行时告警"); + expect(bare.meta).toBeUndefined(); + }); + + it("feeds the render pipeline as a process segment, not as assistant prose", () => { + const items = projectWebuiMessage({ + msgId: "evt-5", + turnId: "turn-1", + timestamp: 1_700_000_000_000, + msgContent: larkHookWarningEnvelope(), + } satisfies WebuiClientMessage); + const segments = projectWebuiProcessSegments(items); + expect(segments).toHaveLength(1); + expect(segments[0]!.activityParts).toEqual([ + { + type: "runtime_warning", + title: "运行时告警 · system-message", + meta: "lark · SessionStart", + text: LARK_WARNING_MESSAGE, + }, + ]); + }); + + it("does not claim ordinary messages or the todo_updated envelope", () => { + // Regression guards for the two paths that share this seam: an ordinary + // assistant message still projects as assistant prose, and the + // todo_updated envelope is still omitted entirely (workspace progress + // owns it) rather than becoming a warning. + const plain = projectWebuiMessage({ + msgId: "msg-1", + role: "assistant", + msgContent: "A runtime.warning would look like JSON, and this is not one.", + } satisfies WebuiClientMessage); + expect(warningItems(plain)).toHaveLength(0); + expect(plain).toEqual([ + expect.objectContaining({ kind: "assistant", text: "A runtime.warning would look like JSON, and this is not one." }), + ]); + + const todo = projectWebuiMessage({ + msgId: "msg-2", + msgContent: JSON.stringify({ eventType: "todo_updated", todos: [] }), + } satisfies WebuiClientMessage); + expect(todo).toEqual([]); + }); +}); + +describe("runtime.warning rendering", () => { + it("renders the warning row with its reason and without any JSON", () => { + const html = renderToStaticMarkup( + createElement(WebuiAssistantBody, { + messageId: "evt-1", + answers: [], + processSegments: [ + { + messageId: "evt-1", + activityParts: [ + { + type: "runtime_warning", + title: "运行时告警 · system-message", + meta: "lark · SessionStart", + text: LARK_WARNING_MESSAGE, + }, + ], + }, + ], + }), + ); + + // What the reader sees: the title with its category, the provenance + // line, and the reason. (React escapes the reason's double quotes as + // `"`; match a quote-free span so the assertion pins the text, not + // the escaping.) + expect(html).toContain("运行时告警 · system-message"); + expect(html).toContain("lark · SessionStart"); + expect(html).toContain("Hook could not complete and was skipped during SessionStart."); + // What the reader must NOT see: any trace of the envelope. + expect(html).not.toContain("eventType"); + expect(html).not.toContain("runtime.warning"); + expect(html).not.toContain("HOOK_TIMEOUT"); + expect(html).not.toContain("plugin-hook"); + + // A warning is never delivered collapsed: a turn whose only activity is + // the warning renders its detail container expanded (React serializes + // `hidden={false}` as no attribute at all), so the row is read rather + // than toggled open. Checked on the opening tag alone — `aria-hidden` + // elsewhere in the row also contains "hidden". + const detailOpenTag = html.slice( + html.indexOf('data-testid="turn-process-detail"'), + html.indexOf(">", html.indexOf('data-testid="turn-process-detail"')), + ); + expect(detailOpenTag).not.toContain("hidden"); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index ad87f44d6..b38169c04 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -4332,6 +4332,7 @@ "packages/webui/test/unit/review-owner.test.ts", "packages/webui/test/unit/review-panel.test.tsx", "packages/webui/test/unit/review-state.test.ts", + "packages/webui/test/unit/runtime-warning-rendering.test.tsx", "packages/webui/test/unit/session-import.test.ts", "packages/webui/test/unit/session-rail-search.test.ts", "packages/webui/test/unit/session-scope.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index a19b83c61..c6deda91a 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -301,6 +301,7 @@ "packages/webui/test/unit/markdown-codeblock-c2-line-numbers.test.tsx", "packages/webui/test/unit/message-attachments.test.tsx", "packages/webui/test/unit/message-parts.test.ts", + "packages/webui/test/unit/runtime-warning-rendering.test.tsx", "packages/webui/test/unit/model-picker-effort-options.test.ts", "packages/webui/test/unit/model-effort-scale.test.ts", "packages/webui/test/unit/model-picker-groups.test.tsx",