diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 9c3501ef..c8d1551f 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1181,7 +1181,13 @@ test("cloud api repairs conversation snapshot when persisted result advances las const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue842-snapshot", password: "alice-pass" }, adminLogin.cookie); assert.equal(aliceCreate.status, 201); const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue842-snapshot", password: "alice-pass" }); - const finalText = "持久化验收OK。"; + const finalText = [ + "持久化验收OK。", + "", + "| Skill | 状态 |", + "|---|---|", + "| hwlab-agent-runtime | 可用 |" + ].join("\n"); codeAgentChatResults.set("trc_issue842_persisted_final", { status: "completed", traceId: "trc_issue842_persisted_final", @@ -1221,6 +1227,7 @@ test("cloud api repairs conversation snapshot when persisted result advances las assert.equal(listed.lastTraceId, "trc_issue842_persisted_final"); assert.equal(listed.messages.at(-1).traceId, "trc_issue842_persisted_final"); assert.equal(listed.messages.at(-1).text, finalText); + assert.match(listed.messages.at(-1).text, /\n\|---\|---\|\n/u); assert.equal(listed.messages.at(-1).status, "idle"); const direct = await getJson(port, "/v1/agent/conversations/cnv_issue842_snapshot", aliceLogin.cookie); @@ -1239,6 +1246,7 @@ test("cloud api repairs conversation snapshot when persisted result advances las assert.equal(selected.status, 200); assert.equal(selected.body.workspace.selectedConversation.messages.at(-1).traceId, "trc_issue842_persisted_final"); assert.equal(selected.body.workspace.selectedConversation.messages.at(-1).text, finalText); + assert.match(selected.body.workspace.selectedConversation.messages.at(-1).text, /\n\|---\|---\|\n/u); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index b297faec..388c3558 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2447,9 +2447,15 @@ function conversationNeedsTerminalRepair(conversation, traceId) { const latestAgent = latestAgentConversationMessage(messages); if (!traceMessage) return Boolean(latestAgent && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)); if (latestAgent?.traceId && latestAgent.traceId !== traceId && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)) return true; + if (markdownTextLooksFlattened(traceMessage.text ?? traceMessage.content ?? traceMessage.message) && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)) return true; const messageStatus = textOr(traceMessage.status, "").toLowerCase(); return ["", "running", "busy", "pending", "active"].includes(messageStatus); } +function markdownTextLooksFlattened(value) { + const text = conversationTextRaw(value).replace(/\r\n?/gu, "\n").trim(); + if (!text || text.includes("\n")) return false; + return /\|\s*:?-{3,}:?\s*\|/u.test(text) || /```[\s\S]+```/u.test(text); +} function terminalConversationRepairTraceId(conversation) { if (!conversation) return ""; const lastTraceId = safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId); @@ -2734,7 +2740,7 @@ function firstConversationText(values = [], maxBytes = 12000) { return ""; } function conversationText(value, maxBytes = 12000) { - const extracted = conversationTextRaw(value).replace(/\s+/gu, " ").trim(); + const extracted = conversationTextRaw(value).replace(/\r\n?/gu, "\n").trim(); if (!extracted || extracted === "[object Object]") return ""; return boundedText(extracted, maxBytes); } diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 4e0568ad..e8724035 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1629,7 +1629,7 @@ async function sessionSwitchCommand(context: any) { }) }); if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); - return responsePayload("client.session.switch", response, context, { route: route("POST", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), current: sessionSummary(response.body?.workspace?.selectedConversation), body: responseBodyForCli(response.body, context.parsed) }); + return responsePayload("client.session.switch", response, context, { route: route("POST", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), current: sessionSummary(response.body?.workspace?.selectedConversation), selectedConversation: finalResponseConversationSummary(response.body?.workspace?.selectedConversation), body: responseBodyForCli(response.body, context.parsed) }); } async function sessionDeleteCommand(context: any) { @@ -3209,7 +3209,8 @@ function finalResponseConversationSummary(conversation: any) { status: text(latest.status), traceId: text(latest.traceId), updatedAt: text(latest.updatedAt), - textPreview: preview(latest.text, 240) + textPreview: preview(latest.text, 240), + markdown: markdownTextSummary(latest.text) }) : undefined }); } @@ -3223,7 +3224,25 @@ function finalResponseResultSummary(body: any) { sessionId: text(body?.sessionId), threadId: text(body?.threadId ?? body?.session?.threadId ?? body?.providerTrace?.threadId), assistantTextPreview: preview(resultText, 240), - assistantTextChars: resultText.length || undefined + assistantTextChars: resultText.length || undefined, + markdown: markdownTextSummary(resultText) + }); +} + +function markdownTextSummary(value: unknown) { + const source = messageTextValue(value); + if (!source) return undefined; + const newlineCount = (source.match(/\n/gu) ?? []).length; + const hasTableSeparator = /\|\s*:?-{3,}:?\s*\|/u.test(source); + const hasFence = /```/u.test(source); + const likelyFlattened = newlineCount === 0 && (hasTableSeparator || /```[\s\S]+```/u.test(source)); + return clean({ + textChars: source.length, + newlineCount, + hasTablePipe: /\|/u.test(source) || undefined, + hasTableSeparator: hasTableSeparator || undefined, + hasFence: hasFence || undefined, + likelyFlattened: likelyFlattened || undefined }); } @@ -3259,6 +3278,9 @@ function finalResponseValidation({ conversationId, projectId, inspectConversatio const inspectText = text(inspectedLatest?.text); const listText = text(listedLatest?.text); const resultText = assistantText(resultBody) || ""; + const inspectMarkdown = markdownTextSummary(inspectText) ?? {}; + const listMarkdown = markdownTextSummary(listText) ?? {}; + const resultMarkdown = markdownTextSummary(resultText) ?? {}; const failures = [ !inspectConversation ? "inspect-conversation-missing" : "", !listedConversation ? "list-conversation-missing" : "", @@ -3269,7 +3291,9 @@ function finalResponseValidation({ conversationId, projectId, inspectConversatio inspectedLatest && text(inspectedLatest.traceId) !== traceId ? "inspect-latest-agent-trace-mismatch" : "", listedLatest && text(listedLatest.traceId) !== traceId ? "list-latest-agent-trace-mismatch" : "", inspectText && resultText && inspectText !== resultText ? "inspect-text-result-mismatch" : "", - listText && resultText && listText !== resultText ? "list-text-result-mismatch" : "" + listText && resultText && listText !== resultText ? "list-text-result-mismatch" : "", + inspectMarkdown.likelyFlattened === true && resultMarkdown.newlineCount > 0 ? "inspect-markdown-flattened" : "", + listMarkdown.likelyFlattened === true && resultMarkdown.newlineCount > 0 ? "list-markdown-flattened" : "" ].filter(Boolean); return { ok: failures.length === 0, @@ -3289,7 +3313,10 @@ function finalResponseValidation({ conversationId, projectId, inspectConversatio inspectLatestAgentTraceMatches: inspectedLatest ? text(inspectedLatest.traceId) === traceId : false, listLatestAgentTraceMatches: listedLatest ? text(listedLatest.traceId) === traceId : false, inspectTextMatchesResult: Boolean(inspectText && resultText && inspectText === resultText), - listTextMatchesResult: Boolean(listText && resultText && listText === resultText) + listTextMatchesResult: Boolean(listText && resultText && listText === resultText), + inspectMarkdownNotFlattened: inspectMarkdown.likelyFlattened !== true, + listMarkdownNotFlattened: listMarkdown.likelyFlattened !== true, + resultMarkdownHasStructure: resultMarkdown.newlineCount > 0 || resultMarkdown.hasTableSeparator === true || resultMarkdown.hasFence === true }, repair: clean({ inspectSnapshotSource: text(inspectConversation?.snapshot?.source), @@ -3301,6 +3328,11 @@ function finalResponseValidation({ conversationId, projectId, inspectConversatio listText: preview(listText, 200), resultText: preview(resultText, 200) }, + markdown: { + inspect: inspectMarkdown, + list: listMarkdown, + result: resultMarkdown + }, failures, nextCommands: [ `hwlab-cli client session inspect ${conversationId} --full`, diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 902f9222..664c814d 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -285,7 +285,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function completeTrace(traceId: string, result: AgentChatResultResponse): void { - const text = firstNonEmptyString(result.assistantText, typeof result.reply === "string" ? result.reply : result.reply?.content, result.text, result.summary) ?? "Code Agent 已完成,但没有返回可展示的 final response。"; + const text = firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), typeof result.reply === "string" ? result.reply : result.reply?.content, result.text, result.summary) ?? "Code Agent 已完成,但没有返回可展示的 final response。"; messages.value = messages.value.map((message) => { if (message.traceId !== traceId || message.role !== "agent") return message; const runnerTrace = result.runnerTrace ? mergeRunnerTrace(message.runnerTrace, result.runnerTrace) : message.runnerTrace ?? null; @@ -352,14 +352,36 @@ function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: Co } function messagesFromConversation(conversation: ConversationRecord): ChatMessage[] { - return (conversation.messages ?? []).map((message) => ({ ...message, id: message.id ?? nextProtocolId("msg"), title: message.title ?? (message.role === "user" ? "用户" : "Code Agent"), createdAt: message.createdAt ?? new Date().toISOString(), status: message.status ?? "source" })); + return (conversation.messages ?? []).map(normalizeChatMessage); } function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] { const selected = workspace?.selectedConversation?.messages; const embedded = workspace?.workspace?.messages; const source = Array.isArray(selected) ? selected : Array.isArray(embedded) ? embedded : []; - return source.map((message) => ({ ...message, id: message.id ?? nextProtocolId("msg"), title: message.title ?? (message.role === "user" ? "用户" : "Code Agent"), createdAt: message.createdAt ?? new Date().toISOString(), status: message.status ?? "source" })); + return source.map(normalizeChatMessage); +} + +function normalizeChatMessage(message: ChatMessage): ChatMessage { + const text = firstNonEmptyString(message.text, messageText((message as Record).content), messageText((message as Record).message), finalResponseText((message as Record).finalResponse)) ?? ""; + return { ...message, text, id: message.id ?? nextProtocolId("msg"), title: message.title ?? (message.role === "user" ? "用户" : "Code Agent"), createdAt: message.createdAt ?? new Date().toISOString(), status: message.status ?? "source" }; +} + +function finalResponseText(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message)); +} + +function messageText(value: unknown): string | null { + if (typeof value === "string") return value.trim() || null; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map(messageText).filter((item): item is string => Boolean(item)).join("\n") || null; + if (value && typeof value === "object") { + const record = value as Record; + return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message), messageText(record.summary), messageText(record.preview), messageText(record.title)); + } + return null; } export async function liveCall(label: string, call: () => Promise>): Promise> {