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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
/** Parsed `runtime.warning` envelope, carried structured so the renderer never re-parses JSON. */
readonly warning?: WebuiRuntimeWarningView;
readonly timestamp?: number;
};

Expand All @@ -102,4 +104,27 @@ export type WebuiTranscriptActivityPart =
| { readonly type: "tool"; readonly tool: Record<string, unknown> }
| { readonly type: "delegation"; readonly message: Record<string, unknown> }
| { readonly type: "agent_joined"; readonly agent: Record<string, unknown> }
| { readonly type: "asset_list"; readonly assets: readonly Record<string, unknown>[] };
| { readonly type: "asset_list"; readonly assets: readonly Record<string, unknown>[] }
| { 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown> | 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(/&quot;/giu, '"').replace(/&apos;/giu, "'").replace(/&lt;/giu, "<").replace(/&gt;/giu, ">").replace(/&amp;/giu, "&");
}
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>] : []) : [] }];
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ function renderActivityParts(
}
index -= 1;
rows.push({ messageId, element: <WebuiAgentJoinedGroup key={`${messageId}-joined-${index}`} agents={agents} /> });
} else if (part.type === "runtime_warning") {
rows.push({ messageId, element: <div className="webui-runtime-warning" key={`${messageId}-runtime-warning-${index}`} data-webui-runtime-warning="true" role="note"><span className="webui-runtime-warning-title"><svg className="webui-runtime-warning-icon" aria-hidden="true" width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 1.5 15 14H1L8 1.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" /><path d="M8 6v3.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" /><circle cx="8" cy="11.75" r="0.9" fill="currentColor" /></svg><span>{part.title}</span>{part.meta ? <span className="webui-runtime-warning-meta">{part.meta}</span> : null}</span><WebuiMarkdown source={part.text} onOpenFile={onOpenFile} workspaceDir={workspaceDir} /></div> });
} else {
rows.push({ messageId, element: <WebuiDeliveredAssets key={`${messageId}-assets-${index}`} assets={part.assets} workspaceDir={workspaceDir} onOpenFile={onOpenFile} /> });
}
Expand Down Expand Up @@ -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" ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading
Loading