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); }