diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts
index 369ecf4c..f9ef1902 100644
--- a/internal/cloud/access-control.test.ts
+++ b/internal/cloud/access-control.test.ts
@@ -1619,6 +1619,51 @@ test("access controller preserves user message when owner evidence updates with
assert.equal(conversation.firstUserMessagePreview, userText);
});
+test("access controller dedupes duplicate refreshed assistant messages by id and trace", async () => {
+ const projectId = "prj_hwpod_workbench";
+ const ownerUserId = "usr_issue932_dedupe";
+ const conversationId = "cnv_issue932_dedupe";
+ const sessionId = "ses_issue932_dedupe";
+ const traceId = "trc_issue932_dedupe";
+ const threadId = "thread-issue-932-dedupe";
+ const nowValue = "2026-06-05T09:30:00.000Z";
+ const accessController = createAccessController({ now: () => nowValue });
+
+ await accessController.recordAgentSessionOwner({
+ ownerUserId,
+ sessionId,
+ projectId,
+ agentId: "hwlab-code-agent",
+ status: "idle",
+ conversationId,
+ threadId,
+ traceId,
+ session: {
+ source: "cloud-web-react-account-sync",
+ sessionStatus: "completed",
+ lastTraceId: traceId,
+ messages: [
+ { id: "msg_issue932_user", role: "user", title: "用户", text: "你好", status: "sent", traceId, conversationId, sessionId, threadId, createdAt: nowValue },
+ { id: "msg_issue932_agent", role: "agent", title: "Code Agent 回复", text: "最终回复", status: "completed", traceId, conversationId, sessionId, threadId, createdAt: nowValue },
+ { id: "msg_issue932_agent", role: "agent", title: "Code Agent 回复", text: "最终回复", status: "completed", traceId, conversationId, sessionId, threadId, createdAt: nowValue }
+ ],
+ messageCount: 3,
+ firstUserMessagePreview: "你好",
+ valuesRedacted: true,
+ secretMaterialStored: false
+ }
+ });
+
+ const session = await accessController.getAgentSession(sessionId);
+ const conversation = await accessController.visibleConversationForActor({ id: ownerUserId, role: "user" }, conversationId, projectId, { includeArchived: true });
+
+ assert.deepEqual(session.session.messages.map((message) => message.role), ["user", "agent"]);
+ assert.deepEqual(conversation.messages.map((message) => message.role), ["user", "agent"]);
+ assert.equal(session.session.messageCount, 2);
+ assert.equal(conversation.messageCount, 2);
+ assert.equal(conversation.messages[1].text, "最终回复");
+});
+
test("access controller restores AgentRun mapping by Code Agent traceId", async () => {
const accessController = createAccessController({ now: () => "2026-06-01T00:00:00.000Z" });
await accessController.recordAgentSessionOwner({
diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts
index 92ff56d7..a8fcd8d4 100644
--- a/internal/cloud/access-control.ts
+++ b/internal/cloud/access-control.ts
@@ -1872,7 +1872,9 @@ function mergeAgentSessionOwnerEvidence(nextValue, existingValue) {
if (!next.agentRun && existing.agentRun) merged.agentRun = existing.agentRun;
const nextCount = numberOrNull(next.messageCount);
const existingCount = numberOrNull(existing.messageCount);
- if (nextCount !== null || existingCount !== null) {
+ if (Array.isArray(merged.messages)) {
+ merged.messageCount = merged.messages.length;
+ } else if (nextCount !== null || existingCount !== null) {
merged.messageCount = Math.max(nextCount ?? 0, existingCount ?? 0);
}
if (!next.firstUserMessagePreview && existing.firstUserMessagePreview) {
@@ -1883,16 +1885,16 @@ function mergeAgentSessionOwnerEvidence(nextValue, existingValue) {
return merged;
}
function mergeAgentSessionOwnerMessages(nextMessages = [], existingMessages = [], context = {}) {
- const next = Array.isArray(nextMessages) ? nextMessages.map(redactConversationMessage).filter(Boolean) : [];
- if (next.some((message) => message.role === "user")) return next;
- const existing = Array.isArray(existingMessages) ? existingMessages.map(redactConversationMessage).filter(Boolean) : [];
+ const next = dedupeConversationMessages(nextMessages);
+ if (next.some((message) => message.role === "user")) return ensureTerminalUserMessage(next, context).slice(-50);
+ const existing = dedupeConversationMessages(existingMessages);
const merged = [...existing];
for (const message of next) {
const index = merged.findIndex((item) => sameConversationMessage(item, message));
- if (index >= 0) merged[index] = message;
+ if (index >= 0) merged[index] = mergeConversationMessage(merged[index], message);
else merged.push(message);
}
- return ensureTerminalUserMessage(merged, context).slice(-50);
+ return dedupeConversationMessages(ensureTerminalUserMessage(merged, context)).slice(-50);
}
function sameConversationMessage(left = {}, right = {}) {
const leftId = textOr(left.id, "");
@@ -1902,6 +1904,29 @@ function sameConversationMessage(left = {}, right = {}) {
const rightTrace = safeTraceIdLocal(right.traceId);
return Boolean(leftTrace && rightTrace && leftTrace === rightTrace && textOr(left.role, "") === textOr(right.role, ""));
}
+function dedupeConversationMessages(messagesSource = []) {
+ const source = Array.isArray(messagesSource) ? messagesSource : [];
+ const merged = [];
+ for (const rawMessage of source) {
+ const message = redactConversationMessage(rawMessage);
+ if (!message) continue;
+ const index = merged.findIndex((item) => sameConversationMessage(item, message));
+ if (index >= 0) merged[index] = mergeConversationMessage(merged[index], message);
+ else merged.push(message);
+ }
+ return merged.slice(-50);
+}
+function mergeConversationMessage(existing = {}, next = {}) {
+ return pruneEmpty({
+ ...existing,
+ ...next,
+ title: textOr(next.title, existing.title),
+ text: textOr(next.text, existing.text),
+ runnerTrace: next.runnerTrace ?? existing.runnerTrace,
+ updatedAt: textOr(next.updatedAt, existing.updatedAt),
+ createdAt: textOr(next.createdAt, existing.createdAt)
+ });
+}
function firstMessageTraceId(messagesSource) {
return safeTraceIdLocal(firstMessageField(messagesSource, "traceId")) || null;
}
@@ -1926,7 +1951,7 @@ function normalizeWorkspacePatch(body = {}, actor = null) {
selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId ?? workspace.selectedAgentSessionId) || workspace.selectedAgentSessionId,
activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId ?? workspace.activeTraceId) || null,
providerProfile: textOr(body.providerProfile ?? workspace.providerProfile, workspace.providerProfile ?? null),
- messages: Array.isArray(body.messages) ? body.messages.slice(-50).map(redactConversationMessage).filter(Boolean) : Array.isArray(workspace.messages) ? workspace.messages : undefined,
+ messages: Array.isArray(body.messages) ? dedupeConversationMessages(body.messages) : Array.isArray(workspace.messages) ? dedupeConversationMessages(workspace.messages) : undefined,
actor: actor ? publicActor(actor) : undefined,
secretMaterialStored: false,
valuesRedacted: true
@@ -2001,7 +2026,7 @@ function redactedWorkspaceJson(value = {}) {
staleAgentSessionId: textOr(workspace.staleAgentSessionId, ""),
staleThreadId: textOr(workspace.staleThreadId, ""),
recoveryAction: textOr(workspace.recoveryAction, ""),
- messages: Array.isArray(workspace.messages) ? workspace.messages.slice(-50).map(redactConversationMessage).filter(Boolean) : undefined,
+ messages: Array.isArray(workspace.messages) ? dedupeConversationMessages(workspace.messages) : undefined,
actor: workspace.actor && typeof workspace.actor === "object" ? publicActor(workspace.actor) : undefined,
updatedAt: textOr(workspace.updatedAt, ""),
resetAt: textOr(workspace.resetAt, ""),
@@ -2016,12 +2041,14 @@ function normalizeConversationSnapshot(body = {}, actor = null) {
const snapshot = normalizeObject(body.snapshot ?? body);
const messagesSource = Array.isArray(body.messages) ? body.messages : snapshot.messages;
const chatMessagesSource = Array.isArray(body.chatMessages) ? body.chatMessages : snapshot.chatMessages;
+ const messages = dedupeConversationMessages(messagesSource);
+ const chatMessages = Array.isArray(chatMessagesSource) ? dedupeConversationMessages(chatMessagesSource) : undefined;
return {
...snapshot,
- messages: Array.isArray(messagesSource) ? messagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : [],
- chatMessages: Array.isArray(chatMessagesSource) ? chatMessagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : undefined,
- messageCount: resolveSnapshotMessageCount(messagesSource, snapshot.messageCount),
- firstUserMessagePreview: firstUserPreviewFromMessages(messagesSource) ?? snapshot.firstUserMessagePreview ?? null,
+ messages,
+ chatMessages,
+ messageCount: resolveSnapshotMessageCount(messages, snapshot.messageCount),
+ firstUserMessagePreview: firstUserPreviewFromMessages(messages) ?? snapshot.firstUserMessagePreview ?? null,
actor: actor ? publicActor(actor) : undefined,
secretMaterialStored: false,
valuesRedacted: true
@@ -2030,7 +2057,7 @@ function normalizeConversationSnapshot(body = {}, actor = null) {
function resolveSnapshotMessageCount(messagesSource, existingCount) {
const explicit = numberOrNull(existingCount);
const fromSource = Array.isArray(messagesSource) ? messagesSource.length : null;
- if (explicit !== null && fromSource !== null) return Math.max(explicit, fromSource);
+ if (fromSource !== null) return fromSource;
return explicit ?? fromSource ?? 0;
}
function firstUserPreviewFromMessages(messagesSource) {
@@ -2134,14 +2161,13 @@ function isLegacyFinalResponseFallbackText(value) {
return textOr(value, "").trim() === "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。";
}
function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) {
- const baseMessages = Array.isArray(existingMessages) ? existingMessages.map(redactConversationMessage).filter(Boolean) : [];
+ const baseMessages = dedupeConversationMessages(existingMessages);
const traceId = safeTraceIdLocal(result.traceId ?? context.traceId);
const terminalStatus = terminalWorkbenchSessionStatus(result);
const assistantText = textOr(result.reply?.content ?? result.assistantText ?? result.message?.content ?? result.error?.message ?? result.userMessage, "");
const now = textOr(context.now, new Date().toISOString());
const firstUserPreview = firstUserPreviewFromMessages(baseMessages) ?? textOr(context.firstUserMessagePreview, null);
- const messages = ensureTerminalUserMessage(baseMessages, { ...context, traceId, now, firstUserPreview });
- const existingCount = numberOrNull(context.existingMessageCount) ?? messages.length;
+ const messages = dedupeConversationMessages(ensureTerminalUserMessage(baseMessages, { ...context, traceId, now, firstUserPreview }));
const runnerTrace = terminalRunnerTraceForConversationMessage(result, { ...context, traceId, terminalStatus });
const next = messages.map((message) => {
if (traceId && message.traceId === traceId && message.role === "agent") {
@@ -2176,10 +2202,10 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c
}));
addedAgentMessage = true;
}
- const finalMessages = next.slice(-50);
+ const finalMessages = dedupeConversationMessages(next).slice(-50);
return {
messages: finalMessages,
- messageCount: Math.max(existingCount + (addedAgentMessage ? 1 : 0), finalMessages.length),
+ messageCount: finalMessages.length,
firstUserMessagePreview: firstUserPreviewFromMessages(finalMessages) ?? firstUserPreview
};
}
@@ -2216,7 +2242,11 @@ function terminalRunnerTraceForConversationMessage(result = {}, context = {}) {
eventCount: numberOrNull(source.eventCount ?? result.traceSummary?.sourceEventCount ?? result.traceSummary?.eventCount) ?? events.length,
eventsCompacted: source.eventsCompacted === true || events.length === 0,
fullTraceLoaded: false,
- lastEvent
+ lastEvent,
+ traceStatus: textOr(result.traceStatus, ""),
+ fallback: redactTraceFallback(result.fallback),
+ finalResponse: redactTraceFinalResponse(result.finalResponse),
+ traceSummary: redactTraceSummary(result.traceSummary)
});
}
function redactConversationMessage(message) {
@@ -2243,10 +2273,64 @@ function redactConversationMessage(message) {
eventCount: runnerTrace.eventCount,
eventsCompacted: runnerTrace.eventsCompacted === true,
fullTraceLoaded: runnerTrace.fullTraceLoaded === true,
- lastEvent: runnerTrace.lastEvent
+ lastEvent: runnerTrace.lastEvent,
+ traceStatus: textOr(runnerTrace.traceStatus, ""),
+ fallback: redactTraceFallback(runnerTrace.fallback),
+ finalResponse: redactTraceFinalResponse(runnerTrace.finalResponse),
+ traceSummary: redactTraceSummary(runnerTrace.traceSummary)
}) : undefined
});
}
+function redactTraceFallback(value) {
+ const fallback = normalizeObject(value);
+ if (Object.keys(fallback).length === 0) return undefined;
+ return pruneEmpty({
+ available: fallback.available === true,
+ source: textOr(fallback.source, ""),
+ conversationId: textOr(fallback.conversationId, ""),
+ sessionId: textOr(fallback.sessionId, ""),
+ threadId: textOr(fallback.threadId, ""),
+ agentRun: redactAgentRunSummary(fallback.agentRun),
+ finalResponse: redactTraceFinalResponse(fallback.finalResponse),
+ traceSummary: redactTraceSummary(fallback.traceSummary)
+ });
+}
+function redactTraceFinalResponse(value) {
+ const response = normalizeObject(value);
+ if (Object.keys(response).length === 0) return undefined;
+ return pruneEmpty({
+ role: textOr(response.role, ""),
+ status: textOr(response.status, ""),
+ messageId: textOr(response.messageId, ""),
+ text: boundedText(response.text ?? response.content ?? response.message, 12000),
+ textPreview: boundedText(response.textPreview, 1200),
+ textChars: numberOrNull(response.textChars)
+ });
+}
+function redactTraceSummary(value) {
+ const summary = normalizeObject(value);
+ if (Object.keys(summary).length === 0) return undefined;
+ return pruneEmpty({
+ source: textOr(summary.source, ""),
+ terminalStatus: textOr(summary.terminalStatus, ""),
+ sourceEventCount: numberOrNull(summary.sourceEventCount ?? summary.eventCount),
+ lastEventLabel: textOr(summary.lastEventLabel, ""),
+ finalAssistantRow: redactTraceFinalResponse(summary.finalAssistantRow)
+ });
+}
+function redactAgentRunSummary(value) {
+ const agentRun = normalizeObject(value);
+ if (Object.keys(agentRun).length === 0) return undefined;
+ return pruneEmpty({
+ adapter: textOr(agentRun.adapter, ""),
+ backendProfile: textOr(agentRun.backendProfile, ""),
+ runId: textOr(agentRun.runId, ""),
+ commandId: textOr(agentRun.commandId, ""),
+ jobName: textOr(agentRun.jobName, ""),
+ namespace: textOr(agentRun.namespace, ""),
+ terminalStatus: textOr(agentRun.terminalStatus, "")
+ });
+}
function conversationsFromAgentSessions(sessions = []) {
const byConversation = new Map();
for (const session of sessions) {
@@ -2261,14 +2345,14 @@ function conversationsFromAgentSessions(sessions = []) {
function publicAgentConversation(session) {
const snapshot = normalizeObject(session.session);
const rawMessages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : [];
- const messages = ensureTerminalUserMessage(rawMessages, {
+ const messages = dedupeConversationMessages(ensureTerminalUserMessage(rawMessages, {
traceId: session.lastTraceId ?? snapshot.lastTraceId,
conversationId: session.conversationId,
sessionId: session.id,
threadId: session.threadId,
now: session.updatedAt,
firstUserPreview: snapshot.firstUserMessagePreview
- });
+ }));
const status = resolvedConversationStatus(session.status, snapshot, messages);
const lastTraceId = resolvedConversationLastTraceId(session.lastTraceId, status, messages, snapshot);
return {
@@ -2285,7 +2369,7 @@ function publicAgentConversation(session) {
endedAt: session.endedAt,
session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status }),
messages,
- messageCount: numberOrNull(snapshot.messageCount) ?? messages.length,
+ messageCount: messages.length,
firstUserMessagePreview: textOr(snapshot.firstUserMessagePreview, null),
snapshot: pruneEmpty({
sessionStatus: snapshot.sessionStatus,
diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx
index e57c21cc..9ef8200f 100644
--- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx
+++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx
@@ -55,3 +55,23 @@ test("message trace panel exposes running status for live trace animation", () =
assert.match(html, /data-trace-status="running"/u);
assert.match(html, /open=""/u);
});
+
+test("message trace panel renders persisted fallback for expired historical trace", () => {
+ const trace: RunnerTrace = {
+ traceId: "trc_issue932_expired",
+ status: "expired",
+ traceStatus: "expired",
+ events: [],
+ eventCount: 31,
+ fallback: { available: true, source: "agent-session-snapshot" },
+ traceSummary: { sourceEventCount: 31, terminalStatus: "completed", source: "agent-session-snapshot" },
+ finalResponse: { text: "历史最终回复" }
+ };
+
+ const html = renderToStaticMarkup();
+
+ assert.match(html, /历史 trace 已过期/u);
+ assert.match(html, /历史最终回复/u);
+ assert.match(html, /sourceEventCount=31/u);
+ assert.doesNotMatch(html, /等待后端事件/u);
+});
diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
index 379f3f11..18d6ac7e 100644
--- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
+++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
@@ -46,7 +46,10 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey, markdownUpgr
const events = Array.isArray(trace.events) ? trace.events : [];
const last = summarizeLastEvent(trace);
- const rows = useMemo(() => traceDisplayRows(trace, events), [trace, events]);
+ const rows = useMemo(() => {
+ const eventRows = traceDisplayRows(trace, events);
+ return eventRows.length > 0 ? eventRows : persistentTraceFallbackRows(trace);
+ }, [trace, events]);
const noiseCount = useMemo(() => traceNoiseEventCount(events), [events]);
const lastUpdatedAt = traceUpdatedAt(trace, events, last.ts);
@@ -113,6 +116,42 @@ function traceCountText(trace: RunnerTrace, events: number, rows: number, noiseC
return `${rows}/${events}/${rawTotal} 行${noise}`;
}
+function persistentTraceFallbackRows(trace: RunnerTrace): TraceEventRow[] {
+ const fallback = objectOrNull(trace.fallback);
+ const summary = objectOrNull(trace.traceSummary);
+ const response = objectOrNull(trace.finalResponse);
+ const text = firstNonEmptyTraceText(response?.text, response?.content, response?.message, summary?.finalText, summary?.text);
+ const sourceCount = positiveInteger(summary?.sourceEventCount ?? summary?.eventCount ?? trace.eventCount);
+ if (!text && fallback?.available !== true && sourceCount === 0) return [];
+ const status = firstNonEmptyTraceText(trace.traceStatus, trace.status, summary?.terminalStatus, objectOrNull(trace.agentRun)?.terminalStatus) ?? "expired";
+ const header = `${trace.traceId ?? "trace"} 历史 trace 已过期,显示持久化摘要`;
+ const meta = [
+ `traceStatus=${status}`,
+ sourceCount > 0 ? `sourceEventCount=${sourceCount}` : null,
+ firstNonEmptyTraceText(fallback?.source, summary?.source) ? `source=${firstNonEmptyTraceText(fallback?.source, summary?.source)}` : null
+ ].filter(Boolean).join("\n");
+ const body = [meta, text].filter(Boolean).join("\n\n") || "后端没有保留原始 event,但已返回持久化 trace 摘要。";
+ return [{ rowId: `fallback:${trace.traceId ?? "trace"}`, seq: null, tone: "source", header, body, terminal: true, bodyFormat: text ? "markdown" : "text" }];
+}
+
+function objectOrNull(value: unknown): Record | null {
+ return value && typeof value === "object" ? value as Record : null;
+}
+
+function firstNonEmptyTraceText(...values: unknown[]): string | null {
+ for (const value of values) {
+ if (typeof value !== "string") continue;
+ const text = value.trim();
+ if (text) return text;
+ }
+ return null;
+}
+
+function positiveInteger(value: unknown): number {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
+}
+
function isRunningTrace(trace: RunnerTrace): boolean {
return String(trace.status ?? "").toLowerCase() === "running";
}
diff --git a/web/hwlab-cloud-web/src/state/conversation.test.ts b/web/hwlab-cloud-web/src/state/conversation.test.ts
new file mode 100644
index 00000000..ac5f057f
--- /dev/null
+++ b/web/hwlab-cloud-web/src/state/conversation.test.ts
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import { test } from "bun:test";
+
+import { mergeConversationMessages } from "./conversation";
+import type { ChatMessage } from "../types/domain";
+
+test("mergeConversationMessages replaces a refreshed agent message instead of appending a duplicate", () => {
+ const base: ChatMessage[] = [
+ message({ id: "msg_user", role: "user", text: "你好", status: "sent" }),
+ message({ id: "msg_agent", role: "agent", text: "处理中", status: "running" })
+ ];
+
+ const merged = mergeConversationMessages(base, [
+ message({ id: "msg_agent", role: "agent", text: "完成回复", status: "completed", updatedAt: "2026-06-05T10:00:00.000Z" })
+ ]);
+
+ assert.equal(merged.length, 2);
+ const agent = merged[1];
+ assert.ok(agent);
+ assert.equal(agent.text, "完成回复");
+ assert.equal(agent.status, "completed");
+});
+
+test("mergeConversationMessages dedupes same trace and role when a refresh generated a new local id", () => {
+ const merged = mergeConversationMessages([
+ message({ id: "msg_agent_old", role: "agent", text: "旧回复", status: "running" })
+ ], [
+ message({ id: "msg_agent_new", role: "agent", text: "最终回复", status: "completed" })
+ ]);
+
+ assert.equal(merged.length, 1);
+ const agent = merged[0];
+ assert.ok(agent);
+ assert.equal(agent.id, "msg_agent_new");
+ assert.equal(agent.text, "最终回复");
+});
+
+function message(overrides: Partial): ChatMessage {
+ return {
+ id: "msg_default",
+ role: "agent",
+ title: "Code Agent",
+ text: "",
+ status: "running",
+ traceId: "trc_issue932_refresh",
+ conversationId: "cnv_issue932_refresh",
+ sessionId: "ses_issue932_refresh",
+ threadId: "thread-issue932-refresh",
+ createdAt: "2026-06-05T09:59:00.000Z",
+ ...overrides
+ };
+}
diff --git a/web/hwlab-cloud-web/src/state/conversation.ts b/web/hwlab-cloud-web/src/state/conversation.ts
index f73a61c3..2fde7107 100644
--- a/web/hwlab-cloud-web/src/state/conversation.ts
+++ b/web/hwlab-cloud-web/src/state/conversation.ts
@@ -14,6 +14,36 @@ export function makeMessage(role: "user" | "agent", text: string, status: ChatMe
return { id: nextProtocolId("msg"), role, title: meta.title ?? (role === "user" ? "用户" : "Code Agent"), text, status, traceId: meta.traceId, conversationId: meta.conversationId, sessionId: meta.sessionId, threadId: meta.threadId, createdAt: new Date().toISOString() };
}
+export function mergeConversationMessages(existing: ChatMessage[], incoming: ChatMessage[] = []): ChatMessage[] {
+ const merged: ChatMessage[] = [];
+ for (const message of [...existing, ...incoming]) {
+ const index = merged.findIndex((item) => sameConversationMessage(item, message));
+ const current = index >= 0 ? merged[index] : null;
+ if (current) merged[index] = mergeConversationMessage(current, message);
+ else merged.push(message);
+ }
+ return merged;
+}
+
+function mergeConversationMessage(existing: ChatMessage, next: ChatMessage): ChatMessage {
+ return {
+ ...existing,
+ ...next,
+ title: firstNonEmptyString(next.title, existing.title) ?? next.title,
+ text: firstNonEmptyString(next.text, existing.text) ?? next.text,
+ runnerTrace: next.runnerTrace ?? existing.runnerTrace,
+ traceEvents: next.traceEvents ?? existing.traceEvents,
+ error: next.error ?? existing.error,
+ updatedAt: next.updatedAt ?? existing.updatedAt
+ };
+}
+
+function sameConversationMessage(left: ChatMessage, right: ChatMessage): boolean {
+ if (left.id && right.id && left.id === right.id) return true;
+ if (!left.traceId || !right.traceId || left.traceId !== right.traceId || left.role !== right.role) return false;
+ return !left.conversationId || !right.conversationId || left.conversationId === right.conversationId;
+}
+
export function messageFromAgentResponse(messageId: string, pending: ChatMessage, response: AgentChatResponse | AgentChatResultResponse): ChatMessage {
const errorObject = typeof response.error === "object" && response.error ? response.error : null;
const failed = Boolean(response.error) || ["failed", "blocked", "timeout", "cancelled"].includes(String(response.status ?? ""));
@@ -96,15 +126,17 @@ export async function ensureWorkspace(current: WorkspaceRecord | null): Promise<
}
export async function persistConversation(input: { workspace: WorkspaceRecord | null; conversationId: string; sessionId: string | null; threadId: string | null; messages: ChatMessage[] }): Promise {
+ const messages = mergeConversationMessages(input.messages);
+ const latest = messages.at(-1);
await api.saveConversation(input.conversationId, {
projectId: WORKBENCH_PROJECT_ID,
conversationId: input.conversationId,
sessionId: input.sessionId,
threadId: input.threadId,
- sessionStatus: input.messages.at(-1)?.status ?? "source",
- lastTraceId: input.messages.at(-1)?.traceId,
- messages: input.messages,
- snapshot: { source: "cloud-web-react-account-sync", updatedAt: new Date().toISOString(), sessionStatus: input.messages.at(-1)?.status ?? "source" }
+ sessionStatus: latest?.status ?? "source",
+ lastTraceId: latest?.traceId,
+ messages,
+ snapshot: { source: "cloud-web-react-account-sync", updatedAt: new Date().toISOString(), sessionStatus: latest?.status ?? "source" }
});
if (input.workspace?.workspaceId) {
const response = await api.updateWorkspace(input.workspace.workspaceId, {
@@ -113,8 +145,8 @@ export async function persistConversation(input: { workspace: WorkspaceRecord |
selectedConversationId: input.conversationId,
selectedAgentSessionId: input.sessionId,
threadId: input.threadId,
- activeTraceId: input.messages.findLast((message) => message.status === "running")?.traceId ?? null,
- messages: input.messages,
+ activeTraceId: messages.findLast((message) => message.status === "running")?.traceId ?? null,
+ messages,
workspace: { source: "cloud-web-react-workspace-sync", updatedAt: new Date().toISOString() },
updatedByClient: "cloud-web-react"
});
diff --git a/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts b/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts
index c8a987fd..94a78fc8 100644
--- a/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts
+++ b/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts
@@ -29,8 +29,9 @@ test("issue 853 submit failure persists the same trace before refresh", () => {
test("issue 853 left sidebar collapse only removes the left columns", () => {
const css = fs.readFileSync(path.join(srcRoot, "styles/workbench.css"), "utf8");
- assert.match(css, /\.workbench-shell\.is-left-sidebar-collapsed \.workspace-body \{ grid-template-columns: 0 0 minmax\(0, 1fr\) var\(--resize-handle-width\) var\(--right-sidebar-width\); \}/u);
- assert.doesNotMatch(css, /\.workbench-shell\.is-left-sidebar-collapsed \.workspace-body \{ grid-template-columns: 0 0 minmax\(420px, 1fr\)/u);
+ assert.match(css, /--right-sidebar-left-collapsed-width: min\(var\(--right-sidebar-width\), 46vw\);/u);
+ assert.match(css, /\.workbench-shell\.is-left-sidebar-collapsed \.workspace-body \{ grid-template-columns: 0 0 minmax\(360px, 1fr\) var\(--resize-handle-width\) minmax\(min\(var\(--right-sidebar-min-width\), 46vw\), var\(--right-sidebar-left-collapsed-width\)\); \}/u);
+ assert.doesNotMatch(css, /\.workbench-shell\.is-left-sidebar-collapsed \.workspace-body \{ grid-template-columns: 0 0 minmax\(0, 1fr\) var\(--resize-handle-width\) var\(--right-sidebar-width\); \}/u);
});
test("issue 853 completed trace panels keep stored open state instead of closing on refresh", () => {
diff --git a/web/hwlab-cloud-web/src/state/runner-trace.test.ts b/web/hwlab-cloud-web/src/state/runner-trace.test.ts
index bf5ebede..969e0c8c 100644
--- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts
+++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts
@@ -379,6 +379,36 @@ test("fetchTraceSnapshot retries until fast-fail trace events are readable", asy
}
});
+test("fetchTraceSnapshot accepts expired trace fallback without waiting for raw events", async () => {
+ const originalFetch = globalThis.fetch;
+ const fetches: string[] = [];
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ fetches.push(url);
+ return jsonResponse({
+ status: "completed",
+ traceStatus: "expired",
+ traceId: "trc_expired_fallback",
+ events: [],
+ eventCount: 0,
+ fallback: { available: true, source: "agent-session-snapshot" },
+ traceSummary: { sourceEventCount: 31, terminalStatus: "completed" },
+ finalResponse: { text: "过期 trace 的最终回复" }
+ });
+ }) as typeof fetch;
+
+ try {
+ const snapshot = await fetchTraceSnapshot("trc_expired_fallback", "trc_expired_fallback", 1000);
+ assert.equal(snapshot?.status, "expired");
+ assert.equal(snapshot?.eventCount, 31);
+ assert.equal((snapshot?.fallback as { source?: string })?.source, "agent-session-snapshot");
+ assert.equal((snapshot?.finalResponse as { text?: string })?.text, "过期 trace 的最终回复");
+ assert.equal(fetches.length, 1);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts
index ecbffa06..d6021dde 100644
--- a/web/hwlab-cloud-web/src/state/runner-trace.ts
+++ b/web/hwlab-cloud-web/src/state/runner-trace.ts
@@ -17,6 +17,11 @@ export interface TraceSnapshot {
eventCount?: number;
eventsCompacted?: boolean;
agentRun?: AgentRunProvenance;
+ traceStatus?: string;
+ retention?: unknown;
+ fallback?: unknown;
+ finalResponse?: unknown;
+ traceSummary?: unknown;
lastEventLabel?: string;
waitingFor?: string;
updatedAt?: string;
@@ -67,6 +72,12 @@ export function snapshotToRunnerTrace(snapshot: TraceSnapshot): NonNullable= maxAttempts || Date.now() - startedAt >= totalTimeoutMs) break;
await new Promise((resolve) => window.setTimeout(resolve, Math.min(TRACE_SNAPSHOT_RETRY_INTERVAL_MS, totalTimeoutMs - (Date.now() - startedAt))));
@@ -345,7 +366,7 @@ function snapshotFromTracePoll(traceId: string, result: AgentChatResultResponse)
...result,
traceId,
events,
- eventCount: Number(result.eventCount ?? events.length) || 0,
+ eventCount: traceSnapshotEventCount(result, events),
lastEventLabel: String(last?.label ?? last?.type ?? result.lastEventLabel ?? "")
} as AgentChatResultResponse);
}
@@ -354,15 +375,31 @@ function hasTraceEvents(snapshot: TraceSnapshot): boolean {
return Array.isArray(snapshot.events) && snapshot.events.length > 0;
}
+function hasPersistedTraceFallback(snapshot: TraceSnapshot): boolean {
+ const fallback = objectOrNull(snapshot.fallback);
+ const summary = objectOrNull(snapshot.traceSummary);
+ return Boolean(
+ fallback?.available === true ||
+ finalResponseText(snapshot.finalResponse) ||
+ positiveNumber(summary?.sourceEventCount ?? summary?.eventCount) > 0
+ );
+}
+
function snapshotToTraceSnapshot(result: AgentChatResultResponse): TraceSnapshot {
const events = Array.isArray((result as { events?: TraceEvent[] }).events) ? ((result as { events?: TraceEvent[] }).events ?? []) : [];
+ const traceStatus = firstNonEmptyString(result.traceStatus, result.status);
return {
traceId: result.traceId,
- status: result.status,
+ status: traceStatus ?? result.status,
sessionId: result.sessionId ?? null,
threadId: result.threadId ?? null,
events,
- eventCount: Number(result.eventCount ?? events.length) || 0,
+ eventCount: traceSnapshotEventCount(result, events),
+ traceStatus: typeof result.traceStatus === "string" ? result.traceStatus : undefined,
+ retention: result.retention,
+ fallback: result.fallback,
+ finalResponse: result.finalResponse,
+ traceSummary: result.traceSummary,
agentRun: result.agentRun,
lastEventLabel: typeof result.lastEventLabel === "string" ? result.lastEventLabel : String(events.at(-1)?.label ?? events.at(-1)?.type ?? ""),
updatedAt: typeof result.updatedAt === "string" ? result.updatedAt : new Date().toISOString()
@@ -378,15 +415,26 @@ export async function replayFullTrace(traceId: string, totalTimeoutMs = 15000):
polled = await api.getAgentChatResult(url, Math.max(1000, totalTimeoutMs - (Date.now() - startedAt)));
}
if (!polled.ok || !polled.data) return null;
- const events = Array.isArray(polled.data.events) ? polled.data.events : [];
- const lastEvent = events[events.length - 1];
- return {
- traceId,
- status: String(polled.data.status ?? "completed"),
- events,
- eventCount: Number(polled.data.eventCount ?? events.length) || 0,
- eventsCompacted: true,
- lastEventLabel: String(lastEvent?.label ?? lastEvent?.type ?? ""),
- updatedAt: String(polled.data.updatedAt ?? new Date().toISOString())
- };
+ return { ...snapshotFromTracePoll(traceId, polled.data), eventsCompacted: true };
+}
+
+function traceSnapshotEventCount(result: AgentChatResultResponse, events: TraceEvent[]): number {
+ const explicit = positiveNumber(result.eventCount);
+ if (explicit > 0 || events.length > 0) return explicit || events.length;
+ const summary = objectOrNull(result.traceSummary);
+ return positiveNumber(summary?.sourceEventCount ?? summary?.eventCount) || 0;
+}
+
+function positiveNumber(value: unknown): number {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
+}
+
+function objectOrNull(value: unknown): Record | null {
+ return value && typeof value === "object" ? value as Record : null;
+}
+
+function finalResponseText(value: unknown): string | null {
+ const item = objectOrNull(value);
+ return firstNonEmptyString(item?.text, item?.content, item?.message);
}
diff --git a/web/hwlab-cloud-web/src/state/trace-reattach.ts b/web/hwlab-cloud-web/src/state/trace-reattach.ts
index 2e5a866c..0f2a4721 100644
--- a/web/hwlab-cloud-web/src/state/trace-reattach.ts
+++ b/web/hwlab-cloud-web/src/state/trace-reattach.ts
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { firstNonEmptyString, nextProtocolId } from "../utils";
-import { makeMessage, messageFromAgentResponse, persistConversation } from "./conversation";
+import { makeMessage, mergeConversationMessages, messageFromAgentResponse, persistConversation } from "./conversation";
import { mergeTraceResults, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "./runner-trace";
import type { Action } from "./workbench-reducer";
import type { WorkbenchState } from "./workbench-state";
@@ -50,13 +50,13 @@ export function useTraceReattach(config: UseTraceReattachConfig): void {
const stub = messages.find((m) => m.id === placeholderId) ?? ({ id: placeholderId, role: "agent" as const, text: "", status: "running" as const, traceId: activeTraceId, conversationId, sessionId, threadId, createdAt: new Date().toISOString() } as ChatMessage);
const completed = messageFromAgentResponse(placeholderId, stub, mergeTraceResults(result, lastSnapshot));
onDispatch({ type: "message:complete", messageId: placeholderId, message: completed, availability: result.availability ?? null, workspace: ws ?? undefined });
- void onPersist({ workspace: ws, conversationId, sessionId: result.sessionId ?? sessionId, threadId: result.threadId ?? threadId, messages: [...messages, completed] });
+ void onPersist({ workspace: ws, conversationId, sessionId: result.sessionId ?? sessionId, threadId: result.threadId ?? threadId, messages: mergeConversationMessages(messages, [completed]) });
controller.abort();
},
onInfrastructureError: (error) => {
const failed = makeMessage("agent", `Code Agent re-attach ${codeAgentTimeoutMs}ms 无活动(${error})`, "failed", { traceId: activeTraceId, conversationId, sessionId, threadId, title: "Code Agent 超时" });
onDispatch({ type: "message:fail", messageId: placeholderId, message: failed });
- void onPersist({ workspace: ws, conversationId, sessionId, threadId, messages: [...messages, failed] });
+ void onPersist({ workspace: ws, conversationId, sessionId, threadId, messages: mergeConversationMessages(messages, [failed]) });
controller.abort();
}
}).finally(() => controller.abort());
diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css
index 330ccdae..ff7c0416 100644
--- a/web/hwlab-cloud-web/src/styles/workbench.css
+++ b/web/hwlab-cloud-web/src/styles/workbench.css
@@ -19,6 +19,7 @@
--right-sidebar-width: 620px;
--right-sidebar-min-width: 420px;
--right-sidebar-max-width: 680px;
+ --right-sidebar-left-collapsed-width: min(var(--right-sidebar-width), 46vw);
--right-collapsed-width: 46px;
--resize-handle-width: 8px;
}
@@ -55,7 +56,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.status-chip-label { flex: 0 0 auto; }
.status-chip-meta { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
.workspace-body { min-width: 0; min-height: 0; display: grid; grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) var(--resize-handle-width) var(--right-sidebar-width); grid-template-rows: minmax(0, 1fr); overflow: hidden; }
-.workbench-shell.is-left-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(0, 1fr) var(--resize-handle-width) var(--right-sidebar-width); }
+.workbench-shell.is-left-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(360px, 1fr) var(--resize-handle-width) minmax(min(var(--right-sidebar-min-width), 46vw), var(--right-sidebar-left-collapsed-width)); }
.workbench-shell.is-right-sidebar-collapsed .workspace-body { grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) 0 0; }
.workbench-shell.is-left-sidebar-collapsed.is-right-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(0, 1fr) 0 0; }
.workspace-body.is-route-without-sidebars { grid-template-columns: minmax(0, 1fr); }