fix(v02): preserve owner conversation messages (#880)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user