fix(v02): preserve terminal workspace trace state (#874)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-05 00:00:04 +08:00
committed by GitHub
parent 0fdd659826
commit b6615c2abf
2 changed files with 160 additions and 4 deletions
+124
View File
@@ -1528,6 +1528,130 @@ test("cloud api repairs conversation snapshot when persisted result advances las
}
});
test("cloud api terminal workspace sync preserves saved messages and stores runner trace", async () => {
const codeAgentChatResults = new Map();
const traceId = "trc_issue853_fast_fail_trace";
const conversationId = "cnv_issue853_fast_fail";
const sessionId = "ses_issue853_fast_fail";
const threadId = "thread-issue-853-fast-fail";
const env = {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
AGENTRUN_MGR_URL: "http://127.0.0.1:9",
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1"
};
const now = () => "2026-06-04T10:25:00.000Z";
const accessController = createAccessController({ env, now });
const server = createCloudApiServer({
env,
accessController,
codeAgentChatResults,
now
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue853-fast-fail", password: "alice-pass" }, adminLogin.cookie);
assert.equal(aliceCreate.status, 201);
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue853-fast-fail", password: "alice-pass" });
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
assert.equal(workspace.status, 200);
const savedMessages = [
{ id: "msg_issue853_user", role: "user", title: "用户", text: "请执行 hwpod profile list", status: "sent", conversationId, sessionId, threadId, createdAt: "2026-06-04T10:24:59.000Z" },
{ id: "msg_issue853_pending", role: "agent", title: "Code Agent 处理中", text: "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。", status: "running", traceId, conversationId, sessionId, threadId, createdAt: "2026-06-04T10:24:59.000Z" }
];
const stored = await putJson(port, `/v1/agent/conversations/${conversationId}`, {
projectId: "prj_device_pod_workbench",
sessionId,
threadId,
sessionStatus: "active",
lastTraceId: traceId,
messages: savedMessages
}, aliceLogin.cookie);
assert.equal(stored.status, 200);
await accessController.recordAgentSessionOwner({
ownerUserId: aliceCreate.body.user.id,
sessionId,
projectId: "prj_device_pod_workbench",
agentId: "hwlab-code-agent",
status: "failed",
conversationId,
threadId,
traceId,
session: {
source: "code-agent-result",
status: "failed",
sessionStatus: "failed",
messages: [],
messageCount: 0,
finalResponse: null,
valuesRedacted: true,
secretMaterialStored: false
}
});
const preserved = await accessController.getAgentSession(sessionId);
assert.equal(preserved.session.messages.length, 2);
assert.equal(preserved.session.messages[0].text, "请执行 hwpod profile list");
codeAgentChatResults.set(traceId, {
status: "failed",
traceId,
conversationId,
sessionId,
threadId,
ownerUserId: aliceCreate.body.user.id,
error: { code: "provider_insufficient_balance", message: "HyueAPI 403 INSUFFICIENT_BALANCE" },
agentRun: { terminalStatus: "failed" },
runnerTrace: {
traceId,
status: "failed",
eventCount: 35,
events: [],
lastEvent: { label: "agentrun:result:failed", status: "failed", type: "result" }
},
session: { sessionId, conversationId, threadId, status: "failed" }
});
const selected = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
expectedRevision: 1,
selectedConversationId: conversationId,
selectedAgentSessionId: sessionId,
activeTraceId: traceId,
sessionStatus: "running",
messages: savedMessages,
updatedByClient: "test-suite"
}, aliceLogin.cookie);
assert.equal(selected.status, 200);
assert.equal(selected.body.workspace.activeTraceId, traceId);
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
assert.equal(restored.status, 200);
assert.equal(restored.body.workspace.activeTraceId, null);
const messages = restored.body.workspace.selectedConversation.messages;
assert.equal(messages.length, 2);
assert.equal(messages[0].role, "user");
assert.equal(messages[0].text, "请执行 hwpod profile list");
assert.equal(messages[1].role, "agent");
assert.equal(messages[1].status, "failed");
assert.equal(messages[1].text, "HyueAPI 403 INSUFFICIENT_BALANCE");
assert.equal(messages[1].runnerTrace.traceId, traceId);
assert.equal(messages[1].runnerTrace.eventCount, 35);
assert.equal(messages[1].runnerTrace.eventsCompacted, true);
assert.equal(messages[1].runnerTrace.fullTraceLoaded, false);
assert.equal(restored.body.workspace.selectedConversation.messageCount, 2);
assert.equal(restored.body.workspace.selectedConversation.firstUserMessagePreview, "请执行 hwpod profile list");
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("access controller restores AgentRun mapping by Code Agent traceId", async () => {
const accessController = createAccessController({ now: () => "2026-06-01T00:00:00.000Z" });
await accessController.recordAgentSessionOwner({
+36 -4
View File
@@ -1315,14 +1315,19 @@ class AccessController {
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 messages = mergeTerminalConversationMessages(existing?.messages, result, {
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 ?? null,
firstUserMessagePreview: existing?.firstUserMessagePreview ?? existing?.snapshot?.firstUserMessagePreview ?? null
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;
@@ -2343,13 +2348,21 @@ function mergeAgentSessionOwnerEvidence(nextValue, existingValue) {
const existing = normalizeObject(existingValue);
const next = normalizeObject(nextValue);
const merged = { ...existing, ...next };
const existingMessages = Array.isArray(existing.messages) ? existing.messages : null;
const nextMessages = Array.isArray(next.messages) ? next.messages : null;
if (existingMessages?.length && (!nextMessages || nextMessages.length === 0)) merged.messages = existingMessages;
const existingChatMessages = Array.isArray(existing.chatMessages) ? existing.chatMessages : null;
const nextChatMessages = Array.isArray(next.chatMessages) ? next.chatMessages : null;
if (existingChatMessages?.length && (!nextChatMessages || nextChatMessages.length === 0)) merged.chatMessages = existingChatMessages;
if (!next.agentRun && existing.agentRun) merged.agentRun = existing.agentRun;
const nextCount = numberOrNull(next.messageCount);
const existingCount = numberOrNull(existing.messageCount);
if (nextCount !== null || existingCount !== null) {
merged.messageCount = Math.max(nextCount ?? 0, existingCount ?? 0);
}
if (next.firstUserMessagePreview && !existing.firstUserMessagePreview) {
if (!next.firstUserMessagePreview && existing.firstUserMessagePreview) {
merged.firstUserMessagePreview = existing.firstUserMessagePreview;
} else if (next.firstUserMessagePreview && !existing.firstUserMessagePreview) {
merged.firstUserMessagePreview = next.firstUserMessagePreview;
}
return merged;
@@ -2587,6 +2600,7 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c
const now = textOr(context.now, new Date().toISOString());
const existingCount = numberOrNull(context.existingMessageCount) ?? messages.length;
const firstUserPreview = firstUserPreviewFromMessages(messages) ?? textOr(context.firstUserMessagePreview, null);
const runnerTrace = terminalRunnerTraceForConversationMessage(result, { ...context, traceId, terminalStatus });
const next = messages.map((message) => {
if (traceId && message.traceId === traceId && message.role === "agent") {
return redactConversationMessage({
@@ -2596,6 +2610,7 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c
conversationId: context.conversationId ?? message.conversationId,
sessionId: context.sessionId ?? message.sessionId,
threadId: context.threadId ?? message.threadId,
runnerTrace: runnerTrace ?? message.runnerTrace,
updatedAt: now
});
}
@@ -2613,6 +2628,7 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c
conversationId: context.conversationId,
sessionId: context.sessionId,
threadId: context.threadId,
runnerTrace,
createdAt: result.reply?.createdAt ?? result.createdAt ?? now,
updatedAt: now
}));
@@ -2624,6 +2640,22 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c
firstUserMessagePreview: firstUserPreviewFromMessages(next) ?? firstUserPreview
};
}
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
});
}
function redactConversationMessage(message) {
if (!message || typeof message !== "object") return null;
const runnerTrace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null;