diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 2c07ac91b1e..8661e326ae3 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -352,7 +352,7 @@ function runtimeEventToActivities( createdAt: event.createdAt, tone: "info", kind: "runtime.warning", - summary: "Runtime warning", + summary: "Runtime Warning", payload: { message: truncateDetail(event.payload.message), ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 12103194870..1b3d6f437a5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -131,7 +131,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsed="true"'); expect(markup).toContain('data-user-message-fade="true"'); expect(markup).toContain('data-user-message-footer="true"'); - }); + }, 20_000); it("does not render collapse controls for short user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -144,7 +144,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Show full message"); expect(markup).toContain('data-user-message-collapsible="false"'); - }); + }, 20_000); it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -212,6 +212,65 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work log"); }); + it("renders a warning detail toggle without exposing the details by default", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Runtime Warning"); + expect(markup).toContain('aria-label="Show runtime warning details"'); + expect(markup).not.toContain("slow_provider"); + expect(markup).not.toContain("Provider got slow"); + expect(markup).not.toContain(">Warning<"); + }); + + it("renders whitespace-only runtime warning messages as a plain runtime warning row", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Runtime Warning"); + expect(markup).not.toContain('aria-label="Show runtime warning details"'); + }); + it("formats changed file paths from the workspace root", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1540d5f344a..077fa1cfd84 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -23,6 +23,7 @@ import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, + ChevronDownIcon, CircleAlertIcon, EyeIcon, GlobeIcon, @@ -51,6 +52,7 @@ import { } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, @@ -1034,10 +1036,48 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-muted-foreground/40"; } +function normalizeRuntimeWarningMessage(message: string | undefined): string | null { + const trimmed = message?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function formatRuntimeWarningDetail(detail: unknown): string | null { + if (typeof detail === "string") { + const trimmed = detail.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + if (detail === null || detail === undefined) { + return null; + } + + try { + const serialized = JSON.stringify(detail, null, 2); + return typeof serialized === "string" && serialized.length > 0 ? serialized : String(detail); + } catch { + return String(detail); + } +} + +function hasRenderableRuntimeWarningMeta( + workEntry: Pick, +): boolean { + return ( + normalizeRuntimeWarningMessage(workEntry.runtimeWarningMessage) !== null || + formatRuntimeWarningDetail(workEntry.runtimeWarningDetail) !== null + ); +} + function workEntryPreview( - workEntry: Pick, + workEntry: Pick< + TimelineWorkEntry, + "detail" | "command" | "changedFiles" | "runtimeWarningMessage" | "runtimeWarningDetail" + >, workspaceRoot: string | undefined, ) { + if (hasRenderableRuntimeWarningMeta(workEntry)) { + return null; + } if (workEntry.command) return workEntry.command; if (workEntry.detail) return workEntry.detail; if ((workEntry.changedFiles?.length ?? 0) === 0) return null; @@ -1104,6 +1144,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; + const [showRuntimeWarningDetail, setShowRuntimeWarningDetail] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); @@ -1118,104 +1159,159 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; + const runtimeWarningMessage = normalizeRuntimeWarningMessage(workEntry.runtimeWarningMessage); + const runtimeWarningDetail = formatRuntimeWarningDetail(workEntry.runtimeWarningDetail); + const hasRuntimeWarningMeta = hasRenderableRuntimeWarningMeta(workEntry); - return ( -
-
- - - -
- {rawCommand ? ( -
-

+

+ + +
+ {runtimeWarningMessage ? ( +

+ {runtimeWarningMessage} +

+ ) : null} + {runtimeWarningDetail ? ( +
+                  {runtimeWarningDetail}
+                
+ ) : null}
- ) : ( - - +
+
+ + ); + } + + return ( + +
+
+ + + +
+ {rawCommand ? ( +

{heading} - {preview && - {preview}} -

- - -

- {displayText} + {preview && ( + + + {" "} + - {preview} + + } + /> + +

+ {rawCommand} +
+
+ + )}

- - - )} +
+ ) : ( + + +

+ + {heading} + + {preview && - {preview}} +

+
+ +

+ {displayText} +

+
+
+ )} +
-
- {hasChangedFiles && !previewIsChangedFiles && ( -
- {workEntry.changedFiles?.slice(0, 4).map((filePath) => { - const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); - return ( - - {displayPath} + {hasChangedFiles && !previewIsChangedFiles && ( +
+ {workEntry.changedFiles?.slice(0, 4).map((filePath) => { + const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); + return ( + + {displayPath} + + ); + })} + {(workEntry.changedFiles?.length ?? 0) > 4 && ( + + +{(workEntry.changedFiles?.length ?? 0) - 4} - ); - })} - {(workEntry.changedFiles?.length ?? 0) > 4 && ( - - +{(workEntry.changedFiles?.length ?? 0) - 4} - - )} -
- )} -
+ )} +
+ )} +
+ ); }); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index baf384d6af2..02dbd32b2d2 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -653,6 +653,29 @@ describe("deriveWorkLogEntries", () => { expect(entries[0]?.tone).toBe("error"); }); + it("keeps runtime warning rows generic while preserving message and structured details", () => { + const detail = { code: "slow_provider", retryInSeconds: 5 }; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "runtime-warning", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "runtime.warning", + summary: "Runtime Warning", + tone: "info", + payload: { + message: "Provider got slow", + detail, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.label).toBe("Runtime Warning"); + expect(entry?.detail).toBeUndefined(); + expect(entry?.runtimeWarningMessage).toBe("Provider got slow"); + expect(entry?.runtimeWarningDetail).toEqual(detail); + }); + it("filters by turn id when provided", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a7767672fa1..15d7ded33db 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,8 @@ export interface WorkLogEntry { createdAt: string; label: string; detail?: string; + runtimeWarningMessage?: string; + runtimeWarningDetail?: unknown; command?: string; rawCommand?: string; changedFiles?: ReadonlyArray; @@ -530,6 +532,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo payload.detail.length > 0 ? payload.detail : null; + const runtimeWarningMessage = + activity.kind === "runtime.warning" && + typeof payload?.message === "string" && + payload.message.length > 0 + ? payload.message + : null; const taskLabel = taskSummary || taskDetailAsLabel; const detail = isTaskActivity ? !taskDetailAsLabel && @@ -538,12 +546,14 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo payload.detail.length > 0 ? stripTrailingExitCode(payload.detail).output : null - : extractToolDetail(payload, title ?? activity.summary); + : activity.kind === "runtime.warning" + ? null + : extractToolDetail(payload, title ?? activity.summary); const toolCallId = isTaskActivity ? null : extractToolCallId(payload); const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, - label: taskLabel || activity.summary, + label: activity.kind === "runtime.warning" ? activity.summary : taskLabel || activity.summary, tone: activity.kind === "task.progress" ? "thinking" @@ -557,6 +567,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (detail) { entry.detail = detail; } + if (runtimeWarningMessage) { + entry.runtimeWarningMessage = runtimeWarningMessage; + } + if (activity.kind === "runtime.warning" && payload?.detail !== undefined) { + entry.runtimeWarningDetail = payload.detail; + } if (commandPreview.command) { entry.command = commandPreview.command; }