diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 43d63077..eef9686e 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1756,6 +1756,79 @@ test("cloud api terminal workspace sync rebuilds missing user message from saved } }); +test("access controller preserves user message when owner evidence updates with agent-only result", async () => { + const projectId = "prj_device_pod_workbench"; + const ownerUserId = "usr_issue853_owner_merge"; + const conversationId = "cnv_issue853_owner_merge"; + const sessionId = "ses_issue853_owner_merge"; + const traceId = "trc_issue853_owner_merge"; + const threadId = "thread-issue-853-owner-merge"; + const nowValue = "2026-06-04T10:45:00.000Z"; + const userText = "请执行 hwpod profile list,并在最终回复里原样包含 HWLAB-853-FINAL-owner-merge"; + const accessController = createAccessController({ now: () => nowValue }); + + await accessController.recordAgentSessionOwner({ + ownerUserId, + sessionId, + projectId, + agentId: "hwlab-code-agent", + status: "active", + conversationId, + threadId, + traceId, + session: { + source: "workbench-status-sync", + sessionStatus: "running", + lastTraceId: traceId, + messages: [ + { id: "msg_issue853_owner_user", role: "user", title: "用户", text: userText, status: "sent", traceId, conversationId, sessionId, threadId, createdAt: nowValue }, + { id: "msg_issue853_owner_agent_pending", role: "agent", title: "Code Agent 处理中", text: "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。", status: "running", traceId, conversationId, sessionId, threadId, createdAt: nowValue } + ], + messageCount: 2, + firstUserMessagePreview: userText, + valuesRedacted: true, + secretMaterialStored: false + } + }); + + await accessController.recordAgentSessionOwner({ + ownerUserId, + sessionId, + projectId, + agentId: "hwlab-code-agent", + status: "failed", + conversationId, + threadId, + traceId, + session: { + source: "code-agent-result", + status: "failed", + sessionStatus: "failed", + messages: [ + { id: "msg_issue853_owner_agent_result", role: "agent", title: "Code Agent result", text: "HyueAPI 403 INSUFFICIENT_BALANCE", status: "failed", traceId, conversationId, sessionId, threadId, createdAt: nowValue } + ], + messageCount: 1, + firstUserMessagePreview: userText, + valuesRedacted: true, + secretMaterialStored: false + } + }); + + const session = await accessController.getAgentSession(sessionId); + const conversation = await accessController.visibleConversationForActor({ id: ownerUserId, role: "user" }, conversationId, projectId, { includeArchived: true }); + assert.deepEqual(session.session.messages.map((message) => message.role), ["user", "agent"]); + assert.deepEqual(conversation.messages.map((message) => message.role), ["user", "agent"]); + assert.equal(session.session.messages[0].text, userText); + assert.equal(conversation.messages[0].text, userText); + assert.equal(session.session.messages[0].traceId, traceId); + assert.equal(conversation.messages[0].traceId, traceId); + assert.equal(session.session.messages[1].text, "HyueAPI 403 INSUFFICIENT_BALANCE"); + assert.equal(conversation.messages[1].status, "failed"); + assert.equal(session.session.messageCount, 2); + assert.equal(conversation.messageCount, 2); + assert.equal(conversation.firstUserMessagePreview, userText); +}); + 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 e588c1ce..657d80fe 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2350,7 +2350,18 @@ function mergeAgentSessionOwnerEvidence(nextValue, existingValue) { 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; + if (nextMessages?.length) { + merged.messages = mergeAgentSessionOwnerMessages(nextMessages, existingMessages, { + traceId: next.lastTraceId ?? next.traceId ?? existing.lastTraceId ?? existing.traceId ?? firstMessageTraceId(nextMessages) ?? firstMessageTraceId(existingMessages), + conversationId: next.conversationId ?? existing.conversationId ?? firstMessageField(nextMessages, "conversationId") ?? firstMessageField(existingMessages, "conversationId"), + sessionId: next.sessionId ?? existing.sessionId ?? firstMessageField(nextMessages, "sessionId") ?? firstMessageField(existingMessages, "sessionId"), + threadId: next.threadId ?? existing.threadId ?? firstMessageField(nextMessages, "threadId") ?? firstMessageField(existingMessages, "threadId"), + now: next.updatedAt ?? existing.updatedAt ?? firstMessageField(nextMessages, "updatedAt") ?? firstMessageField(nextMessages, "createdAt") ?? firstMessageField(existingMessages, "updatedAt") ?? firstMessageField(existingMessages, "createdAt"), + firstUserPreview: firstUserPreviewFromMessages(nextMessages) ?? next.firstUserMessagePreview ?? firstUserPreviewFromMessages(existingMessages) ?? existing.firstUserMessagePreview + }); + } else if (existingMessages?.length) { + 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; @@ -2367,6 +2378,37 @@ function mergeAgentSessionOwnerEvidence(nextValue, existingValue) { } return merged; } +function mergeAgentSessionOwnerMessages(nextMessages = [], existingMessages = [], context = {}) { + const next = Array.isArray(nextMessages) ? nextMessages.map(redactConversationMessage).filter(Boolean) : []; + if (next.some((message) => message.role === "user")) return next; + const existing = Array.isArray(existingMessages) ? existingMessages.map(redactConversationMessage).filter(Boolean) : []; + const merged = [...existing]; + for (const message of next) { + const index = merged.findIndex((item) => sameConversationMessage(item, message)); + if (index >= 0) merged[index] = message; + else merged.push(message); + } + return ensureTerminalUserMessage(merged, context).slice(-50); +} +function sameConversationMessage(left = {}, right = {}) { + const leftId = textOr(left.id, ""); + const rightId = textOr(right.id, ""); + if (leftId && rightId && leftId === rightId) return true; + const leftTrace = safeTraceIdLocal(left.traceId); + const rightTrace = safeTraceIdLocal(right.traceId); + return Boolean(leftTrace && rightTrace && leftTrace === rightTrace && textOr(left.role, "") === textOr(right.role, "")); +} +function firstMessageTraceId(messagesSource) { + return safeTraceIdLocal(firstMessageField(messagesSource, "traceId")) || null; +} +function firstMessageField(messagesSource, key) { + if (!Array.isArray(messagesSource)) return null; + for (const message of messagesSource) { + const value = textOr(message?.[key], ""); + if (value) return boundedText(value, 240); + } + return null; +} function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; } function safeConversationIdLocal(value) { return /^cnv_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); } function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 100); } @@ -2719,7 +2761,15 @@ function conversationsFromAgentSessions(sessions = []) { } function publicAgentConversation(session) { const snapshot = normalizeObject(session.session); - const messages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; + const rawMessages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; + const messages = ensureTerminalUserMessage(rawMessages, { + traceId: session.lastTraceId ?? snapshot.lastTraceId, + conversationId: session.conversationId, + sessionId: session.id, + threadId: session.threadId, + now: session.updatedAt, + firstUserPreview: snapshot.firstUserMessagePreview + }); const status = resolvedConversationStatus(session.status, snapshot, messages); const lastTraceId = resolvedConversationLastTraceId(session.lastTraceId, status, messages, snapshot); return {