feat(m1-08): stream safe execution summaries

This commit is contained in:
2026-08-03 23:10:49 +08:00
parent ffda402861
commit 9fd321c41c
8 changed files with 547 additions and 40 deletions
+44
View File
@@ -0,0 +1,44 @@
.execution-progress-card {
justify-self: start;
width: min(100%, 32rem);
border: 1px solid #577563;
border-radius: .85rem;
background: #182820;
color: #cde3d5;
}
.execution-progress-card summary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: .8rem;
padding: .72rem .85rem;
cursor: pointer;
list-style: none;
}
.execution-progress-card summary::-webkit-details-marker { display: none; }
.execution-progress-card summary::before { content: ""; font-size: 1.2rem; color: #a9dfc4; transition: transform .15s ease; }
.execution-progress-card[open] summary::before { transform: rotate(90deg); }
.execution-progress-card[data-status="running"] summary::before { content: "◌"; transform: none; }
.execution-progress-heading strong { font-size: .9rem; }
.execution-progress-duration { color: #9cbbaa; white-space: nowrap; }
.execution-progress-stages {
display: grid;
gap: .34rem;
margin: 0;
padding: 0 .9rem .85rem 2rem;
color: #b9d1c2;
font-size: .82rem;
}
.execution-progress-stages li::marker { color: #85caa2; }
.execution-progress-stages li[data-status="completed"] { color: #d5f0df; }
.execution-progress-stages li[data-status="running"] { color: #d7e6a4; }
.execution-progress-card[data-status="failed"] { border-color: #82625e; }
.execution-progress-card[data-status="failed"] .execution-progress-stages li[data-status="failed"] { color: #ffb2aa; }
/* A trace belongs to the final Agent turn, not to the global message stream. */
.message-agent-content { display: grid; gap: .45rem; min-width: 0; }
.message-agent-content > .execution-progress-card { width: min(100%, 32rem); }
+45 -2
View File
@@ -1,4 +1,5 @@
import "./style.css";
import "./execution-progress.css";
import {
type Actor,
type ConversationItem,
@@ -7,6 +8,7 @@ import {
parseEnvelopeJSON,
} from "./protocol";
import { ConversationStore } from "./runtime/conversation-store";
import { ExecutionProgressState } from "./runtime/execution-progress-state";
import { InteractionKernel } from "./runtime/interaction-kernel";
import { RendererRegistry } from "./runtime/renderer-registry";
import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter";
@@ -29,6 +31,7 @@ let polling = false;
let flushingOutbox = false;
const interactionKernel = new InteractionKernel();
const conversationStore = new ConversationStore(localStorage);
const executionProgress = new ExecutionProgressState();
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing");
@@ -136,6 +139,7 @@ const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(item));
rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item));
rendererRegistry.register("execution-summary", (item, context) => context.renderExecutionTrace(item));
rendererRegistry.register("progress", (item, context) => context.renderProgress(item));
rendererRegistry.register("error", (item, context) => context.renderError(item));
rendererRegistry.register("tool-call", (item, context) => context.renderToolCall(item));
@@ -149,6 +153,34 @@ function renderIncoming(item: ConversationItem): void {
if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。");
}
/**
* Status only begins/ends a turn. Public operation labels can only come from
* the separately validated adapter trace, never from Agent status/detail.
*/
function projectExecutionProgress(item: ConversationItem, now: string): void {
if (item.kind === "agent-status" && item.status === "thinking" && item.envelope?.sender.kind === "agent") {
const summary = executionProgress.begin(item.execution_id ?? newLocalID("execution"), now);
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
return;
}
if (item.kind === "execution-summary") {
const summary = executionProgress.applyTrace(item.trace.execution_id, item.trace.status, item.trace.steps, now);
if (summary) {
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
}
return;
}
const terminal = item.kind === "error" ? "failed" : (item.kind === "agent-status" && item.status === "idle" ? "completed" : undefined);
if (!terminal) return;
const executionID = item.kind === "agent-status" ? item.execution_id : undefined;
const summary = executionProgress.finish(executionID, terminal, now);
if (!summary) return;
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
}
function currentConversationKey(): string {
return `${session.uid}:${session.channelType}:${session.channelID}`;
}
@@ -269,7 +301,11 @@ async function syncLoop(): Promise<void> {
conversationStore.replaceTasks(result.snapshot.tasks);
}
for (const item of result.items) {
if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item);
const receivedAt = new Date().toISOString();
if (conversationStore.recordIncoming(item, message.message_seq, receivedAt)) {
projectExecutionProgress(item, receivedAt);
renderIncoming(item);
}
}
}
}
@@ -316,12 +352,19 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
const snapshot = conversationStore.open(currentConversationKey());
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks);
executionProgress.restore(snapshot.execution_summaries);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
session.lastSeq = snapshot.cursor;
messages.replaceChildren(); rendererContext.reset();
loginView.hidden = true; chatView.hidden = false;
for (const stored of snapshot.items) renderIncoming(stored.item);
// Status envelopes describe ephemeral activity. The durable M1-08 summary
// below is the only restored execution view, so historical `thinking`
// messages cannot create a fresh or expanded pseudo-live card on reload.
for (const stored of snapshot.items) {
if (stored.item.kind !== "agent-status") renderIncoming(stored.item);
}
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop();
} catch (reason) { error.textContent = loginFailureMessage(reason); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
+35 -4
View File
@@ -116,6 +116,10 @@ export type TaskProgress = {
cancellable: boolean;
};
/** Display-only, adapter-authoritative execution trace. It grants no ability. */
export type AgentExecutionStep = { id: string; title: string; status: "running" | "completed" | "failed" };
export type AgentExecutionTrace = { execution_id: string; status: "running" | "completed" | "failed"; steps: readonly AgentExecutionStep[] };
/**
* A user-originated action awaiting delivery through the Transport Adapter.
* It is data only: the Store owns retries and the Kernel owns state changes.
@@ -180,8 +184,9 @@ type ConversationItemBase = {
export type ConversationItem =
| (ConversationItemBase & { kind: "user-message"; text: string; delivery: DeliveryState })
| (ConversationItemBase & { kind: "markdown"; markdown: string })
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string })
| (ConversationItemBase & { kind: "markdown"; markdown: string; execution_id?: string })
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string; execution_id?: string })
| (ConversationItemBase & { kind: "execution-summary"; trace: AgentExecutionTrace })
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string; task?: TaskProgress })
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
| (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest })
@@ -199,6 +204,7 @@ const ACTOR_KINDS = new Set<ActorKind>(["human", "agent", "app", "system"]);
const SUPPORTED_TYPES = new Set([
"lineup.v1.text",
"lineup.v1.agent.status",
"lineup.v1.agent.execution",
"lineup.v1.agent.progress",
"lineup.v1.error",
"lineup.v1.tool.call",
@@ -323,6 +329,7 @@ const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed",
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
const TASK_STATUSES = new Set<TaskStatus>(["running", "waiting", "completed", "failed", "cancelled"]);
const EXECUTION_STATUSES = new Set(["running", "completed", "failed"]);
function optionalTimestamp(value: unknown): string | undefined {
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined;
@@ -436,6 +443,24 @@ function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress |
return { operation_id: operationID, title, percent, status, cancellable: payload.cancellable === true };
}
function parseExecutionTrace(payload: JsonObject): AgentExecutionTrace | undefined {
const executionID = identifier(payload.execution_id);
const status = text(payload.status);
if (!executionID || !EXECUTION_STATUSES.has(status) || !Array.isArray(payload.steps) || payload.steps.length > 12) return undefined;
const steps: AgentExecutionStep[] = [];
const seen = new Set<string>();
for (const raw of payload.steps) {
const step = object(raw);
const id = step && identifier(step.id);
const title = step && text(step.title).trim();
const stepStatus = step && text(step.status);
if (!id || !title || title.length > 160 || !stepStatus || !EXECUTION_STATUSES.has(stepStatus) || seen.has(id)) return undefined;
seen.add(id);
steps.push({ id, title, status: stepStatus as AgentExecutionStep["status"] });
}
return { execution_id: executionID, status: status as AgentExecutionTrace["status"], steps };
}
function parseToolResult(payload: JsonObject): ToolCallResult | undefined {
const callID = identifier(payload.call_id);
const status = text(payload.status) as ToolCallFinalStatus;
@@ -484,14 +509,20 @@ export function decodeConversationItem(raw: string): ConversationItem {
switch (envelope.type) {
case "lineup.v1.text": {
const markdown = text(payload.markdown) || text(payload.text);
return markdown ? { kind: "markdown", markdown, ...base } : fallbackItem({ code: "invalid_payload", detail: "Text payload requires text or markdown.", envelope });
return markdown
? { kind: "markdown", markdown, ...(identifier(payload.execution_id) ? { execution_id: identifier(payload.execution_id)! } : {}), ...base }
: fallbackItem({ code: "invalid_payload", detail: "Text payload requires text or markdown.", envelope });
}
case "lineup.v1.agent.status": {
const status = text(payload.status);
return status
? { kind: "agent-status", status, detail: text(payload.detail), ...base }
? { kind: "agent-status", status, detail: text(payload.detail), ...(identifier(payload.execution_id) ? { execution_id: identifier(payload.execution_id)! } : {}), ...base }
: fallbackItem({ code: "invalid_payload", detail: "Agent status payload requires status.", envelope });
}
case "lineup.v1.agent.execution": {
const trace = parseExecutionTrace(payload);
return trace ? { kind: "execution-summary", trace, ...base } : fallbackItem({ code: "invalid_payload", detail: "Agent execution requires bounded public steps.", envelope });
}
case "lineup.v1.agent.progress": {
const percent = payload.percent;
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
@@ -129,6 +129,42 @@ describe("ConversationStore", () => {
expect(current.snapshot().outbox).toEqual([]);
});
it("persists only the already-sanitized execution progress summary projection", () => {
const storage = new MemoryStorage();
const first = new ConversationStore(storage);
first.open(conversationID);
first.replaceExecutionSummaries([{
id: "execution_001",
status: "completed",
started_at: createdAt,
updated_at: "2026-08-03T00:00:08.000Z",
finished_at: "2026-08-03T00:00:08.000Z",
steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }],
}]);
const snapshot = new ConversationStore(storage).open(conversationID);
expect(snapshot.execution_summaries).toEqual([expect.objectContaining({
id: "execution_001", steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }],
})]);
expect(JSON.stringify(snapshot.execution_summaries)).not.toContain("detail");
});
it("drops untrusted extra fields from persisted execution summaries", () => {
const storage = new MemoryStorage();
const store = new ConversationStore(storage);
store.open(conversationID);
store.replaceExecutionSummaries([{
id: "execution_safe",
status: "running",
started_at: createdAt,
updated_at: createdAt,
steps: [],
detail: "do not persist this",
} as unknown as import("./execution-progress-state").ExecutionProgressSummary]);
expect(JSON.stringify(store.snapshot().execution_summaries)).not.toContain("do not persist this");
});
it("upgrades v5 plaintext outbox entries to typed text entries", () => {
const storage = new MemoryStorage();
storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({
@@ -138,4 +174,33 @@ describe("ConversationStore", () => {
local_id: "legacy_001", kind: "text", payload: "hello", created_at: createdAt,
}]);
});
it("preserves v6 tool state while adding the execution summary field", () => {
const storage = new MemoryStorage();
const state = {
call_id: "call_migrate_006", tool: "confirm", status: "completed", conversation_id: conversationID,
created_at: createdAt, updated_at: createdAt,
} as unknown as ToolCallRecord;
storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({
version: 6, cursor: 8, items: [], outbox: [], tool_calls: [state], tool_drafts: { call_migrate_006: { value: "not-lost" } }, tasks: [],
}));
const snapshot = new ConversationStore(storage).open(conversationID);
expect(snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_migrate_006", status: "completed" })]);
expect(snapshot.tool_drafts).toEqual({ call_migrate_006: { value: "not-lost" } });
expect(snapshot.execution_summaries).toEqual([]);
});
it("upgrades v7 while retaining tool state but dropping fixed lifecycle placeholders", () => {
const storage = new MemoryStorage();
const state = { call_id: "call_migrate_007", tool: "confirm", status: "completed", conversation_id: conversationID, created_at: createdAt, updated_at: createdAt } as unknown as ToolCallRecord;
storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({
version: 7, cursor: 9, items: [], outbox: [], tool_calls: [state], tool_drafts: { call_migrate_007: { ignored: "draft" } }, tasks: [],
execution_summaries: [{ id: "execution_legacy", status: "completed", started_at: createdAt, updated_at: createdAt, finished_at: createdAt, stages: ["received", "completed"] }],
}));
const snapshot = new ConversationStore(storage).open(conversationID);
expect(snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_migrate_007" })]);
expect(snapshot.tool_drafts).toEqual({ call_migrate_007: { ignored: "draft" } });
expect(snapshot.execution_summaries).toEqual([]);
});
});
+82 -14
View File
@@ -1,6 +1,8 @@
import type { ConversationItem, DeliveryState, JsonObject } from "../protocol";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
import type { ExecutionProgressSummary } from "./execution-progress-state";
import type { SurfaceInstanceSnapshot } from "./surface-instance-manager";
export type StoredConversationItem = {
local_id: string;
@@ -11,7 +13,7 @@ export type StoredConversationItem = {
export type OutboxEntry = {
local_id: string;
kind: "text" | "tool-result" | "tool-cancel";
kind: "text" | "tool-result" | "tool-cancel" | "surface-event";
payload: string;
created_at: string;
};
@@ -24,16 +26,21 @@ export type ConversationSnapshot = {
tool_calls: readonly ToolCallRecord[];
tool_drafts: Readonly<Record<string, JsonObject>>;
tasks: readonly TaskRecord[];
/** Allowlisted public operation summaries; never reasoning or raw tool data. */
execution_summaries: readonly ExecutionProgressSummary[];
surfaces: SurfaceInstanceSnapshot;
};
type PersistedConversation = {
version: 6;
version: 9;
cursor: number;
items: StoredConversationItem[];
outbox: OutboxEntry[];
tool_calls: ToolCallRecord[];
tool_drafts: Record<string, JsonObject>;
tasks: TaskRecord[];
execution_summaries: ExecutionProgressSummary[];
surfaces: SurfaceInstanceSnapshot;
};
const STORE_PREFIX = "lineup.conversation.v1";
@@ -72,6 +79,8 @@ export class ConversationStore {
tool_calls: clone(this.state.tool_calls),
tool_drafts: clone(this.state.tool_drafts),
tasks: clone(this.state.tasks),
execution_summaries: clone(this.state.execution_summaries),
surfaces: clone(this.state.surfaces),
};
}
@@ -102,6 +111,17 @@ export class ConversationStore {
this.persist();
}
/** Stores only bounded public operation labels and timestamps. */
public replaceExecutionSummaries(records: readonly ExecutionProgressSummary[]): void {
this.state.execution_summaries = normalizeExecutionSummaries(records);
this.persist();
}
public replaceSurfaces(snapshot: SurfaceInstanceSnapshot): void {
this.state.surfaces = clone(snapshot);
this.persist();
}
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
const stored: StoredConversationItem = {
@@ -116,7 +136,7 @@ export class ConversationStore {
}
/** Queues a standard interaction response before any network attempt. */
public enqueueToolAction(localID: string, kind: "tool-result" | "tool-cancel", payload: string, createdAt: string): void {
public enqueueToolAction(localID: string, kind: "tool-result" | "tool-cancel" | "surface-event", payload: string, createdAt: string): void {
if (this.state.outbox.some(entry => entry.local_id === localID)) return;
this.state.outbox.push({ local_id: localID, kind, payload, created_at: createdAt });
this.persist();
@@ -213,35 +233,40 @@ export class ConversationStore {
try {
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown };
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown };
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
return emptyConversation();
}
if (candidate.version !== 6) {
if (candidate.version !== 9) {
// v1 advanced its cursor from a send acknowledgement. Some browsers
// had already been upgraded to v2 before that correction, retaining a
// high cursor with holes for their own echoes. Preserve every valid
// local item but replay the server history once to repair those holes;
// the next persist upgrades this record to v5.
return {
version: 6,
cursor: candidate.version === 4 || candidate.version === 5 ? Math.max(0, candidate.cursor) : 0,
version: 9,
cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0,
items: candidate.items as StoredConversationItem[],
outbox: normalizeOutbox(candidate.outbox),
tool_calls: (candidate.version === 4 || candidate.version === 5) && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
tool_drafts: (candidate.version === 4 || candidate.version === 5) && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
tasks: candidate.version === 5 && Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
tool_calls: (candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
tool_drafts: (candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
tasks: (candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
// v7's fixed lifecycle placeholders are intentionally discarded.
execution_summaries: [],
surfaces: emptySurfaces(),
};
}
return {
version: 6,
version: 9,
cursor: Math.max(0, candidate.cursor),
items: candidate.items as StoredConversationItem[],
outbox: normalizeOutbox(candidate.outbox),
tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries),
surfaces: normalizeSurfaces(candidate.surfaces),
};
} catch {
return emptyConversation();
@@ -263,16 +288,19 @@ export class ConversationStore {
}
function emptyConversation(): PersistedConversation {
return { version: 6, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [] };
return { version: 9, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces() };
}
function emptySurfaces(): SurfaceInstanceSnapshot { return { version: 1, instances: [] }; }
function normalizeSurfaces(value: unknown): SurfaceInstanceSnapshot { return value && typeof value === "object" && !Array.isArray(value) ? value as SurfaceInstanceSnapshot : emptySurfaces(); }
function normalizeOutbox(value: unknown): OutboxEntry[] {
if (!Array.isArray(value)) return [];
return value.flatMap(entry => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
const candidate = entry as Partial<OutboxEntry>;
if (typeof candidate.local_id !== "string" || typeof candidate.payload !== "string" || typeof candidate.created_at !== "string") return [];
const kind = candidate.kind === "tool-result" || candidate.kind === "tool-cancel" ? candidate.kind : "text";
const kind = candidate.kind === "tool-result" || candidate.kind === "tool-cancel" || candidate.kind === "surface-event" ? candidate.kind : "text";
return [{ local_id: candidate.local_id, kind, payload: candidate.payload, created_at: candidate.created_at }];
});
}
@@ -282,6 +310,46 @@ function isDraftMap(value: unknown): value is Record<string, JsonObject> {
&& Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft));
}
/**
* This is intentionally stricter than a generic JSON clone. Execution
* summaries are a privacy boundary: unknown fields (including a model detail,
* command, argument, URL, path, output, or tool input) are discarded first.
*/
function normalizeExecutionSummaries(value: unknown): ExecutionProgressSummary[] {
if (!Array.isArray(value)) return [];
const statuses = new Set(["running", "completed", "failed"]);
const stepStatuses = new Set(["completed", "failed"]);
return value.flatMap(raw => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
const candidate = raw as Partial<ExecutionProgressSummary>;
if (typeof candidate.id !== "string" || candidate.id.length < 1 || candidate.id.length > 128
|| typeof candidate.status !== "string" || !statuses.has(candidate.status)
|| typeof candidate.started_at !== "string" || Number.isNaN(Date.parse(candidate.started_at))
|| typeof candidate.updated_at !== "string" || Number.isNaN(Date.parse(candidate.updated_at))
|| (candidate.finished_at !== undefined && (typeof candidate.finished_at !== "string" || Number.isNaN(Date.parse(candidate.finished_at))))
|| !Array.isArray(candidate.steps) || candidate.steps.length > 12) return [];
const seen = new Set<string>();
const steps = candidate.steps.flatMap(raw => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
const step = raw as { id?: unknown; title?: unknown; status?: unknown };
if (typeof step.id !== "string" || !/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(step.id)
|| typeof step.title !== "string" || !step.title.trim() || step.title.length > 160
|| typeof step.status !== "string" || !stepStatuses.has(step.status) || seen.has(step.id)) return [];
seen.add(step.id);
return [{ id: step.id, title: step.title.trim(), status: step.status as "completed" | "failed" }];
});
if (steps.length !== candidate.steps.length) return [];
return [{
id: candidate.id,
status: candidate.status as ExecutionProgressSummary["status"],
started_at: candidate.started_at,
updated_at: candidate.updated_at,
...(candidate.finished_at ? { finished_at: candidate.finished_at } : {}),
steps,
}];
}).slice(-20);
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { ExecutionProgressState } from "./execution-progress-state";
const T0 = "2026-08-03T00:00:00.000Z";
const T1 = "2026-08-03T00:00:18.000Z";
describe("ExecutionProgressState", () => {
it("starts a turn with no invented operation steps", () => {
const state = new ExecutionProgressState();
expect(state.begin("exec_001", T0)).toEqual(expect.objectContaining({ id: "exec_001", status: "running", steps: [] }));
});
it("only accepts bounded, canonical execution steps for the matching turn", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
expect(state.applyTrace("exec_other", "completed", [], T1)).toBeUndefined();
const summary = state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }], T1);
expect(summary).toEqual(expect.objectContaining({ status: "completed", steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }] }));
});
it("updates a real running step in place before the turn ends", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
expect(state.applyTrace("exec_001", "running", [{ id: "step_01", title: "查询公开资料", status: "running" }], T1))
.toEqual(expect.objectContaining({ status: "running", finished_at: undefined, steps: [{ id: "step_01", title: "查询公开资料", status: "running" }] }));
expect(state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "查询公开资料", status: "completed" }], T1))
.toEqual(expect.objectContaining({ status: "completed", finished_at: T1 }));
});
it("finishes without a trace without fabricating a fixed lifecycle", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
expect(state.finishActive("completed", T1)).toEqual(expect.objectContaining({ steps: [] }));
});
it("keeps overlapping execution ids independent and finishes only the matching turn", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
state.begin("exec_002", T1);
state.finish("exec_002", "completed", T1);
expect(state.snapshot()).toEqual([
expect.objectContaining({ id: "exec_001", status: "running" }),
expect.objectContaining({ id: "exec_002", status: "completed" }),
]);
});
});
@@ -0,0 +1,114 @@
/**
* Display-safe projection of one Agent turn.
*
* The client never turns status text, model reasoning, commands, arguments,
* URLs, paths, or tool output into a step. Steps arrive only through the
* separately validated, adapter-authoritative `agent.execution` envelope.
*/
export type ExecutionProgressStatus = "running" | "completed" | "failed";
export type ExecutionStepStatus = "running" | "completed" | "failed";
export type ExecutionProgressStep = {
id: string;
title: string;
status: ExecutionStepStatus;
};
export type ExecutionProgressSummary = {
id: string;
status: ExecutionProgressStatus;
started_at: string;
updated_at: string;
finished_at?: string;
steps: readonly ExecutionProgressStep[];
};
const MAX_SUMMARIES = 20;
const MAX_STEPS = 12;
const ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/;
const VALID_STATUSES = new Set<ExecutionProgressStatus>(["running", "completed", "failed"]);
const VALID_STEP_STATUSES = new Set<ExecutionStepStatus>(["running", "completed", "failed"]);
export class ExecutionProgressState {
private summaries: ExecutionProgressSummary[] = [];
/** A status boundary starts its exact turn, but deliberately creates no fake step. */
public begin(id: string, now: string): ExecutionProgressSummary {
const safeID = validID(id) ? id : `execution_${Date.parse(now) || Date.now()}`;
const existing = this.summaries.find(summary => summary.id === safeID);
if (existing) {
existing.updated_at = now;
return copy(existing);
}
const summary: ExecutionProgressSummary = { id: safeID, status: "running", started_at: now, updated_at: now, steps: [] };
this.summaries.push(summary);
this.trim();
return copy(summary);
}
/** Applies a protocol-validated trace for its exact execution id. */
public applyTrace(id: string, status: ExecutionProgressStatus, steps: readonly ExecutionProgressStep[], now: string): ExecutionProgressSummary | undefined {
const summary = this.summaries.find(entry => entry.id === id);
if (!summary) return undefined;
summary.status = status;
summary.updated_at = now;
summary.finished_at = status === "running" ? undefined : now;
summary.steps = sanitizeSteps(steps);
return copy(summary);
}
/** Ends the one active turn without inventing an operation description. */
public finishActive(status: "completed" | "failed", now: string): ExecutionProgressSummary | undefined {
const active = this.summaries.find(summary => summary.status === "running");
if (!active) return undefined;
active.status = status;
active.updated_at = now;
active.finished_at = now;
return copy(active);
}
/** Ends only the matching running execution; never steals another turn's card. */
public finish(id: string | undefined, status: "completed" | "failed", now: string): ExecutionProgressSummary | undefined {
if (!id) return this.finishActive(status, now);
const summary = this.summaries.find(entry => entry.id === id && entry.status === "running");
if (!summary) return undefined;
summary.status = status;
summary.updated_at = now;
summary.finished_at = now;
return copy(summary);
}
public restore(summaries: readonly ExecutionProgressSummary[]): void {
this.summaries = summaries.flatMap(summary => valid(summary) ? [copy(summary)] : []).slice(-MAX_SUMMARIES);
}
public snapshot(): readonly ExecutionProgressSummary[] { return this.summaries.map(copy); }
private trim(): void { if (this.summaries.length > MAX_SUMMARIES) this.summaries.splice(0, this.summaries.length - MAX_SUMMARIES); }
}
function valid(value: ExecutionProgressSummary): boolean {
return validID(value.id) && VALID_STATUSES.has(value.status)
&& validTime(value.started_at) && validTime(value.updated_at)
&& (value.finished_at === undefined || validTime(value.finished_at))
&& Array.isArray(value.steps) && value.steps.length <= MAX_STEPS
&& value.steps.every(step => validStep(step));
}
function validID(value: unknown): value is string { return typeof value === "string" && ID.test(value); }
function validTime(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
function validStep(value: unknown): value is ExecutionProgressStep {
return Boolean(value && typeof value === "object" && !Array.isArray(value)
&& validID((value as ExecutionProgressStep).id)
&& typeof (value as ExecutionProgressStep).title === "string"
&& (value as ExecutionProgressStep).title.trim().length > 0
&& (value as ExecutionProgressStep).title.length <= 160
&& VALID_STEP_STATUSES.has((value as ExecutionProgressStep).status));
}
function sanitizeSteps(steps: readonly ExecutionProgressStep[]): ExecutionProgressStep[] {
const seen = new Set<string>();
return steps.flatMap(step => validStep(step) && !seen.has(step.id) ? (seen.add(step.id), [{ id: step.id, title: step.title.trim(), status: step.status }]) : []).slice(0, MAX_STEPS);
}
function copy(summary: ExecutionProgressSummary): ExecutionProgressSummary {
return { ...summary, steps: summary.steps.map(step => ({ ...step })) };
}
+114 -18
View File
@@ -11,6 +11,7 @@ import type {
} from "../protocol";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
import type { ExecutionProgressSummary } from "./execution-progress-state";
export type ToolInteractionHandlers = {
submit: (callID: string, submission: JsonObject) => boolean;
@@ -33,24 +34,33 @@ export type TaskInteractionHandlers = {
* Kernel, Tauri bridge, or capability reference.
*/
export class TrustedDOMRendererContext {
private thinking: HTMLElement | undefined;
private readonly userRows = new Map<string, HTMLElement>();
private readonly toolCards = new Map<string, { card: HTMLElement; status: HTMLElement; controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[] }>();
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
private readonly executionCards = new Map<string, HTMLDetailsElement>();
private readonly agentReplyGroups = new Map<string, HTMLElement>();
private readonly executionSummaries = new Map<string, ExecutionProgressSummary>();
private readonly expirationTimers = new Set<number>();
private readonly executionTicker: number;
public constructor(
private readonly messages: HTMLElement,
private readonly presence: HTMLElement,
private readonly toolInteractions?: ToolInteractionHandlers,
private readonly taskInteractions?: TaskInteractionHandlers,
) {}
) {
// Status envelopes are sparse. Keep elapsed time honest between them
// without persisting a ticking value or treating it as Agent progress.
this.executionTicker = window.setInterval(() => this.refreshExecutionDurations(), 1_000);
}
public reset(): void {
this.thinking = undefined;
this.userRows.clear();
this.toolCards.clear();
this.taskCards.clear();
this.executionCards.clear();
this.agentReplyGroups.clear();
this.executionSummaries.clear();
for (const timer of this.expirationTimers) window.clearTimeout(timer);
this.expirationTimers.clear();
}
@@ -61,16 +71,21 @@ export class TrustedDOMRendererContext {
}
public renderMarkdown(item: Extract<ConversationItem, { kind: "markdown" }>): void {
this.hideThinking();
this.appendBubble("agent", item.markdown, true);
const row = this.appendBubble("agent", item.markdown, true);
if (item.execution_id) {
row.dataset.executionId = item.execution_id;
this.agentReplyGroups.set(item.execution_id, row);
this.attachExecutionCard(item.execution_id);
}
}
public renderAgentStatus(item: Extract<ConversationItem, { kind: "agent-status" }>): void {
if (item.status === "thinking") this.showThinking(item.detail || "正在思考…");
else {
this.hideThinking();
this.setPresence(item.detail || item.status || "在线");
// Status detail is never displayed as thought content or an operation.
this.setPresence(item.status === "thinking" ? "正在处理你的请求" : "已同步,等待消息");
}
public renderExecutionTrace(_item: Extract<ConversationItem, { kind: "execution-summary" }>): void {
// main.ts projects the validated trace into the existing summary card.
}
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): void {
@@ -87,10 +102,52 @@ export class TrustedDOMRendererContext {
}
public renderError(item: Extract<ConversationItem, { kind: "error" }>): void {
this.hideThinking();
this.renderSystemMessage(`Agent 错误:${item.message}`);
}
/** Renders only protocol-validated public steps; raw tool data never reaches this card. */
public renderExecutionSummary(summary: ExecutionProgressSummary): void {
this.executionSummaries.set(summary.id, summary);
// A completed reply which performed no publicly summarizable operation
// must not leave a misleading empty ledger frame behind. A later genuine
// trace still recreates and attaches the card under this same reply.
if (summary.status !== "running" && summary.steps.length === 0) {
const empty = this.executionCards.get(summary.id);
empty?.remove();
this.executionCards.delete(summary.id);
return;
}
const current = this.executionCards.get(summary.id);
if (current) {
this.updateExecutionSummary(current, summary);
return;
}
const card = document.createElement("details");
card.className = "execution-progress-card";
card.dataset.executionId = summary.id;
const heading = document.createElement("summary");
heading.className = "execution-progress-heading";
const title = document.createElement("strong");
const duration = document.createElement("small");
duration.className = "execution-progress-duration";
heading.append(title, duration);
const steps = document.createElement("ul");
steps.className = "execution-progress-stages";
card.append(heading, steps);
this.executionCards.set(summary.id, card);
this.updateExecutionSummary(card, summary);
this.attachExecutionCard(summary.id);
if (!card.isConnected) this.messages.append(card);
this.scrollLatest();
}
/** Only terminal summaries survive a page restore; live animation never does. */
public restoreExecutionSummaries(summaries: readonly ExecutionProgressSummary[]): void {
for (const summary of summaries) {
if (summary.status !== "running") this.renderExecutionSummary(summary);
}
}
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
const record = this.toolInteractions?.read(item.call.call_id);
if (item.call.tool === "choice" && item.call.action_group) {
@@ -181,7 +238,14 @@ export class TrustedDOMRendererContext {
meta.className = "meta";
meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`;
bubble.append(meta);
if (role === "agent") {
const content = document.createElement("div");
content.className = "message-agent-content";
content.append(bubble);
row.append(content);
} else {
row.append(bubble);
}
this.messages.append(row);
this.scrollLatest();
return row;
@@ -453,16 +517,42 @@ export class TrustedDOMRendererContext {
for (const control of entry.controls) control.disabled = true;
}
private showThinking(detail: string): void {
this.thinking?.remove();
this.thinking = this.appendBubble("agent", detail);
this.thinking.classList.add("thinking");
this.setPresence("正在思考");
private updateExecutionSummary(card: HTMLDetailsElement, summary: ExecutionProgressSummary): void {
card.dataset.status = summary.status;
card.open = summary.status === "running";
const elapsed = executionElapsed(summary);
const heading = card.querySelector<HTMLElement>(".execution-progress-heading strong");
const duration = card.querySelector<HTMLElement>(".execution-progress-duration");
const steps = card.querySelector<HTMLUListElement>(".execution-progress-stages");
if (!heading || !duration || !steps) return;
heading.textContent = summary.status === "running"
? "正在执行操作"
: summary.status === "completed"
? "已完成处理 · 查看过程摘要"
: "处理未完成 · 查看过程摘要";
duration.textContent = `${elapsed}`;
steps.replaceChildren(...summary.steps.map(step => {
const row = document.createElement("li");
row.dataset.status = step.status;
row.textContent = `${step.status === "running" ? "◌" : step.status === "completed" ? "✓" : "×"} ${step.title}`;
return row;
}));
}
private hideThinking(): void {
this.thinking?.remove();
this.thinking = undefined;
/** Moves a turn's safe ledger card under its matching final Agent reply. */
private attachExecutionCard(executionID: string): void {
const card = this.executionCards.get(executionID);
const reply = this.agentReplyGroups.get(executionID);
const container = reply?.querySelector<HTMLElement>(".message-agent-content");
if (card && container && card.parentElement !== container) container.append(card);
}
private refreshExecutionDurations(): void {
for (const [id, summary] of this.executionSummaries) {
if (summary.status !== "running") continue;
const card = this.executionCards.get(id);
if (card) this.updateExecutionSummary(card, summary);
}
}
private scrollLatest(): void {
@@ -470,6 +560,12 @@ export class TrustedDOMRendererContext {
}
}
function executionElapsed(summary: ExecutionProgressSummary): number {
const end = summary.status === "running" ? Date.now() : Date.parse(summary.finished_at ?? summary.updated_at);
const start = Date.parse(summary.started_at);
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1_000)) : 0;
}
function deliveryLabel(delivery: DeliveryState): string {
if (delivery.status === "local_pending") return "发送中";
if (delivery.status === "failed") return "发送失败";