diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 214353b9..26c2ae81 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -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({ diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 6d99c04c..64abb50c 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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;