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
+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"; }