From ce7c2658cfdade61a1e7a15307cf6fa69d19b48a Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:28:31 +0800 Subject: [PATCH] fix: unify workbench conversation message persistence (#1281) --- internal/cloud/access-control.ts | 48 ++++++++++++---- internal/cloud/code-agent-agentrun-adapter.ts | 28 ++++++++-- internal/cloud/server-code-agent-http.ts | 22 +++++++- tools/src/hwlab-cli-lib.ts | 56 +++++++++++++++---- 4 files changed, 129 insertions(+), 25 deletions(-) diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 612fd737..52ef091b 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2190,7 +2190,7 @@ function agentSessionContainsTraceId(session, traceId) { return [snapshot.messages, snapshot.chatMessages].some((messages) => Array.isArray(messages) && messages.some((message) => safeTraceIdLocal(message?.traceId) === id)); } function normalizeFinalResponseConversationMessages(messages, finalResponse) { - const finalText = textOr(finalResponse?.text, ""); + const finalText = conversationText(finalResponse?.text ?? finalResponse?.content ?? finalResponse?.message, 12000); const traceId = safeTraceIdLocal(finalResponse?.traceId); if (!Array.isArray(messages) || !finalText) return messages; return messages.map((message) => { @@ -2238,11 +2238,13 @@ function dedupeConversationMessages(messagesSource = []) { return merged.slice(-50); } function mergeConversationMessage(existing = {}, next = {}) { + const nextText = conversationText(next.text ?? next.content ?? next.message, 12000); + const existingText = conversationText(existing.text ?? existing.content ?? existing.message, 12000); return pruneEmpty({ ...existing, ...next, title: textOr(next.title, existing.title), - text: textOr(next.text, existing.text), + text: nextText || existingText, runnerTrace: next.runnerTrace ?? existing.runnerTrace, updatedAt: textOr(next.updatedAt, existing.updatedAt), createdAt: textOr(next.createdAt, existing.createdAt) @@ -2386,7 +2388,7 @@ function firstUserPreviewFromMessages(messagesSource) { for (const message of messagesSource) { if (!message || typeof message !== "object") continue; if (String(message.role ?? "").toLowerCase() !== "user") continue; - const text = textOr(message.text ?? message.title ?? message.content, ""); + const text = conversationText(message.text ?? message.content ?? message.message ?? message.title, 240); if (text) return boundedText(text, 240); } return null; @@ -2465,9 +2467,9 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c 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 assistantText = firstConversationText([result.reply?.content, result.assistantText, result.message?.content, result.error?.message, result.userMessage], 12000); const now = textOr(context.now, new Date().toISOString()); - const firstUserPreview = firstUserPreviewFromMessages(baseMessages) ?? textOr(context.firstUserMessagePreview, null); + const firstUserPreview = firstUserPreviewFromMessages(baseMessages) ?? (conversationText(context.firstUserMessagePreview, 240) || null); const messages = dedupeConversationMessages(ensureTerminalUserMessage(baseMessages, { ...context, traceId, now, firstUserPreview })); const runnerTrace = terminalRunnerTraceForConversationMessage(result, { ...context, traceId, terminalStatus }); const next = messages.map((message) => { @@ -2512,7 +2514,7 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c } function ensureTerminalUserMessage(messages = [], context = {}) { if (messages.some((message) => message?.role === "user")) return messages; - const userText = textOr(context.firstUserPreview, ""); + const userText = conversationText(context.firstUserPreview, 12000); if (!userText) return messages; const traceId = safeTraceIdLocal(context.traceId); const userMessage = redactConversationMessage({ @@ -2552,11 +2554,12 @@ function terminalRunnerTraceForConversationMessage(result = {}, context = {}) { function redactConversationMessage(message) { if (!message || typeof message !== "object") return null; const runnerTrace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; + const text = conversationText(message.text ?? message.content ?? message.message, 12000); return pruneEmpty({ id: textOr(message.id, ""), role: textOr(message.role, ""), title: textOr(message.title, ""), - text: boundedText(message.text, 12000), + text, status: textOr(message.status, ""), traceId: textOr(message.traceId, ""), conversationId: textOr(message.conversationId, ""), @@ -2588,8 +2591,8 @@ function redactTraceFinalResponse(value) { 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), + text: conversationText(response.text ?? response.content ?? response.message, 12000), + textPreview: conversationText(response.textPreview, 1200), textChars: numberOrNull(response.textChars) }); } @@ -2677,7 +2680,7 @@ function publicAgentConversation(session) { session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status }), messages: displayMessages, messageCount: displayMessages.length, - firstUserMessagePreview: textOr(snapshot.firstUserMessagePreview, null), + firstUserMessagePreview: conversationText(snapshot.firstUserMessagePreview, 240) || null, snapshot: pruneEmpty({ sessionStatus: snapshot.sessionStatus, source: snapshot.source, @@ -2724,6 +2727,31 @@ function boundedText(value, maxBytes) { const buffer = Buffer.from(text, "utf8"); return buffer.length > maxBytes ? buffer.subarray(0, maxBytes).toString("utf8") : text; } +function firstConversationText(values = [], maxBytes = 12000) { + for (const value of values) { + const text = conversationText(value, maxBytes); + if (text) return text; + } + return ""; +} +function conversationText(value, maxBytes = 12000) { + const extracted = conversationTextRaw(value).replace(/\s+/gu, " ").trim(); + if (!extracted || extracted === "[object Object]") return ""; + return boundedText(extracted, maxBytes); +} +function conversationTextRaw(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map(conversationTextRaw).filter(Boolean).join("\n"); + if (typeof value === "object") { + for (const key of ["text", "content", "message", "prompt", "value", "summary", "preview", "title"]) { + const text = conversationTextRaw(value[key]); + if (text) return text; + } + } + return ""; +} function pruneEmpty(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); } function safeReturnTo(value) { const text = textOr(value, "/workbench").trim() || "/workbench"; diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index a910f228..c99562c7 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -924,7 +924,7 @@ function agentRunPromptTextFromMessages(messages = [], traceId = null) { } function agentRunPromptMetadataFromText(value, traceId = null, source = "hwlab-code-agent-request", options = {}) { - const promptText = String(value ?? "").trim(); + const promptText = promptTextValue(value); if (!promptText) return null; return agentRunPromptMetadataFromParts({ traceId, @@ -991,11 +991,31 @@ function agentRunPromptId(traceId = null, digest = null) { } function clippedPromptText(value, limit) { - const promptText = String(value ?? "").trim(); + const promptText = promptTextValue(value); if (!promptText) return undefined; return promptText.length > limit ? `${promptText.slice(0, limit)}...` : promptText; } +function promptTextValue(value) { + const extracted = promptTextRaw(value).replace(/\s+/gu, " ").trim(); + if (!extracted || extracted === "[object Object]") return ""; + return extracted; +} + +function promptTextRaw(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map(promptTextRaw).filter(Boolean).join("\n"); + if (typeof value === "object") { + for (const key of ["text", "content", "message", "prompt", "value", "summary", "preview", "title"]) { + const text = promptTextRaw(value[key]); + if (text) return text; + } + } + return ""; +} + function sha256Text(value) { return createHash("sha256").update(String(value)).digest("hex"); } @@ -1009,7 +1029,7 @@ function adapterError(code, message, details = {}) { } function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) { - const prompt = String(params.message ?? params.prompt ?? "").trim(); + const prompt = promptTextValue(params.message ?? params.prompt ?? ""); const promptEvidence = agentRunPromptMetadataFromText(prompt, traceId, "agentrun-command-payload", { fullText: true }); const threadId = safeOpaqueId(params.threadId); return { @@ -1036,7 +1056,7 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId } function buildAgentRunSteerCommandInput({ params, traceId, steerTraceId, mapped }) { - const prompt = String(params.message ?? params.prompt ?? params.text ?? "").trim(); + const prompt = promptTextValue(params.message ?? params.prompt ?? params.text ?? ""); const promptEvidence = agentRunPromptMetadataFromText(prompt, steerTraceId, "agentrun-steer-command-payload", { fullText: true }); return { type: "steer", diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 069a598b..3feaaa33 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -2037,11 +2037,31 @@ function codeAgentConversationMessagesEvidence(payload = {}, params = {}, traceI } function boundedConversationMessageText(value, limit = 12000) { - const text = textValue(value); + const text = conversationText(value); if (!text) return ""; return text.length > limit ? text.slice(0, limit) : text; } +function conversationText(value) { + const extracted = conversationTextRaw(value).replace(/\s+/gu, " ").trim(); + if (!extracted || extracted === "[object Object]") return ""; + return extracted; +} + +function conversationTextRaw(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map(conversationTextRaw).filter(Boolean).join("\n"); + if (typeof value === "object") { + for (const key of ["text", "content", "message", "prompt", "value", "summary", "preview", "title"]) { + const text = conversationTextRaw(value[key]); + if (text) return text; + } + } + return ""; +} + function codeAgentMessageTraceSuffix(traceId) { const text = safeTraceId(traceId) || `local_${Date.now().toString(36)}`; return text.replace(/^trc_/u, "").replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 48) || "trace"; diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 1ba0b1d3..4e0568ad 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -2262,12 +2262,12 @@ async function agentSend(context: any) { if (!isHttpSuccess(accepted)) { return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary }); } - await saveAcceptedAgentWorkspaceState(context, accepted.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile }); + await saveAcceptedAgentWorkspaceState(context, accepted.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile, message }); if (parsed.wait !== true) { return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary, workspace: workspaceSummaryFromSession(await loadStoredState({ parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), waited: false, waitPolicy: agentSendWaitPolicy(traceId) }); } const result = await pollAgentResult(context, traceId, accepted.body); - const completedWorkspace = await saveCompletedAgentWorkspaceState(context, result.response?.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile }); + const completedWorkspace = await saveCompletedAgentWorkspaceState(context, result.response?.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile, message }); return ok("client.agent.send", { status: result.final ? "succeeded" : "timeout", route: route("POST", "/v1/agent/chat"), @@ -3351,18 +3351,40 @@ function agentSessionSummary(session: any, fallback: any = {}) { } function compactAgentMessagesForWorkspace(resultBody: any, fallback: any) { - return [{ + const traceId = text(resultBody?.traceId) || text(fallback.traceId); + const conversationId = text(resultBody?.conversationId) || text(fallback.conversationId); + const sessionId = text(resultBody?.sessionId) || text(fallback.sessionId); + const threadId = text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId); + const now = new Date().toISOString(); + const userText = messageTextValue(fallback.message ?? resultBody?.userMessage ?? resultBody?.prompt); + const messages = []; + if (userText) { + messages.push({ + id: traceId ? `msg_${traceId.slice(4)}_user` : makeId("msg_user"), + role: "user", + title: "用户", + text: userText, + status: "sent", + traceId, + conversationId, + sessionId, + threadId, + updatedAt: now + }); + } + messages.push({ id: makeId("msg"), role: "agent", title: "Code Agent result", - text: assistantText(resultBody) || text(resultBody?.error?.message) || text(resultBody?.status), + text: messageTextValue(assistantText(resultBody) || resultBody?.error?.message || resultBody?.status), status: text(resultBody?.status) || "unknown", - traceId: text(resultBody?.traceId) || text(fallback.traceId), - conversationId: text(resultBody?.conversationId) || text(fallback.conversationId), - sessionId: text(resultBody?.sessionId) || text(fallback.sessionId), - threadId: text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId), - updatedAt: new Date().toISOString() - }]; + traceId, + conversationId, + sessionId, + threadId, + updatedAt: now + }); + return messages; } function compactProbe(method: string, pathName: string, response: any) { @@ -4183,6 +4205,20 @@ function cliError(code: string, message: string, details: Record= 200 && response.status < 300; } function text(value: unknown) { return String(value ?? "").trim(); } +function messageTextValue(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value.trim(); + if (typeof value === "number" || typeof value === "boolean") return String(value).trim(); + if (Array.isArray(value)) return value.map(messageTextValue).filter(Boolean).join("\n").trim(); + if (typeof value === "object") { + const record = value as Record; + for (const key of ["text", "content", "message", "prompt", "value", "summary", "preview", "title"]) { + const extracted = messageTextValue(record[key]); + if (extracted) return extracted; + } + } + return ""; +} function textOrNull(value: unknown) { const result = text(value); return result || null; } function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; } function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; }