fix(v02): preserve owner conversation messages (#880)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-05 01:07:49 +08:00
committed by GitHub
parent 0c10056c22
commit 6422487444
2 changed files with 125 additions and 2 deletions
+73
View File
@@ -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({
+52 -2
View File
@@ -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 {