fix: preserve restored session markdown

This commit is contained in:
lyon
2026-06-15 23:03:20 +08:00
parent b9b312f27b
commit 1a15863f9d
4 changed files with 78 additions and 10 deletions
+9 -1
View File
@@ -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()));
+7 -1
View File
@@ -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);
}
+37 -5
View File
@@ -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`,
+25 -3
View File
@@ -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<string, unknown>).content), messageText((message as Record<string, unknown>).message), finalResponseText((message as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<T>(label: string, call: () => Promise<ApiResult<T>>): Promise<ApiResult<T>> {