fix: remove workbench legacy read repair paths

This commit is contained in:
lyon
2026-06-17 15:20:56 +08:00
parent b769e25224
commit da6be55512
6 changed files with 41 additions and 452 deletions
+18 -17
View File
@@ -740,12 +740,13 @@ test("workbench workspace read preserves a newer selected conversation without t
assert.equal(repaired.body.workspace.selectedConversation.messages[0].text, "old turn running");
assert.equal(agentRunCalls.length, 0);
const repairedOld = await getJson(port, "/v1/agent/conversations/cnv_issue808_old", aliceLogin.cookie);
assert.equal(repairedOld.status, 200);
assert.equal(repairedOld.body.conversation.status, "idle");
assert.equal(repairedOld.body.conversation.lastTraceId, "trc_issue808_old_done");
assert.equal(repairedOld.body.conversation.messages[0].status, "idle");
assert.equal(repairedOld.body.conversation.messages[0].text, "old turn completed");
const directOld = await getJson(port, "/v1/agent/conversations/cnv_issue808_old", aliceLogin.cookie);
assert.equal(directOld.status, 200);
assert.equal(directOld.body.conversation.status, "running");
assert.equal(directOld.body.conversation.lastTraceId, "trc_issue808_old_done");
assert.equal(directOld.body.conversation.messages[0].status, "running");
assert.equal(directOld.body.conversation.messages[0].text, "old turn running");
assert.equal(agentRunCalls.length, 0);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve())));
@@ -1219,7 +1220,7 @@ test("cloud api exposes terminal conversation status when stored session status
}
});
test("cloud api repairs conversation snapshot when persisted result advances last trace", async () => {
test("cloud api preserves conversation snapshot when persisted result advances last trace", async () => {
const codeAgentChatResults = new Map();
const server = createCloudApiServer({
env: {
@@ -1285,15 +1286,15 @@ test("cloud api repairs conversation snapshot when persisted result advances las
const listed = list.body.conversations.find((conversation) => conversation.conversationId === "cnv_issue842_snapshot");
assert.ok(listed, "expected conversation to be listed");
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).traceId, "trc_issue842_previous");
assert.equal(listed.messages.at(-1).text, "上一轮回复");
assert.doesNotMatch(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);
assert.equal(direct.status, 200);
assert.equal(direct.body.conversation.messages.at(-1).traceId, "trc_issue842_persisted_final");
assert.equal(direct.body.conversation.messages.at(-1).text, finalText);
assert.equal(direct.body.conversation.messages.at(-1).traceId, "trc_issue842_previous");
assert.equal(direct.body.conversation.messages.at(-1).text, "上一轮回复");
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie);
assert.equal(workspace.status, 200);
@@ -1304,9 +1305,9 @@ test("cloud api repairs conversation snapshot when persisted result advances las
updatedByClient: "test-suite"
}, aliceLogin.cookie);
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);
assert.equal(selected.body.workspace.selectedConversation.messages.at(-1).traceId, "trc_issue842_previous");
assert.equal(selected.body.workspace.selectedConversation.messages.at(-1).text, "上一轮回复");
assert.doesNotMatch(selected.body.workspace.selectedConversation.messages.at(-1).text, /\n\|---\|---\|\n/u);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
@@ -1474,7 +1475,7 @@ test("cloud api workspace read preserves preview-only conversation without termi
threadId,
traceId,
session: {
source: "workbench-status-sync",
source: "code-agent-result",
sessionStatus: "failed",
lastTraceId: traceId,
messages: [
@@ -1561,7 +1562,7 @@ test("access controller preserves user message when owner evidence updates with
threadId,
traceId,
session: {
source: "workbench-status-sync",
source: "code-agent-result",
sessionStatus: "running",
lastTraceId: traceId,
messages: [
+6 -414
View File
@@ -5,12 +5,6 @@
import { createHash, createPublicKey, randomBytes, randomUUID, verify as verifySignature } from "node:crypto";
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
import {
codeAgentAgentRunAdapterEnabled,
loadPersistedAgentRunResult,
syncAgentRunChatResult
} from "./code-agent-agentrun-adapter.ts";
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import {
HWLAB_TOOL_IDS,
createOpenFgaAuthorizer,
@@ -1051,12 +1045,7 @@ class AccessController {
includeArchived: false,
now: this.now()
}) ?? [];
let conversations = await this.repairVisibleConversationsIfNeeded(
auth.actor,
conversationsFromAgentSessions(sessions),
projectId,
{ deadlineMs: 1500 }
);
let conversations = conversationsFromAgentSessions(sessions);
conversations = includeConversation(conversations, includeConversationId ? await this.visibleConversationForActor(auth.actor, includeConversationId, projectId) : null);
return sendJson(response, 200, {
ok: true,
@@ -1088,9 +1077,8 @@ class AccessController {
includeArchived: true,
now: this.now()
}) ?? [];
let conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
if (!conversation) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404));
conversation = await this.repairVisibleConversationIfNeeded(auth.actor, conversation, projectId) ?? conversation;
return sendJson(response, 200, { ok: true, status: "found", contractVersion: "agent-conversations-v1", actor: publicActor(auth.actor), conversation });
}
@@ -1209,7 +1197,7 @@ class AccessController {
}
const selectedConversationId = textOr(body.selectedConversationId ?? body.conversationId, current.selectedConversationId ?? "");
if (selectedConversationId) {
const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId, { skipTerminalRepair: true });
const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId);
if (!visible) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403));
}
const workspace = await this.store.updateWorkspace?.({
@@ -1241,7 +1229,7 @@ class AccessController {
const body = await jsonBody(request);
const conversationId = textOr(body.conversationId, "") || `cnv_${randomUUID()}`;
if (!safeConversationIdLocal(conversationId)) return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400));
const existing = await this.visibleConversationForActor(auth.actor, conversationId, body.projectId ?? current.projectId, { skipTerminalRepair: true });
const existing = await this.visibleConversationForActor(auth.actor, conversationId, body.projectId ?? current.projectId);
if (!existing && body.create !== true) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404));
const sessionId = safeAgentSessionId(body.sessionId ?? existing?.sessionId) || `ses_${conversationId.slice(4)}`;
if (!existing) {
@@ -1329,251 +1317,13 @@ class AccessController {
};
}
async syncTerminalWorkbenchWorkspace(workspace, actor) {
const activeTraceId = safeTraceIdLocal(workspace?.activeTraceId);
const codeAgentEnv = this.codeAgentEnv ?? this.env;
if (!workspace || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return workspace;
const workspaceJson = normalizeObject(workspace.workspace);
const repairTraceId = activeTraceId ? "" : await this.terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson);
const traceId = activeTraceId || repairTraceId;
if (!traceId) return workspace;
try {
const result = await this.terminalCodeAgentResultForActor(traceId, actor);
if (!result) return workspace;
const now = this.now();
const staleContinuation = isThreadResumeFailedResult(result);
const resultConversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : workspace.selectedConversationId;
const resultAgentSessionId = safeAgentSessionId(result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId) || workspace.selectedAgentSessionId;
const currentSelectedConversationId = safeConversationIdLocal(workspace.selectedConversationId) ? workspace.selectedConversationId : null;
const currentSelectedAgentSessionId = safeAgentSessionId(workspace.selectedAgentSessionId) || null;
const resultMatchesCurrentSelection = Boolean(
resultConversationId &&
(!currentSelectedConversationId || currentSelectedConversationId === resultConversationId) &&
(!currentSelectedAgentSessionId || currentSelectedAgentSessionId === resultAgentSessionId)
);
const preserveCurrentSelection = Boolean(currentSelectedConversationId && !resultMatchesCurrentSelection);
const selectedConversationId = staleContinuation
? (preserveCurrentSelection ? currentSelectedConversationId : null)
: (preserveCurrentSelection ? currentSelectedConversationId : resultConversationId);
const selectedAgentSessionId = staleContinuation
? (preserveCurrentSelection ? currentSelectedAgentSessionId : null)
: (preserveCurrentSelection ? currentSelectedAgentSessionId : resultAgentSessionId);
const nextSessionStatus = preserveCurrentSelection ? workspaceJson.sessionStatus : terminalWorkbenchSessionStatus(result);
await this.syncTerminalWorkbenchConversation({
workspace,
actor,
result,
activeTraceId: traceId,
selectedConversationId: resultConversationId,
selectedAgentSessionId: resultAgentSessionId,
now
});
const staleThreadId = threadResumeFailureThreadId(result);
const updated = await this.store.updateWorkspace?.({
workspaceId: workspace.id,
ownerUserId: workspace.ownerUserId,
actorRole: actor?.role ?? "user",
selectedConversationId,
selectedAgentSessionId,
activeTraceId: null,
providerProfile: workspace.providerProfile,
patch: {
...workspaceJson,
selectedConversationId,
selectedAgentSessionId,
activeTraceId: null,
sessionStatus: nextSessionStatus,
lastTraceId: traceId,
syncedTraceStatus: result.status ?? result.agentRun?.terminalStatus ?? null,
...(staleContinuation ? {
staleContinuationCleared: true,
staleContinuationReason: "thread-resume-failed",
staleConversationId: resultConversationId,
staleAgentSessionId: resultAgentSessionId,
staleThreadId,
recoveryAction: "new-session-on-next-turn"
} : {}),
updatedAt: now,
source: "workbench-status-sync",
valuesRedacted: true,
secretMaterialStored: false
},
updatedBySessionId: workspace.updatedBySessionId,
updatedByClient: "workbench-status-sync",
now
});
return updated ?? workspace;
} catch {
return workspace;
}
}
async terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson) {
try {
const traceId = safeTraceIdLocal(workspaceJson?.lastTraceId);
if (!traceId || !safeConversationIdLocal(workspace?.selectedConversationId)) return "";
const conversation = await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId, { skipTerminalRepair: true });
return conversationNeedsTerminalRepair(conversation, traceId) ? traceId : "";
} catch {
return "";
}
}
async publicWorkbenchWorkspace(workspace, actor) {
const conversation = workspace?.selectedConversationId
? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId, { skipTerminalRepair: true })
? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId)
: null;
return publicWorkbenchWorkspace(workspace, { conversation });
}
async syncTerminalWorkbenchConversation({ workspace, actor, result, activeTraceId, selectedConversationId, selectedAgentSessionId, now }) {
if (!safeConversationIdLocal(selectedConversationId) || !safeAgentSessionId(selectedAgentSessionId)) return null;
const existing = await this.visibleConversationForActor(actor, selectedConversationId, workspace.projectId, { skipTerminalRepair: true });
const sessionStatus = terminalWorkbenchSessionStatus(result);
const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, existing?.threadId ?? null);
const workspaceJson = normalizeObject(workspace?.workspace);
const workspaceMessages = workspace?.selectedConversationId === selectedConversationId && workspace?.selectedAgentSessionId === selectedAgentSessionId && Array.isArray(workspaceJson.messages)
? workspaceJson.messages
: [];
const existingMessages = Array.isArray(existing?.messages) && existing.messages.length > 0 ? existing.messages : workspaceMessages;
const messages = mergeTerminalConversationMessages(existingMessages, result, {
traceId: activeTraceId,
conversationId: selectedConversationId,
sessionId: selectedAgentSessionId,
threadId,
now,
existingMessageCount: existing?.messageCount ?? existing?.snapshot?.messageCount ?? (workspaceMessages.length || null),
firstUserMessagePreview: existing?.firstUserMessagePreview ?? existing?.snapshot?.firstUserMessagePreview ?? firstUserPreviewFromMessages(workspaceMessages) ?? null
});
const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? [];
const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null;
const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null;
return await this.recordAgentSessionOwner({
ownerUserId: actor.id,
sessionId: selectedAgentSessionId,
projectId: workspace.projectId,
agentId: existing?.agentId ?? "hwlab-code-agent",
status: sessionStatus,
conversationId: selectedConversationId,
threadId,
traceId: activeTraceId,
session: normalizeConversationSnapshot({
...(existing?.snapshot ?? {}),
source: "workbench-status-sync",
sessionStatus,
updatedAt: now,
lastTraceId: activeTraceId,
messages: mergedMessages,
messageCount: mergedCount,
firstUserMessagePreview: mergedPreview
}, actor),
now
});
}
async terminalCodeAgentResultForActor(traceId, actor) {
const safeTraceId = safeTraceIdLocal(traceId);
const codeAgentEnv = this.codeAgentEnv ?? this.env;
if (!safeTraceId || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return null;
const options = {
env: codeAgentEnv,
fetchImpl: this.fetchImpl,
accessController: this,
codeAgentChatResults: this.codeAgentChatResults,
traceStore: this.traceStore ?? defaultCodeAgentTraceStore
};
try {
const cached = this.codeAgentChatResults?.get?.(safeTraceId) ?? null;
const persisted = cached ? null : await loadPersistedAgentRunResult(safeTraceId, options);
const current = cached ?? persisted ?? null;
if (!current || !canActorReadWorkspaceTrace(current, actor)) return null;
const synced = current?.agentRun?.runId
? await syncAgentRunChatResult({ traceId: safeTraceId, currentResult: current, options, traceStore: options.traceStore })
: { result: current };
const result = synced.result ?? current;
return isTerminalCodeAgentResult(result) ? result : null;
} catch {
return null;
}
}
async repairVisibleConversationsIfNeeded(actor, conversations = [], projectId = "", options = {}) {
if (!Array.isArray(conversations) || conversations.length === 0) return [];
const deadlineMs = positiveInteger(options.deadlineMs, 0);
const deadline = deadlineMs > 0 ? Date.now() + deadlineMs : 0;
const repaired = [];
for (const conversation of conversations) {
if (!terminalConversationRepairTraceId(conversation)) {
repaired.push(conversation);
continue;
}
const remainingMs = deadline > 0 ? deadline - Date.now() : 0;
if (deadline > 0 && remainingMs <= 0) {
repaired.push(conversation);
continue;
}
const repair = this.repairVisibleConversationIfNeeded(actor, conversation, projectId);
const next = deadline > 0 ? await resolveBeforeDeadline(repair, remainingMs) : await repair;
repaired.push(next ?? conversation);
}
return repaired;
}
async repairVisibleConversationIfNeeded(actor, conversation, projectId = "") {
if (textOr(conversation?.status, "").toLowerCase() === "archived") return conversation;
const traceId = terminalConversationRepairTraceId(conversation);
if (!traceId) return conversation;
const result = await this.terminalCodeAgentResultForActor(traceId, actor);
if (!result) return conversation;
const sessionId = safeAgentSessionId(
result.sessionId
?? result.session?.sessionId
?? result.sessionReuse?.sessionId
?? conversation.sessionId
?? conversation.session?.sessionId
);
const conversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : conversation.conversationId;
if (!safeConversationIdLocal(conversationId) || !sessionId) return conversation;
const now = this.now();
const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, conversation.threadId ?? null);
const messages = mergeTerminalConversationMessages(conversation.messages, result, {
traceId,
conversationId,
sessionId,
threadId,
now,
existingMessageCount: conversation.messageCount ?? conversation.snapshot?.messageCount ?? null,
firstUserMessagePreview: conversation.firstUserMessagePreview ?? conversation.snapshot?.firstUserMessagePreview ?? null
});
const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? [];
const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null;
const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null;
const sessionStatus = terminalWorkbenchSessionStatus(result);
const repaired = await this.recordAgentSessionOwner({
ownerUserId: actor.id,
sessionId,
projectId: textOr(conversation.projectId ?? projectId, "prj_hwpod_workbench"),
agentId: conversation.agentId ?? "hwlab-code-agent",
status: sessionStatus,
conversationId,
threadId,
traceId,
session: normalizeConversationSnapshot({
...(conversation.snapshot ?? {}),
source: "conversation-terminal-repair",
sessionStatus,
updatedAt: now,
lastTraceId: traceId,
messages: mergedMessages,
messageCount: mergedCount,
firstUserMessagePreview: mergedPreview
}, actor),
now
});
return conversationsFromAgentSessions(repaired ? [repaired] : []).find((item) => item.conversationId === conversationId) ?? conversation;
}
async visibleConversationForActor(actor, conversationId, projectId = "", options = {}) {
if (!safeConversationIdLocal(conversationId)) return null;
const sessions = await this.store.listAgentSessionsForUser?.({
@@ -1586,9 +1336,7 @@ class AccessController {
includeArchived: options.includeArchived === true,
now: this.now()
}) ?? [];
const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
if (!conversation || options.skipTerminalRepair === true) return conversation;
return await this.repairVisibleConversationIfNeeded(actor, conversation, projectId) ?? conversation;
return conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
}
async nextVisibleConversationForActor(actor, projectId = "", excludedConversationId = "") {
@@ -2451,130 +2199,6 @@ function numberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function isTerminalCodeAgentResult(result = {}) {
const status = textOr(result.status, "").toLowerCase();
const terminalStatus = textOr(result.agentRun?.terminalStatus, "").toLowerCase();
return [status, terminalStatus].some((value) => ["completed", "failed", "blocked", "canceled", "cancelled", "timeout", "error"].includes(value));
}
function isThreadResumeFailedResult(result = {}) {
const values = [
result.error?.code,
result.error?.category,
result.blocker?.code,
result.blocker?.category,
result.providerTrace?.failureKind,
result.agentRun?.providerTrace?.failureKind,
result.agentRun?.failureKind
].map((value) => textOr(value, "").toLowerCase().replace(/_/gu, "-"));
return values.some((value) => value === "thread-resume-failed" || value === "session-store-evicted")
|| /session stor(?:e|age).*evicted|pvc-backed session|no rollout found for thread id|thread\/resume failed/iu.test(String(result.error?.message ?? result.blocker?.message ?? result.providerTrace?.failureMessage ?? result.agentRun?.providerTrace?.failureMessage ?? ""));
}
function threadResumeFailureThreadId(result = {}) {
return boundedText(textOr(
result.providerTrace?.threadId
?? result.agentRun?.providerTrace?.threadId
?? result.session?.threadId
?? result.sessionReuse?.threadId
?? result.threadId,
""
), 240) || null;
}
function terminalWorkbenchSessionStatus(result = {}) {
if (isThreadResumeFailedResult(result)) return "thread-resume-failed";
const sessionStatus = textOr(result.session?.status ?? result.sessionSummary?.status ?? result.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus, "").toLowerCase();
if (sessionStatus && sessionStatus !== "running" && sessionStatus !== "busy" && sessionStatus !== "pending") return sessionStatus === "cancelled" ? "canceled" : sessionStatus;
const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase();
if (status === "completed") return "idle";
if (status === "cancelled") return "canceled";
return status || "idle";
}
function canActorReadWorkspaceTrace(result = {}, actor = null) {
const ownerUserId = textOr(result.ownerUserId, "");
if (!ownerUserId || actor?.role === "admin") return true;
return ownerUserId === actor?.id;
}
function conversationNeedsTerminalRepair(conversation, traceId) {
if (!conversation || !safeTraceIdLocal(traceId)) return false;
const status = textOr(conversation.status, "").toLowerCase();
if (["running", "busy", "pending", "active"].includes(status)) return true;
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
const traceMessage = messages.find((message) => message?.traceId === traceId && message?.role === "agent") ?? null;
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);
if (lastTraceId && conversationNeedsTerminalRepair(conversation, lastTraceId)) return lastTraceId;
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const traceId = safeTraceIdLocal(message?.traceId);
if (message?.role === "agent" && traceId && conversationNeedsTerminalRepair(conversation, traceId)) return traceId;
}
return "";
}
function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) {
const baseMessages = dedupeConversationMessages(existingMessages);
const traceId = safeTraceIdLocal(result.traceId ?? context.traceId);
const terminalStatus = terminalWorkbenchSessionStatus(result);
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) ?? (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) => {
if (traceId && message.traceId === traceId && message.role === "agent") {
return redactConversationMessage({
...message,
text: assistantText || message.text,
status: terminalStatus,
conversationId: context.conversationId ?? message.conversationId,
sessionId: context.sessionId ?? message.sessionId,
threadId: context.threadId ?? message.threadId,
runnerTrace: runnerTrace ?? message.runnerTrace,
updatedAt: now
});
}
return message;
}).filter(Boolean);
let addedAgentMessage = false;
if (traceId && !next.some((message) => message.traceId === traceId && message.role === "agent")) {
const messageId = result.reply?.messageId ?? result.messageId ?? `msg_${traceId.slice(4)}_agent`;
next.push(redactConversationMessage({
id: messageId,
messageId,
role: "agent",
title: "Code Agent result",
text: assistantText,
status: terminalStatus,
traceId,
turnId: traceId,
conversationId: context.conversationId,
sessionId: context.sessionId,
threadId: context.threadId,
runnerTrace,
createdAt: result.reply?.createdAt ?? result.createdAt ?? now,
updatedAt: now
}));
addedAgentMessage = true;
}
const finalMessages = dedupeConversationMessages(next).slice(-50);
return {
messages: finalMessages,
messageCount: finalMessages.length,
firstUserMessagePreview: firstUserPreviewFromMessages(finalMessages) ?? firstUserPreview
};
}
function ensureTerminalUserMessage(messages = [], context = {}) {
if (messages.some((message) => message?.role === "user")) return messages;
const userText = conversationText(context.firstUserPreview, 12000);
@@ -2598,25 +2222,6 @@ function ensureTerminalUserMessage(messages = [], context = {}) {
});
return userMessage ? [userMessage, ...messages] : messages;
}
function terminalRunnerTraceForConversationMessage(result = {}, context = {}) {
const source = result.runnerTrace && typeof result.runnerTrace === "object" ? result.runnerTrace : null;
if (!source) return null;
const events = Array.isArray(source.events) ? source.events : [];
const lastEvent = source.lastEvent && typeof source.lastEvent === "object" ? source.lastEvent : events.at(-1);
return pruneEmpty({
traceId: safeTraceIdLocal(source.traceId ?? result.traceId ?? context.traceId) || undefined,
status: textOr(source.status ?? result.status ?? result.agentRun?.terminalStatus ?? context.terminalStatus, ""),
sessionId: safeAgentSessionId(source.sessionId ?? result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId ?? context.sessionId) || undefined,
threadId: textOr(source.threadId ?? result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? context.threadId, ""),
eventCount: numberOrNull(source.eventCount ?? result.traceSummary?.sourceEventCount ?? result.traceSummary?.eventCount) ?? events.length,
eventsCompacted: source.eventsCompacted === true || events.length === 0,
fullTraceLoaded: false,
lastEvent,
traceStatus: textOr(result.traceStatus, ""),
finalResponse: redactTraceFinalResponse(result.finalResponse),
traceSummary: redactTraceSummary(result.traceSummary)
});
}
function redactConversationMessage(message) {
if (!message || typeof message !== "object") return null;
const runnerTrace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null;
@@ -2978,19 +2583,6 @@ function nonNegativeResultInteger(value) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
}
function positiveInteger(value, fallback = 0) { const number = Number(value); return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback; }
async function resolveBeforeDeadline(promise, timeoutMs) {
/** @type {ReturnType<typeof setTimeout> | null} */
let timer = null;
const boundedTimeoutMs = Math.max(1, positiveInteger(timeoutMs, 1));
const guarded = Promise.resolve(promise).catch(() => null);
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), boundedTimeoutMs); });
try {
return await Promise.race([guarded, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
function accessWriteErrorPayload(result = {}) {
const error = result.error ?? {};
const code = textOr(error.code, "openfga_write_failed");
+1 -2
View File
@@ -417,7 +417,7 @@ test("hwlab-cli client session final-response verifies original conversation acr
threadId: "thread_issue834",
status: "idle",
lastTraceId: "trc_issue834_final",
snapshot: { source: "conversation-terminal-repair" },
snapshot: { source: "code-agent-result" },
messages: [{ role: "agent", traceId: "trc_issue834_final", status: "idle", text: finalText }]
};
const fetchImpl = async (url: string | URL) => {
@@ -443,7 +443,6 @@ test("hwlab-cli client session final-response verifies original conversation acr
assert.equal(result.payload.validation.checks.listFound, true);
assert.equal(result.payload.validation.checks.inspectTextMatchesResult, true);
assert.equal(result.payload.validation.checks.listTextMatchesResult, true);
assert.equal(result.payload.validation.repair.appearsRepaired, true);
assert.equal(calls[1], "http://web.test/v1/agent/conversations?projectId=prj_v02_code_agent&limit=100");
assert.deepEqual(result.payload.validation.failures, []);
});
+3 -5
View File
@@ -1,3 +1,6 @@
// SPEC: PJ2026-010402 HWLAB CLI draft-2026-06-17-r0; PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0.
// Responsibility: HWLAB CLI Web-equivalent client commands, including session, trace, result and workbench diagnostics.
import { createHash } from "node:crypto";
import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -3322,11 +3325,6 @@ function finalResponseValidation({ conversationId, projectId, inspectConversatio
listMarkdownNotFlattened: listMarkdown.likelyFlattened !== true,
resultMarkdownHasStructure: resultMarkdown.newlineCount > 0 || resultMarkdown.hasTableSeparator === true || resultMarkdown.hasFence === true
},
repair: clean({
inspectSnapshotSource: text(inspectConversation?.snapshot?.source),
listSnapshotSource: text(listedConversation?.snapshot?.source),
appearsRepaired: text(inspectConversation?.snapshot?.source).includes("repair") || text(listedConversation?.snapshot?.source).includes("repair")
}),
previews: {
inspectText: preview(inspectText, 200),
listText: preview(listText, 200),
+3 -1
View File
@@ -1,3 +1,6 @@
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0.
// Responsibility: Code Agent HTTP client for send, steer, turn, trace, session and cancel APIs.
import { fetchJson, type ApiRequestOptions } from "./client";
import type { AgentChatResponse, AgentChatResultResponse, ApiResult } from "@/types";
@@ -16,7 +19,6 @@ function agentTracePath(traceId: string, projectId: string, options: AgentTraceR
export const agentAPI = {
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }),
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }),
getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }),
getAgentTurn: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(`/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(projectId)}`, { timeoutMs, timeoutName: "Code Agent turn", activityRef }),
getAgentTrace: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: AgentTraceRequestOptions): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(agentTracePath(traceId, projectId, options), { timeoutMs, timeoutName: "Code Agent trace", activityRef }),
getAgentSession: (sessionId: string): Promise<ApiResult<{ session?: Record<string, unknown> }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: 8000, timeoutName: "Code Agent session" }),
+10 -13
View File
@@ -160,9 +160,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (response.ok && isCurrentWorkspaceSelection(requestEpoch)) {
workspace.value = response.data?.workspace ?? current;
rememberWorkspaceSnapshot(activeProjectId.value, workspace.value);
setActiveConversationSelection(selectedConversationIdFromWorkspace(workspace.value));
rememberConversationDetail(workspace.value?.selectedConversation);
messages.value = restoreMessagesTraceAuthority(messagesFromWorkspace(workspace.value), messages.value);
const selectedConversationId = selectedConversationIdFromWorkspace(workspace.value);
const selectedConversation = workspace.value?.selectedConversation ?? null;
setActiveConversationSelection(selectedConversationId);
rememberConversationDetail(selectedConversation);
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, null, []), messages.value);
currentRequest.value = null;
void hydrateTurnStatusAuthority(messages.value);
void hydrateTraceEvents(messages.value);
@@ -253,9 +255,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (response.ok) {
workspace.value = response.data?.workspace ?? workspace.value;
rememberWorkspaceSnapshot(activeProjectId.value, workspace.value);
replaceActiveConversationSelection(selectedConversationIdFromWorkspace(workspace.value));
rememberConversationDetail(workspace.value?.selectedConversation);
messages.value = restoreMessagesTraceAuthority(messagesFromWorkspace(workspace.value), messages.value);
const selectedConversationId = selectedConversationIdFromWorkspace(workspace.value);
const selectedConversation = workspace.value?.selectedConversation ?? null;
replaceActiveConversationSelection(selectedConversationId);
rememberConversationDetail(selectedConversation);
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, null, []), messages.value);
currentRequest.value = null;
void hydrateTurnStatusAuthority(messages.value);
await refreshConversations(selectedConversationIdFromWorkspace(workspace.value));
@@ -1102,13 +1106,6 @@ function messagesFromSelectedConversation(conversation: ConversationRecord | nul
return [];
}
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) => normalizeChatMessage(message));
}
function normalizeChatMessage(message: ChatMessage): ChatMessage {
const runnerTrace = normalizeMessageRunnerTrace(message);
const error = normalizeAgentError(message.error ?? runnerTrace?.error);