Merge pull request #988 from pikasTech/fix/issue955-trace-authority-cleanup

fix: 收敛 Code Agent trace 终态证据路径
This commit is contained in:
Lyon
2026-06-06 13:57:31 +08:00
committed by GitHub
11 changed files with 145 additions and 763 deletions
+5 -436
View File
@@ -719,7 +719,7 @@ test("workbench workspace terminal status sync preserves a newer selected conver
assert.equal(restored.body.workspace.workspace.selectedConversationId, "cnv_issue808_new");
assert.equal(restored.body.workspace.workspace.selectedAgentSessionId, "ses_issue808_new");
assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue808_old_done");
assert.equal(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_issue808_old/commands/cmd_issue808_old/result"), false);
assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_issue808_old/commands/cmd_issue808_old/result"));
const selectedOld = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, {
projectId: "prj_hwpod_workbench",
@@ -983,8 +983,10 @@ test("workbench workspace status repairs terminal selected conversation after ac
}, aliceLogin.cookie);
assert.equal(update.status, 200);
assert.equal(update.body.workspace.activeTraceId, null);
assert.equal(update.body.workspace.selectedConversation.status, "running");
assert.equal(update.body.workspace.selectedConversation.messages.length, 0);
assert.equal(update.body.workspace.selectedConversation.status, "idle");
assert.equal(update.body.workspace.selectedConversation.messages.length, 1);
assert.equal(update.body.workspace.selectedConversation.messages[0].status, "idle");
assert.equal(update.body.workspace.selectedConversation.messages[0].text, "repair completed");
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie);
assert.equal(restored.status, 200);
@@ -1156,96 +1158,6 @@ test("cloud api exposes terminal conversation status when stored session status
}
});
test("cloud api repairs persisted final response fallback from terminal result", async () => {
const codeAgentChatResults = new Map();
const server = createCloudApiServer({
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"
},
codeAgentChatResults,
now: () => "2026-06-04T09:45:00.000Z"
});
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-issue834-fallback", password: "alice-pass" }, adminLogin.cookie);
assert.equal(aliceCreate.status, 201);
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue834-fallback", password: "alice-pass" });
const fallbackText = "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。";
const finalText = "**直接答:能,但只到一半。**\n\n功能已经过了,剩余风险另开 issue 跟踪。";
codeAgentChatResults.set("trc_issue834_final_response", {
status: "completed",
traceId: "trc_issue834_final_response",
conversationId: "cnv_issue834_fallback",
sessionId: "ses_issue834_fallback",
threadId: "thread-issue-834-fallback",
ownerUserId: aliceCreate.body.user.id,
reply: { role: "assistant", content: finalText, messageId: "msg_issue834_real_final" },
agentRun: { terminalStatus: "completed" },
session: {
sessionId: "ses_issue834_fallback",
conversationId: "cnv_issue834_fallback",
threadId: "thread-issue-834-fallback",
status: "idle"
}
});
const stored = await putJson(port, "/v1/agent/conversations/cnv_issue834_fallback", {
projectId: "prj_hwpod_workbench",
sessionId: "ses_issue834_fallback",
threadId: "thread-issue-834-fallback",
sessionStatus: "completed",
lastTraceId: "trc_issue834_final_response",
messages: [
{ id: "msg_issue834_question", role: "user", title: "用户", text: "功能是过了还是没过?", status: "sent", conversationId: "cnv_issue834_fallback", sessionId: "ses_issue834_fallback", threadId: "thread-issue-834-fallback" },
{ id: "msg_issue834_fallback", role: "agent", title: "Agent", text: fallbackText, status: "completed", traceId: "trc_issue834_final_response", conversationId: "cnv_issue834_fallback", sessionId: "ses_issue834_fallback", threadId: "thread-issue-834-fallback" }
]
}, aliceLogin.cookie);
assert.equal(stored.status, 200);
assert.equal(stored.body.conversation.messages[1].text, fallbackText);
assert.equal(stored.body.conversation.status, "completed");
const list = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie);
assert.equal(list.status, 200);
const listed = list.body.conversations.find((conversation) => conversation.conversationId === "cnv_issue834_fallback");
assert.ok(listed, "expected fallback conversation to be listed");
assert.equal(listed.status, "idle");
assert.equal(listed.session.status, "idle");
assert.equal(listed.lastTraceId, "trc_issue834_final_response");
assert.equal(listed.messages[1].text, finalText);
assert.equal(listed.messages[1].status, "idle");
const direct = await getJson(port, "/v1/agent/conversations/cnv_issue834_fallback", aliceLogin.cookie);
assert.equal(direct.status, 200);
assert.equal(direct.body.conversation.status, "idle");
assert.equal(direct.body.conversation.lastTraceId, "trc_issue834_final_response");
assert.equal(direct.body.conversation.messages[1].text, finalText);
assert.equal(direct.body.conversation.messages[1].status, "idle");
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie);
assert.equal(workspace.status, 200);
const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, {
projectId: "prj_hwpod_workbench",
conversationId: "cnv_issue834_fallback",
sessionId: "ses_issue834_fallback",
updatedByClient: "test-suite"
}, aliceLogin.cookie);
assert.equal(selected.status, 200);
assert.equal(selected.body.workspace.selectedConversation.messages[1].text, finalText);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api repairs conversation snapshot when persisted result advances last trace", async () => {
const codeAgentChatResults = new Map();
const server = createCloudApiServer({
@@ -1679,349 +1591,6 @@ test("access controller dedupes duplicate refreshed assistant messages by id and
assert.equal(conversation.messages[1].text, "最终回复");
});
test("access controller keeps final response separate from progress assistant text (#934)", async () => {
const projectId = "prj_hwpod_workbench";
const ownerUserId = "usr_issue934_owner_merge";
const conversationId = "cnv_issue934_owner_merge";
const sessionId = "ses_issue934_owner_merge";
const traceId = "trc_issue934_owner_merge";
const threadId = "thread-issue-934-owner-merge";
const nowValue = "2026-06-05T13:39:48.000Z";
const userText = "看看有几个hwpod可用?";
const progressText = "Let me check the available HWPODs using the `hwpod` CLI tool.";
const middleText = "当前工作区还没有 `.hwlab/hwpod-spec.yaml` 文件,所以 `hwpod inspect` 无法获取可用 HWPOD 信息。";
const finalText = "当前工作区没有 `.hwlab/hwpod-spec.yaml`,也没有 `.hwlab` 目录或现成的 hwpod-spec 文件。这意味着这个 AgentRun 会话还没有绑定具体的 HWPOD。\n\n目前可用的 HWPOD**0 个**(需要先创建 spec 并绑定)。";
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_issue934_user", role: "user", title: "用户", text: userText, status: "sent", traceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_agent_pending", role: "agent", title: "Code Agent 处理中", text: `${progressText}${middleText}`, 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: "completed",
conversationId,
threadId,
traceId,
session: {
source: "code-agent-result",
status: "completed",
sessionStatus: "completed",
lastTraceId: traceId,
finalResponse: {
text: finalText,
textChars: finalText.length,
role: "assistant",
status: "completed",
traceId,
valuesPrinted: false
},
messages: [
{ id: "msg_issue934_agent_result", role: "agent", title: "Code Agent 回复", text: finalText, status: "completed", traceId, conversationId, sessionId, threadId, createdAt: nowValue }
],
messageCount: 1,
firstUserMessagePreview: userText,
valuesRedacted: true,
secretMaterialStored: false
}
});
await accessController.recordAgentSessionOwner({
ownerUserId,
sessionId,
projectId,
agentId: "hwlab-code-agent",
status: "completed",
conversationId,
threadId,
traceId,
session: {
source: "cloud-web-react-account-sync",
sessionStatus: "completed",
lastTraceId: traceId,
messages: [
{ id: "msg_issue934_user", role: "user", title: "用户", text: userText, status: "sent", traceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_agent_pending", role: "agent", title: "Code Agent 回复", text: `${progressText}${middleText}${finalText}`, status: "completed", traceId, conversationId, sessionId, threadId, createdAt: nowValue }
],
messageCount: 2,
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.finalResponse.text, finalText);
assert.equal(session.session.messages[1].text, finalText);
assert.equal(conversation.messages[1].text, finalText);
assert.doesNotMatch(conversation.messages[1].text, /Let me check the available HWPODs/u);
assert.doesNotMatch(conversation.messages[1].text, //u);
assert.equal(conversation.messageCount, 2);
assert.equal(conversation.firstUserMessagePreview, userText);
});
test("cloud api exposes final response for legacy progress-polluted snapshots (#934)", async () => {
const nowValue = "2026-06-05T13:39:48.000Z";
const projectId = "prj_hwpod_workbench";
const conversationId = "cnv_issue934_legacy_snapshot";
const sessionId = "ses_issue934_legacy_snapshot";
const traceId = "trc_issue934_legacy_final";
const staleLastTraceId = "trc_issue934_legacy_greeting";
const threadId = "thread-issue-934-legacy";
const userText = "看看有几个hwpod可用?";
const progressText = "Let me check the available HWPODs using the hwpod CLI tool.";
const middleText = "当前工作区还没有 .hwlab/hwpod-spec.yaml 文件,所以 hwpod inspect 无法获取可用 HWPOD 信息。";
const finalText = "当前工作区没有 .hwlab/hwpod-spec.yaml,也没有 .hwlab 目录或现成的 hwpod-spec 文件。\n\n目前可用的 HWPOD0 个。";
const accessController = createAccessController({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" },
now: () => nowValue
});
const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, accessController, now: () => nowValue });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
assert.equal(login.status, 200);
const ownerUserId = login.body.actor.id;
accessController.store.agentSessions.set(sessionId, {
id: sessionId,
projectId,
agentId: "hwlab-code-agent",
status: "completed",
startedAt: nowValue,
endedAt: null,
ownerUserId,
conversationId,
threadId,
lastTraceId: staleLastTraceId,
updatedAt: nowValue,
session: {
source: "legacy-progress-polluted-snapshot",
sessionStatus: "completed",
lastTraceId: staleLastTraceId,
finalResponse: {
text: finalText,
textChars: finalText.length,
role: "assistant",
status: "completed",
traceId,
valuesPrinted: false
},
messages: [
{ id: "msg_issue934_legacy_user", role: "user", title: "用户", text: userText, status: "sent", traceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_legacy_agent", role: "agent", title: "Code Agent 回复", text: `${progressText}${middleText}${finalText}`, status: "completed", traceId, conversationId, sessionId, threadId, createdAt: nowValue }
],
messageCount: 2,
firstUserMessagePreview: userText,
valuesRedacted: true,
secretMaterialStored: false
}
});
const list = await getJson(port, `/v1/agent/conversations?projectId=${projectId}`, login.cookie);
assert.equal(list.status, 200);
const listed = list.body.conversations.find((conversation) => conversation.conversationId === conversationId);
assert.ok(listed, "expected legacy conversation to be listed");
assert.equal(listed.messages[1].text, finalText);
assert.doesNotMatch(listed.messages[1].text, /Let me check|/u);
const direct = await getJson(port, `/v1/agent/conversations/${conversationId}?projectId=${projectId}`, login.cookie);
assert.equal(direct.status, 200);
assert.equal(direct.body.conversation.messages[1].text, finalText);
assert.doesNotMatch(direct.body.conversation.messages[1].text, /Let me check|/u);
const workspace = await getJson(port, `/v1/workbench/workspace?projectId=${projectId}`, login.cookie);
assert.equal(workspace.status, 200);
const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, {
projectId,
conversationId,
sessionId,
updatedByClient: "test-suite"
}, login.cookie);
assert.equal(selected.status, 200);
assert.equal(selected.body.workspace.selectedConversation.messages[1].text, finalText);
assert.doesNotMatch(selected.body.workspace.selectedConversation.messages[1].text, /Let me check|/u);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api trims folded progress from historical multi-turn final response snapshots (#934)", async () => {
const nowValue = "2026-06-05T13:39:48.000Z";
const projectId = "prj_hwpod_workbench";
const conversationId = "cnv_issue934_multiturn_history";
const sessionId = "ses_issue934_multiturn_history";
const firstTraceId = "trc_issue934_first_turn";
const secondTraceId = "trc_issue934_second_turn";
const threadId = "thread-issue-934-multiturn";
const firstFinalText = "当前工作区没有 `.hwlab/hwpod-spec.yaml`,也没有 `.hwlab` 目录或现成的 hwpod-spec 文件。这意味着这个 AgentRun 会话还没有绑定具体的 HWPOD。\n\n目前可用的 HWPOD**0 个**(需要先创建 spec 并绑定)。\n\n如果你有已知的 HWPOD 信息(例如节点 ID、target 或 spec 模板),可以给我,我可以帮你生成 `.hwlab/hwpod-spec.yaml`。";
const secondUserText = "看看有几个hwpod可用?";
const progressText = "Let me check the available HWPODs using the `hwpod` CLI tool.当前工作区还没有 `.hwlab/hwpod-spec.yaml` 文件,所以 `hwpod inspect` 无法获取可用 HWPOD 信息。 让我看看仓库里有没有现成的 spec 示例或相关配置:";
const accessController = createAccessController({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" },
now: () => nowValue
});
const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, accessController, now: () => nowValue });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
assert.equal(login.status, 200);
const ownerUserId = login.body.actor.id;
accessController.store.agentSessions.set(sessionId, {
id: sessionId,
projectId,
agentId: "hwlab-code-agent",
status: "completed",
startedAt: nowValue,
endedAt: null,
ownerUserId,
conversationId,
threadId,
lastTraceId: firstTraceId,
updatedAt: nowValue,
session: {
source: "legacy-multiturn-progress-polluted-snapshot",
sessionStatus: "completed",
lastTraceId: firstTraceId,
finalResponse: {
text: firstFinalText,
textChars: firstFinalText.length,
role: "assistant",
status: "completed",
traceId: firstTraceId,
valuesPrinted: false
},
messages: [
{ id: "msg_issue934_first_user", role: "user", title: "用户", text: "你好", status: "sent", traceId: firstTraceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_first_agent", role: "agent", title: "Code Agent 回复", text: firstFinalText, status: "completed", traceId: firstTraceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_second_user", role: "user", title: "用户", text: secondUserText, status: "sent", traceId: secondTraceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_second_agent", role: "agent", title: "Code Agent 回复", text: `${progressText}${firstFinalText}`, status: "completed", traceId: secondTraceId, conversationId, sessionId, threadId, createdAt: nowValue }
],
messageCount: 4,
firstUserMessagePreview: "你好",
valuesRedacted: true,
secretMaterialStored: false
}
});
const direct = await getJson(port, `/v1/agent/conversations/${conversationId}?projectId=${projectId}`, login.cookie);
assert.equal(direct.status, 200);
assert.equal(direct.body.conversation.messages.length, 4);
assert.equal(direct.body.conversation.messages[1].text, firstFinalText);
assert.equal(direct.body.conversation.messages[3].text, firstFinalText);
assert.equal(direct.body.conversation.messages[3].traceId, secondTraceId);
assert.doesNotMatch(direct.body.conversation.messages[3].text, /Let me check||/u);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api trims folded progress when only conversation messages preserve the clean final text (#934)", async () => {
const nowValue = "2026-06-05T15:08:00.000Z";
const projectId = "prj_hwpod_workbench";
const conversationId = "cnv_issue934_message_only_history";
const sessionId = "ses_issue934_message_only_history";
const firstTraceId = "trc_issue934_message_only_first";
const secondTraceId = "trc_issue934_message_only_second";
const threadId = "thread-issue-934-message-only";
const finalText = "当前工作区没有 `.hwlab/hwpod-spec.yaml`,也没有 `.hwlab` 目录或现成的 hwpod-spec 文件。这意味着这个 AgentRun 会话还没有绑定具体的 HWPOD。\n\n目前可用的 HWPOD**0 个**(需要先创建 spec 并绑定)。\n\n如果你有已知的 HWPOD 信息(例如节点 ID、target 或 spec 模板),可以给我,我可以帮你生成 `.hwlab/hwpod-spec.yaml`。";
const progressText = "Let me check the available HWPODs using the `hwpod` CLI tool.当前工作区还没有 `.hwlab/hwpod-spec.yaml` 文件,所以 `hwpod inspect` 无法获取可用 HWPOD 信息。 让我看看仓库里有没有现成的 spec 示例或相关配置:";
const whitespaceFoldedFinalText = finalText.replace(/\s+/gu, " ");
const accessController = createAccessController({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" },
now: () => nowValue
});
const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, accessController, now: () => nowValue });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
assert.equal(login.status, 200);
const ownerUserId = login.body.actor.id;
accessController.store.agentSessions.set(sessionId, {
id: sessionId,
projectId,
agentId: "hwlab-code-agent",
status: "completed",
startedAt: nowValue,
endedAt: null,
ownerUserId,
conversationId,
threadId,
lastTraceId: firstTraceId,
updatedAt: nowValue,
session: {
source: "legacy-message-only-progress-polluted-snapshot",
sessionStatus: "completed",
lastTraceId: firstTraceId,
messages: [
{ id: "msg_issue934_message_only_first_user", role: "user", title: "用户", text: "你好", status: "sent", traceId: firstTraceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_message_only_first_agent", role: "agent", title: "Code Agent 回复", text: finalText, status: "completed", traceId: firstTraceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_message_only_second_user", role: "user", title: "用户", text: "看看有几个hwpod可用?", status: "sent", traceId: secondTraceId, conversationId, sessionId, threadId, createdAt: nowValue },
{ id: "msg_issue934_message_only_second_agent", role: "agent", title: "Code Agent 回复", text: `${progressText}${whitespaceFoldedFinalText}`, status: "completed", traceId: secondTraceId, conversationId, sessionId, threadId, createdAt: nowValue }
],
messageCount: 4,
firstUserMessagePreview: "你好",
valuesRedacted: true,
secretMaterialStored: false
}
});
const direct = await getJson(port, `/v1/agent/conversations/${conversationId}?projectId=${projectId}`, login.cookie);
assert.equal(direct.status, 200);
assert.equal(direct.body.conversation.messages.length, 4);
assert.equal(direct.body.conversation.messages[1].text, finalText);
assert.equal(direct.body.conversation.messages[3].text, finalText);
assert.equal(direct.body.conversation.messages[3].traceId, secondTraceId);
assert.doesNotMatch(direct.body.conversation.messages[3].text, /Let me check||/u);
const list = await getJson(port, `/v1/agent/conversations?projectId=${projectId}`, login.cookie);
assert.equal(list.status, 200);
const listed = list.body.conversations.find((conversation) => conversation.conversationId === conversationId);
assert.ok(listed, "expected message-only legacy conversation to be listed");
assert.equal(listed.messages[3].text, finalText);
assert.doesNotMatch(listed.messages[3].text, /Let me check||/u);
} 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({
+5 -70
View File
@@ -1365,7 +1365,7 @@ class AccessController {
async repairVisibleConversationIfNeeded(actor, conversation, projectId = "") {
if (textOr(conversation?.status, "").toLowerCase() === "archived") return conversation;
const traceId = terminalConversationFallbackRepairTraceId(conversation);
const traceId = terminalConversationRepairTraceId(conversation);
if (!traceId) return conversation;
const result = await this.terminalCodeAgentResultForActor(traceId, actor);
if (!result) return conversation;
@@ -1898,7 +1898,7 @@ function mergeAgentSessionOwnerEvidence(nextValue, existingValue, options = {})
} else if (existingMessages?.length) {
merged.messages = existingMessages;
}
merged.messages = normalizeFoldedProgressConversationMessages(normalizeFinalResponseConversationMessages(merged.messages, merged.finalResponse));
merged.messages = normalizeFinalResponseConversationMessages(merged.messages, merged.finalResponse);
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;
@@ -1994,7 +1994,7 @@ function normalizeFinalResponseConversationMessages(messages, finalResponse) {
return messages.map((message) => {
if (message?.role !== "agent") return message;
const sameTrace = traceId && safeTraceIdLocal(message.traceId) === traceId;
if (!sameTrace && !isFoldedProgressBeforeFinalResponse(message.text, finalText)) return message;
if (!sameTrace) return message;
return redactConversationMessage({
...message,
text: finalText,
@@ -2003,35 +2003,6 @@ function normalizeFinalResponseConversationMessages(messages, finalResponse) {
});
}).filter(Boolean);
}
function isFoldedProgressBeforeFinalResponse(messageText, finalText) {
const text = textOr(messageText, "");
const final = textOr(finalText, "");
if (final.length < 80 || text.length <= final.length) return false;
const index = text.indexOf(final);
const foldedIndex = index > 0 ? index : collapseConversationWhitespace(text).indexOf(collapseConversationWhitespace(final));
if (foldedIndex <= 0) return false;
const prefix = text.slice(0, Math.min(foldedIndex, text.length)).trim();
if (prefix.length < 20) return false;
return /\bLet me (?:check|inspect|look)\b|\bI(?:'|)?ll (?:check|inspect|look)\b|(?:||)?(?:|||)|(?:|||)|(?:||)||hwpod inspect/iu.test(prefix);
}
function collapseConversationWhitespace(value) {
return textOr(value, "").replace(/\s+/gu, " ").trim();
}
function normalizeFoldedProgressConversationMessages(messages) {
if (!Array.isArray(messages) || messages.length < 2) return messages;
const cleanAgentTexts = [];
return messages.map((message) => {
if (message?.role !== "agent") return message;
const text = textOr(message.text, "");
const replacementText = cleanAgentTexts.find((candidate) => isFoldedProgressBeforeFinalResponse(text, candidate));
const normalized = replacementText ? redactConversationMessage({ ...message, text: replacementText }) : message;
const normalizedText = textOr(normalized?.text, "");
if (normalizedText.length >= 80 && !cleanAgentTexts.includes(normalizedText) && !cleanAgentTexts.some((candidate) => isFoldedProgressBeforeFinalResponse(normalizedText, candidate))) {
cleanAgentTexts.push(normalizedText);
}
return normalized;
}).filter(Boolean);
}
function mergeAgentSessionOwnerMessages(nextMessages = [], existingMessages = [], context = {}) {
const next = dedupeConversationMessages(nextMessages);
if (next.some((message) => message.role === "user")) return ensureTerminalUserMessage(next, context).slice(-50);
@@ -2274,8 +2245,7 @@ function conversationNeedsTerminalRepair(conversation, traceId) {
if (!traceMessage) return Boolean(latestAgent && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId));
if (latestAgent?.traceId && latestAgent.traceId !== traceId && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)) return true;
const messageStatus = textOr(traceMessage.status, "").toLowerCase();
return ["", "running", "busy", "pending", "active"].includes(messageStatus)
|| isLegacyFinalResponseFallbackText(traceMessage.text);
return ["", "running", "busy", "pending", "active"].includes(messageStatus);
}
function terminalConversationRepairTraceId(conversation) {
if (!conversation) return "";
@@ -2289,25 +2259,6 @@ function terminalConversationRepairTraceId(conversation) {
}
return "";
}
function terminalConversationFallbackRepairTraceId(conversation) {
if (!conversation) return "";
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
const lastTraceId = safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId);
const lastTraceMessage = messages.find((message) => message?.traceId === lastTraceId && message?.role === "agent") ?? null;
const latestAgent = latestAgentConversationMessage(messages);
if (lastTraceId && latestAgent?.traceId && latestAgent.traceId !== lastTraceId) return lastTraceId;
if (lastTraceId && latestAgent && !lastTraceMessage) return lastTraceId;
if (lastTraceId && isLegacyFinalResponseFallbackText(lastTraceMessage?.text)) return lastTraceId;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const traceId = safeTraceIdLocal(message?.traceId);
if (message?.role === "agent" && traceId && isLegacyFinalResponseFallbackText(message.text)) return traceId;
}
return "";
}
function isLegacyFinalResponseFallbackText(value) {
return textOr(value, "").trim() === "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。";
}
function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) {
const baseMessages = dedupeConversationMessages(existingMessages);
const traceId = safeTraceIdLocal(result.traceId ?? context.traceId);
@@ -2392,7 +2343,6 @@ function terminalRunnerTraceForConversationMessage(result = {}, context = {}) {
fullTraceLoaded: false,
lastEvent,
traceStatus: textOr(result.traceStatus, ""),
fallback: redactTraceFallback(result.fallback),
finalResponse: redactTraceFinalResponse(result.finalResponse),
traceSummary: redactTraceSummary(result.traceSummary)
});
@@ -2423,26 +2373,11 @@ function redactConversationMessage(message) {
fullTraceLoaded: runnerTrace.fullTraceLoaded === true,
lastEvent: runnerTrace.lastEvent,
traceStatus: textOr(runnerTrace.traceStatus, ""),
fallback: redactTraceFallback(runnerTrace.fallback),
finalResponse: redactTraceFinalResponse(runnerTrace.finalResponse),
traceSummary: redactTraceSummary(runnerTrace.traceSummary)
}) : undefined
});
}
function redactTraceFallback(value) {
const fallback = normalizeObject(value);
if (Object.keys(fallback).length === 0) return undefined;
return pruneEmpty({
available: fallback.available === true,
source: textOr(fallback.source, ""),
conversationId: textOr(fallback.conversationId, ""),
sessionId: textOr(fallback.sessionId, ""),
threadId: textOr(fallback.threadId, ""),
agentRun: redactAgentRunSummary(fallback.agentRun),
finalResponse: redactTraceFinalResponse(fallback.finalResponse),
traceSummary: redactTraceSummary(fallback.traceSummary)
});
}
function redactTraceFinalResponse(value) {
const response = normalizeObject(value);
if (Object.keys(response).length === 0) return undefined;
@@ -2522,7 +2457,7 @@ function publicAgentConversation(session) {
now: session.updatedAt,
firstUserPreview: snapshot.firstUserMessagePreview
}));
const displayMessages = normalizeFoldedProgressConversationMessages(normalizeFinalResponseConversationMessages(messages, snapshot.finalResponse));
const displayMessages = normalizeFinalResponseConversationMessages(messages, snapshot.finalResponse);
const status = resolvedConversationStatus(session.status, snapshot, displayMessages);
const lastTraceId = resolvedConversationLastTraceId(session.lastTraceId, status, displayMessages, snapshot);
return {
+6 -6
View File
@@ -1624,7 +1624,7 @@ test("cloud api trace uses AgentRun command result evidence when live trace stor
},
traceSummary: {
traceId: "trc_issue842_expired",
source: "stale-agent-session-snapshot",
source: "stale-derived-session-evidence",
sourceEventCount: 26,
terminalStatus: "completed",
noiseEventCount: 4,
@@ -1717,8 +1717,8 @@ test("cloud api trace uses AgentRun command result evidence when live trace stor
assert.equal(body.status, "expired");
assert.equal(body.traceStatus, "expired");
assert.equal(body.eventCount, 0);
assert.equal(body.fallback.available, true);
assert.equal(body.fallback.source, "agentrun-command-result");
assert.equal(body.terminalEvidence.available, true);
assert.equal(body.terminalEvidence.source, "agentrun-command-result");
assert.equal(body.finalResponse.text, commandFinalText);
assert.equal(body.traceSummary.source, "agentrun-command-result");
assert.equal(body.traceSummary.sourceEventCount, 0);
@@ -1795,7 +1795,7 @@ test("cloud api trace replays an earlier AgentRun command after same-run lastSeq
ownerRole: TEST_AGENT_ACTOR.role,
reply: { role: "assistant", content: firstFinalText },
finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: firstTraceId, valuesPrinted: false },
traceSummary: { traceId: firstTraceId, source: "agent-session-snapshot", sourceEventCount: 41, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 35, valuesPrinted: false }, valuesPrinted: false },
traceSummary: { traceId: firstTraceId, source: "code-agent-derived-session-evidence", sourceEventCount: 41, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 35, valuesPrinted: false }, valuesPrinted: false },
agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId: firstCommandId, lastSeq: 66, terminalStatus: "completed", valuesPrinted: false },
valuesPrinted: false
});
@@ -1908,7 +1908,7 @@ test("cloud api repairs historical same-session AgentRun trace after lastTraceId
],
agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId: firstCommandId, backendProfile: "deepseek", terminalStatus: "completed", lastSeq: 35, valuesPrinted: false },
finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: secondTraceId, valuesPrinted: false },
traceSummary: { traceId: secondTraceId, source: "agent-session-snapshot", sourceEventCount: 36, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 35, valuesPrinted: false }, valuesPrinted: false },
traceSummary: { traceId: secondTraceId, source: "code-agent-derived-session-evidence", sourceEventCount: 36, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 35, valuesPrinted: false }, valuesPrinted: false },
traceResults: {
[secondTraceId]: {
traceId: secondTraceId,
@@ -2062,7 +2062,7 @@ test("cloud api result polling repairs polluted completed AgentRun memory cache
ownerRole: TEST_AGENT_ACTOR.role,
reply: { role: "assistant", content: firstFinalText },
finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: secondTraceId, valuesPrinted: false },
traceSummary: { traceId: secondTraceId, source: "agent-session-snapshot", sourceEventCount: 11, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 11, valuesPrinted: false }, valuesPrinted: false },
traceSummary: { traceId: secondTraceId, source: "code-agent-derived-session-evidence", sourceEventCount: 11, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 11, valuesPrinted: false }, valuesPrinted: false },
agentRun: {
adapter: "agentrun-v01",
managerUrl: `http://127.0.0.1:${agentRunPort}`,
+58 -45
View File
@@ -1719,7 +1719,7 @@ function codeAgentTraceSummaryEvidence(payload = {}, traceId = null, finalRespon
const terminalEvent = [...events].reverse().find((event) => event?.terminal === true) ?? runnerTrace?.lastEvent ?? null;
return {
traceId,
source: "agent-session-snapshot",
source: "code-agent-derived-session-evidence",
sourceEventCount: Number.isFinite(Number(runnerTrace?.eventCount)) ? Number(runnerTrace.eventCount) : events.length,
terminalStatus: payload.agentRun?.terminalStatus ?? payload.status ?? terminalEvent?.status ?? null,
lastEventLabel: terminalEvent?.label ?? null,
@@ -1867,6 +1867,18 @@ export async function handleCodeAgentTraceHttp(request, response, url, options)
recordCodeAgentConversationFact(agentRunResult, options);
await recordCodeAgentSessionOwner({ payload: agentRunResult, params: agentRunResult, options, status: codeAgentOwnerStatusForResult(agentRunResult), preserveLastTraceId: true });
}
} catch (error) {
sendJson(response, Number(error?.statusCode ?? 502), {
error: {
code: error?.code ?? "agentrun_trace_command_result_unavailable",
message: error?.message ?? "AgentRun command result could not be resolved for this traceId",
traceId,
valuesPrinted: false
}
});
return;
}
try {
await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore });
} catch (error) {
refreshError = error;
@@ -1879,18 +1891,17 @@ export async function handleCodeAgentTraceHttp(request, response, url, options)
}
const snapshot = traceStore.snapshot(traceId);
sendJson(response, 200, traceSnapshotWithPersistentFallback(snapshot, agentRunResult, traceId, refreshError));
sendJson(response, 200, traceSnapshotWithTerminalEvidence(snapshot, agentRunResult, traceId, refreshError));
}
function traceSnapshotWithPersistentFallback(snapshot, persistedResult, traceId, refreshError = null) {
const fallback = persistentTraceFallback(persistedResult, traceId);
if (snapshot?.status !== "missing") return traceSnapshotWithPersistentEvidence(snapshot, fallback, refreshError);
if (!fallback) return {
function traceSnapshotWithTerminalEvidence(snapshot, result, traceId, refreshError = null) {
const evidence = agentRunTerminalTraceEvidence(result, traceId);
if (snapshot?.status !== "missing") return traceSnapshotWithAttachedTerminalEvidence(snapshot, evidence, refreshError);
if (!evidence) return {
...snapshot,
ok: false,
traceStatus: "missing",
retention: traceRetentionSummary("missing"),
fallback: { available: false, source: null }
retention: traceRetentionSummary("missing")
};
return {
...snapshot,
@@ -1899,51 +1910,51 @@ function traceSnapshotWithPersistentFallback(snapshot, persistedResult, traceId,
traceStatus: "expired",
persisted: true,
retention: traceRetentionSummary("expired"),
conversationId: fallback.conversationId,
sessionId: fallback.sessionId,
threadId: fallback.threadId,
agentRun: fallback.agentRun,
finalResponse: fallback.finalResponse,
traceSummary: fallback.traceSummary,
fallback: {
conversationId: evidence.conversationId,
sessionId: evidence.sessionId,
threadId: evidence.threadId,
agentRun: evidence.agentRun,
finalResponse: evidence.finalResponse,
traceSummary: evidence.traceSummary,
terminalEvidence: {
available: true,
source: fallback.source,
source: evidence.source,
refresh: refreshError ? {
attempted: true,
ok: false,
code: refreshError?.code ?? "agentrun_trace_refresh_failed",
message: refreshError?.message ?? "AgentRun trace refresh failed; using persisted session summary.",
message: refreshError?.message ?? "AgentRun trace events could not be refreshed; command result remains authoritative for final response.",
valuesPrinted: false
} : { attempted: false, ok: null },
conversationId: fallback.conversationId,
sessionId: fallback.sessionId,
threadId: fallback.threadId,
agentRun: fallback.agentRun,
traceSummary: fallback.traceSummary,
finalResponse: fallback.finalResponse,
conversationId: evidence.conversationId,
sessionId: evidence.sessionId,
threadId: evidence.threadId,
agentRun: evidence.agentRun,
traceSummary: evidence.traceSummary,
finalResponse: evidence.finalResponse,
valuesPrinted: false
}
};
}
function traceSnapshotWithPersistentEvidence(snapshot, fallback, refreshError = null) {
if (!fallback) return snapshot;
function traceSnapshotWithAttachedTerminalEvidence(snapshot, evidence, refreshError = null) {
if (!evidence) return snapshot;
return {
...snapshot,
conversationId: snapshot.conversationId ?? fallback.conversationId,
sessionId: snapshot.sessionId ?? fallback.sessionId,
threadId: snapshot.threadId ?? fallback.threadId,
agentRun: snapshot.agentRun ?? fallback.agentRun,
finalResponse: snapshot.finalResponse ?? fallback.finalResponse,
traceSummary: snapshot.traceSummary ?? fallback.traceSummary,
fallback: snapshot.fallback ?? (refreshError ? {
conversationId: snapshot.conversationId ?? evidence.conversationId,
sessionId: snapshot.sessionId ?? evidence.sessionId,
threadId: snapshot.threadId ?? evidence.threadId,
agentRun: snapshot.agentRun ?? evidence.agentRun,
finalResponse: snapshot.finalResponse ?? evidence.finalResponse,
traceSummary: snapshot.traceSummary ?? evidence.traceSummary,
terminalEvidence: snapshot.terminalEvidence ?? (refreshError ? {
available: true,
source: fallback.source,
source: evidence.source,
refresh: {
attempted: true,
ok: false,
code: refreshError?.code ?? "agentrun_trace_refresh_failed",
message: refreshError?.message ?? "AgentRun trace refresh failed; live events may be partial.",
message: refreshError?.message ?? "AgentRun trace events could not be refreshed; live events may be partial.",
valuesPrinted: false
},
valuesPrinted: false
@@ -1951,10 +1962,10 @@ function traceSnapshotWithPersistentEvidence(snapshot, fallback, refreshError =
};
}
function persistentTraceFallback(result, traceId) {
function agentRunTerminalTraceEvidence(result, traceId) {
if (!result || typeof result !== "object") return null;
const storedSummary = result.traceSummary && typeof result.traceSummary === "object" ? result.traceSummary : null;
const finalResponse = persistentFinalResponse(result);
const storedSummary = result.traceSummary && typeof result.traceSummary === "object" && result.traceSummary.source === "agentrun-command-result" ? result.traceSummary : null;
const finalResponse = agentRunTerminalFinalResponse(result, traceId);
const agentRun = result.agentRun && typeof result.agentRun === "object" ? {
adapter: result.agentRun.adapter ?? null,
runId: result.agentRun.runId ?? null,
@@ -1967,10 +1978,10 @@ function persistentTraceFallback(result, traceId) {
lastSeq: result.agentRun.lastSeq ?? null,
valuesPrinted: false
} : null;
if (!storedSummary && !finalResponse && !agentRun) return null;
if (!agentRun?.runId || !agentRun?.commandId || (!storedSummary && !finalResponse)) return null;
const traceSummary = {
traceId,
source: storedSummary?.source ?? (finalResponse ? "agent-session-final-response" : "agent-session-agentrun"),
source: "agentrun-command-result",
sourceEventCount: numberOrNull(storedSummary?.sourceEventCount ?? storedSummary?.eventCount ?? result.runnerTrace?.eventCount),
renderedRowSummary: storedSummary?.renderedRowSummary ?? null,
noiseEventCount: numberOrNull(storedSummary?.noiseEventCount),
@@ -1980,7 +1991,7 @@ function persistentTraceFallback(result, traceId) {
valuesPrinted: false
};
return {
source: storedSummary?.source ?? (finalResponse ? "agent-session-final-response" : "agent-session-agentrun"),
source: "agentrun-command-result",
conversationId: safeConversationId(result.conversationId) || null,
sessionId: safeSessionId(result.sessionId) || null,
threadId: safeOpaqueId(result.threadId) || null,
@@ -1990,20 +2001,22 @@ function persistentTraceFallback(result, traceId) {
};
}
function persistentFinalResponse(result) {
function agentRunTerminalFinalResponse(result, traceId) {
const stored = result.finalResponse && typeof result.finalResponse === "object" ? result.finalResponse : null;
const text = textValue(stored?.text ?? result.reply?.content ?? result.message?.content ?? result.assistantText);
if (!text) return null;
const storedTraceId = safeTraceId(stored?.traceId ?? result.traceId) || null;
if (storedTraceId && storedTraceId !== traceId) return null;
return {
text,
textChars: text.length,
messageId: stored?.messageId ?? result.reply?.messageId ?? result.messageId ?? null,
role: stored?.role ?? result.reply?.role ?? "assistant",
status: stored?.status ?? result.status ?? null,
traceId: safeTraceId(stored?.traceId ?? result.traceId) || null,
traceId: traceId,
createdAt: stored?.createdAt ?? result.reply?.createdAt ?? result.createdAt ?? null,
updatedAt: stored?.updatedAt ?? result.updatedAt ?? null,
source: stored?.source ?? "code-agent-result",
source: "agentrun-command-result",
valuesPrinted: false
};
}
@@ -2012,8 +2025,8 @@ function traceRetentionSummary(status) {
return {
traceStatus: status,
liveTraceStore: status === "missing" ? "missing" : "expired-or-evicted",
policy: "live trace events are best-effort short-term storage; completed turns persist final response and summary in the agent session snapshot",
replacementEvidence: status === "missing" ? "submit a fresh equivalent trace or inspect conversation/session if available" : "use fallback.finalResponse, fallback.traceSummary, conversationId, sessionId, threadId, and agentRun ids",
policy: "live trace events are short-term storage; terminal final response and summary are resolved from AgentRun command result by traceId and commandId",
replacementEvidence: status === "missing" ? "resolve the trace through AgentRun command registry/result or report the trace as missing" : "use terminalEvidence.finalResponse, terminalEvidence.traceSummary, conversationId, sessionId, threadId, and agentRun ids",
valuesPrinted: false
};
}
+7 -85
View File
@@ -397,46 +397,11 @@ test("hwlab-cli client session final-response verifies original conversation acr
assert.equal(result.payload.validation.checks.listFound, true);
assert.equal(result.payload.validation.checks.inspectTextMatchesResult, true);
assert.equal(result.payload.validation.checks.listTextMatchesResult, true);
assert.equal(result.payload.validation.checks.fallbackTextAbsent, true);
assert.equal(result.payload.validation.repair.appearsRepaired, true);
assert.equal(calls[1], "http://web.test/v1/agent/conversations?projectId=prj_v02_code_agent&limit=100");
assert.deepEqual(result.payload.validation.failures, []);
});
test("hwlab-cli client session final-response fails when old fallback is still visible", async () => {
const fallbackText = "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。";
const conversation = {
conversationId: "cnv_issue834",
projectId: "prj_v02_code_agent",
sessionId: "ses_issue834",
status: "idle",
lastTraceId: "trc_issue834_final",
messages: [{ role: "agent", traceId: "trc_issue834_final", status: "idle", text: fallbackText }]
};
const fetchImpl = async (url: string | URL) => {
if (String(url).endsWith("/v1/agent/conversations/cnv_issue834")) {
return new Response(JSON.stringify({ ok: true, conversation }), { status: 200 });
}
if (String(url).endsWith("/v1/agent/conversations?projectId=prj_v02_code_agent&limit=100")) {
return new Response(JSON.stringify({ ok: true, conversations: [conversation] }), { status: 200 });
}
if (String(url).endsWith("/v1/agent/chat/result/trc_issue834_final")) {
return new Response(JSON.stringify({ ok: true, status: "completed", traceId: "trc_issue834_final", reply: { content: "真实 final response" } }), { status: 200 });
}
return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_route" } }), { status: 404 });
};
const result = await runHwlabCli(["client", "session", "final-response", "cnv_issue834", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { fetchImpl });
assert.equal(result.exitCode, 1);
assert.equal(result.payload.action, "client.session.final-response");
assert.equal(result.payload.validation.state, "failed");
assert.equal(result.payload.validation.checks.fallbackTextAbsent, false);
assert.ok(result.payload.validation.failures.includes("fallback-text-present"));
assert.ok(result.payload.validation.failures.includes("inspect-text-result-mismatch"));
assert.deepEqual(result.payload.validation.fallbackText.presentIn, ["inspect", "list"]);
});
test("hwlab-cli client session final-response preserves inspect auth failure visibility", async () => {
const fetchImpl = async (url: string | URL) => {
if (String(url).endsWith("/v1/agent/conversations/cnv_issue834")) {
@@ -455,49 +420,6 @@ test("hwlab-cli client session final-response preserves inspect auth failure vis
assert.equal(result.payload.error?.code, undefined);
});
test("hwlab-cli client agent inspect can resolve a session id through the conversation list", async () => {
const calls: string[] = [];
const conversation = {
conversationId: "cnv_issue849",
sessionId: "ses_issue849",
threadId: "thread_issue849",
status: "idle",
lastTraceId: "trc_issue849",
messages: [{ role: "agent", traceId: "trc_issue849", text: "final response" }]
};
const fetchImpl = async (url: string | URL) => {
calls.push(String(url));
if (String(url).endsWith("/v1/agent/chat/inspect?sessionId=ses_issue849")) {
return new Response(JSON.stringify({ ok: false, status: "not_found", session: null }), { status: 404 });
}
if (String(url).endsWith("/v1/agent/conversations?projectId=prj_hwpod_workbench&limit=100")) {
return new Response(JSON.stringify({ ok: true, conversations: [conversation] }), { status: 200 });
}
if (String(url).endsWith("/v1/agent/conversations/cnv_issue849")) {
return new Response(JSON.stringify({ ok: true, status: "found", conversation }), { status: 200 });
}
return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_route" } }), { status: 404 });
};
const result = await runHwlabCli(["client", "agent", "inspect", "--session-id", "ses_issue849", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { fetchImpl });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "client.agent.inspect");
assert.equal(result.payload.fallbackLookup.attempted, true);
assert.equal(result.payload.fallbackLookup.source, "client.session.list");
assert.equal(result.payload.fallbackLookup.conversationId, "cnv_issue849");
assert.equal(result.payload.fallbackLookup.traceId, "trc_issue849");
assert.equal(result.payload.route.path, "/v1/agent/conversations/cnv_issue849");
assert.equal(result.payload.conversation.conversationId, "cnv_issue849");
assert.equal(result.payload.conversation.sessionId, "ses_issue849");
assert.equal(result.payload.body.status, "found");
assert.deepEqual(calls, [
"http://web.test/v1/agent/chat/inspect?sessionId=ses_issue849",
"http://web.test/v1/agent/conversations?projectId=prj_hwpod_workbench&limit=100",
"http://web.test/v1/agent/conversations/cnv_issue849"
]);
});
test("hwlab-cli rejects manual endpoint override in locked assembled runtime", async () => {
const result = await runHwlabCli(["client", "auth", "status", "--base-url", "http://74.48.78.17:17666"], {
env: {
@@ -1678,7 +1600,7 @@ test("hwlab-cli client agent trace exposes missing Web render state without a bo
assert.deepEqual(result.payload.data, result.payload.rendered);
});
test("hwlab-cli client agent trace renders persisted summary for expired traces", async () => {
test("hwlab-cli client agent trace renders AgentRun terminal evidence for expired traces", async () => {
const finalText = "历史 trace 已过期,但 final response 仍可审计。";
const result = await runHwlabCli(["client", "agent", "trace", "trc_expired", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], {
fetchImpl: async () => new Response(JSON.stringify({
@@ -1693,17 +1615,17 @@ test("hwlab-cli client agent trace renders persisted summary for expired traces"
threadId: "thread-expired",
retention: { traceStatus: "expired", liveTraceStore: "expired-or-evicted" },
agentRun: { runId: "run_expired", commandId: "cmd_expired", valuesPrinted: false },
finalResponse: { text: finalText, textChars: finalText.length, role: "assistant", status: "completed", source: "code-agent-result", valuesPrinted: false },
traceSummary: { source: "agent-session-snapshot", sourceEventCount: 26, terminalStatus: "completed", valuesPrinted: false },
fallback: {
finalResponse: { text: finalText, textChars: finalText.length, role: "assistant", status: "completed", source: "agentrun-command-result", valuesPrinted: false },
traceSummary: { source: "agentrun-command-result", sourceEventCount: 26, terminalStatus: "completed", valuesPrinted: false },
terminalEvidence: {
available: true,
source: "agent-session-snapshot",
source: "agentrun-command-result",
conversationId: "cnv_expired",
sessionId: "ses_expired",
threadId: "thread-expired",
agentRun: { runId: "run_expired", commandId: "cmd_expired", valuesPrinted: false },
finalResponse: { text: finalText, textChars: finalText.length, role: "assistant", status: "completed", valuesPrinted: false },
traceSummary: { source: "agent-session-snapshot", sourceEventCount: 26, terminalStatus: "completed", valuesPrinted: false },
traceSummary: { source: "agentrun-command-result", sourceEventCount: 26, terminalStatus: "completed", valuesPrinted: false },
valuesPrinted: false
}
}), { status: 200 })
@@ -1720,7 +1642,7 @@ test("hwlab-cli client agent trace renders persisted summary for expired traces"
assert.match(result.payload.rendered.rows[0].header, / trace /u);
assert.match(result.payload.rendered.rows[0].body, /final response /u);
assert.match(result.payload.rendered.rows[0].body, /run_expired/u);
assert.equal(result.payload.rendered.fallback.available, true);
assert.equal(result.payload.rendered.terminalEvidence.available, true);
assert.equal(result.payload.rendered.traceSummary.sourceEventCount, 26);
assert.deepEqual(result.payload.data, result.payload.rendered);
});
+27 -85
View File
@@ -863,53 +863,7 @@ async function agentCommand(context: any) {
async function agentInspect(context: any) {
const pathName = agentInspectPath(context);
const response = await requestJson({ ...context, method: "GET", path: pathName });
if (responseSucceeded(response) || !shouldFallbackInspectBySession(context, response)) {
return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) });
}
const fallback = await inspectConversationBySessionId(context, requiredSessionId(context.parsed.sessionId));
if (!fallback) {
return responsePayload("client.agent.inspect", response, context, {
route: route("GET", pathName),
fallbackLookup: { attempted: true, source: "client.session.list", found: false },
body: responseBodyForCli(response.body, context.parsed)
});
}
return fallback;
}
function shouldFallbackInspectBySession(context: any, response: any): boolean {
if (!text(context.parsed.sessionId)) return false;
if (text(context.parsed.traceId) || text(context.parsed.conversationId) || text(context.parsed.threadId)) return false;
return response.status === 404 || response.body?.status === "not_found" || response.body?.ok === false;
}
async function inspectConversationBySessionId(context: any, sessionId: string) {
const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID;
const limit = numberOption(context.parsed.limit) ?? 100;
const listPath = `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}&limit=${encodeURIComponent(String(limit))}`;
const listResponse = await requestJson({ ...context, method: "GET", path: listPath });
if (!responseSucceeded(listResponse)) return null;
const match = Array.isArray(listResponse.body?.conversations)
? listResponse.body.conversations.find((item: any) => text(item?.sessionId ?? item?.session?.sessionId) === sessionId) ?? null
: null;
const conversationId = text(match?.conversationId);
if (!conversationId) return null;
const inspectPath = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`;
const inspectResponse = await requestJson({ ...context, method: "GET", path: inspectPath, extraHeaders: text(match?.lastTraceId) ? { "x-trace-id": text(match.lastTraceId) } : undefined });
return responsePayload("client.agent.inspect", inspectResponse, context, {
route: route("GET", inspectPath),
conversation: sessionSummary(inspectResponse.body?.conversation ?? match),
fallbackLookup: {
attempted: true,
source: "client.session.list",
listRoute: route("GET", listPath),
matchedSessionId: sessionId,
conversationId,
traceId: text(match?.lastTraceId)
},
body: responseBodyForCli(inspectResponse.body, context.parsed)
});
return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) });
}
function agentHelp() {
@@ -959,7 +913,7 @@ function sessionHelp() {
"switch CONVERSATION_ID|--conversation-id ID",
"delete CONVERSATION_ID|--conversation-id ID --confirm",
"inspect CONVERSATION_ID|--conversation-id ID [--trace-id TRACE]",
"final-response CONVERSATION_ID|--conversation-id ID [--trace-id TRACE] [--project-id PROJECT] [--fallback-text TEXT]"
"final-response CONVERSATION_ID|--conversation-id ID [--trace-id TRACE] [--project-id PROJECT]"
]
});
}
@@ -1087,8 +1041,7 @@ async function sessionFinalResponseCommand(context: any) {
inspectConversation: conversation,
listedConversation,
traceId,
resultBody: resultResponse.body,
fallbackText: text(context.parsed.fallbackText) || "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。"
resultBody: resultResponse.body
});
const success = responseSucceeded(inspectResponse) && responseSucceeded(listResponse) && responseSucceeded(resultResponse) && validation.ok;
return {
@@ -2793,8 +2746,7 @@ function finalResponseEarlyValidation({ conversationId, projectId, routeName, re
checks: {
inspectFound: false,
listFound: false,
resultHasFinalText: false,
fallbackTextAbsent: false
resultHasFinalText: false
},
httpStatus: response?.status,
error: response?.body?.error && typeof response.body.error === "object"
@@ -2808,19 +2760,12 @@ function finalResponseEarlyValidation({ conversationId, projectId, routeName, re
};
}
function finalResponseValidation({ conversationId, projectId, inspectConversation, listedConversation, traceId, resultBody, fallbackText }: any) {
function finalResponseValidation({ conversationId, projectId, inspectConversation, listedConversation, traceId, resultBody }: any) {
const inspectedLatest = latestAgentMessage(inspectConversation);
const listedLatest = latestAgentMessage(listedConversation);
const inspectText = text(inspectedLatest?.text);
const listText = text(listedLatest?.text);
const resultText = assistantText(resultBody) || "";
const fallback = text(fallbackText);
const textPairs = [
{ source: "inspect", text: inspectText },
{ source: "list", text: listText },
{ source: "result", text: resultText }
];
const fallbackHits = fallback ? textPairs.filter((item) => item.text.includes(fallback)).map((item) => item.source) : [];
const failures = [
!inspectConversation ? "inspect-conversation-missing" : "",
!listedConversation ? "list-conversation-missing" : "",
@@ -2831,8 +2776,7 @@ function finalResponseValidation({ conversationId, projectId, inspectConversatio
inspectedLatest && text(inspectedLatest.traceId) !== traceId ? "inspect-latest-agent-trace-mismatch" : "",
listedLatest && text(listedLatest.traceId) !== traceId ? "list-latest-agent-trace-mismatch" : "",
inspectText && resultText && inspectText !== resultText ? "inspect-text-result-mismatch" : "",
listText && resultText && listText !== resultText ? "list-text-result-mismatch" : "",
fallbackHits.length > 0 ? "fallback-text-present" : ""
listText && resultText && listText !== resultText ? "list-text-result-mismatch" : ""
].filter(Boolean);
return {
ok: failures.length === 0,
@@ -2852,10 +2796,8 @@ function finalResponseValidation({ conversationId, projectId, inspectConversatio
inspectLatestAgentTraceMatches: inspectedLatest ? text(inspectedLatest.traceId) === traceId : false,
listLatestAgentTraceMatches: listedLatest ? text(listedLatest.traceId) === traceId : false,
inspectTextMatchesResult: Boolean(inspectText && resultText && inspectText === resultText),
listTextMatchesResult: Boolean(listText && resultText && listText === resultText),
fallbackTextAbsent: fallbackHits.length === 0
listTextMatchesResult: Boolean(listText && resultText && listText === resultText)
},
fallbackText: fallback ? { checked: true, presentIn: fallbackHits } : { checked: false, presentIn: [] },
repair: clean({
inspectSnapshotSource: text(inspectConversation?.snapshot?.source),
listSnapshotSource: text(listedConversation?.snapshot?.source),
@@ -3141,8 +3083,8 @@ function traceBodyForCli(body: any, parsed: ParsedArgs) {
function webTraceRenderBody(traceObject: any, parsed: ParsedArgs) {
const events = Array.isArray(traceObject?.events) ? traceObject.events : [];
const rows = traceDisplayRows(traceObject ?? {}, events);
const fallbackRows = rows.length > 0 ? [] : persistentTraceFallbackRows(traceObject);
const allRows = rows.length > 0 ? rows : fallbackRows;
const terminalRows = rows.length > 0 ? [] : terminalTraceEvidenceRows(traceObject);
const allRows = rows.length > 0 ? rows : terminalRows;
const noiseEventCount = traceNoiseEventCount(events);
const rowLimit = Math.max(1, Math.min(numberOption(parsed.limit) ?? 80, 500));
const rowTail = parsed.full === true ? allRows : allRows.slice(-rowLimit);
@@ -3165,35 +3107,35 @@ function webTraceRenderBody(traceObject: any, parsed: ParsedArgs) {
assistantText: assistantText(traceObject),
rows: rowTail.map(compactTraceRenderRow),
retention: traceObject?.retention,
fallback: traceObject?.fallback,
terminalEvidence: traceObject?.terminalEvidence,
finalResponse: traceObject?.finalResponse,
traceSummary: traceObject?.traceSummary,
fullBodyAvailable: true
});
}
function persistentTraceFallbackRows(traceObject: any) {
const fallback = traceObject?.fallback && typeof traceObject.fallback === "object" ? traceObject.fallback : null;
const finalResponse = traceObject?.finalResponse ?? fallback?.finalResponse;
const traceSummary = traceObject?.traceSummary ?? fallback?.traceSummary;
function terminalTraceEvidenceRows(traceObject: any) {
const evidence = traceObject?.terminalEvidence && typeof traceObject.terminalEvidence === "object" ? traceObject.terminalEvidence : null;
const finalResponse = traceObject?.finalResponse ?? evidence?.finalResponse;
const traceSummary = traceObject?.traceSummary ?? evidence?.traceSummary;
const textValue = text(finalResponse?.text ?? finalResponse?.textPreview ?? traceSummary?.finalAssistantRow?.textPreview);
if (!fallback?.available && !textValue && !traceSummary) return [];
if (!evidence?.available && !textValue && !traceSummary) return [];
const body = [
textValue ? `final assistant: ${textValue}` : "final assistant: unavailable in persisted snapshot",
`source: ${text(traceSummary?.source ?? fallback?.source ?? "agent-session-snapshot")}`,
textValue ? `final assistant: ${textValue}` : "final assistant: unavailable in AgentRun terminal evidence",
`source: ${text(traceSummary?.source ?? evidence?.source ?? "agentrun-command-result")}`,
`terminalStatus: ${text(traceSummary?.terminalStatus ?? finalResponse?.status ?? traceObject?.status ?? "unknown")}`,
`sourceEventCount: ${traceSummary?.sourceEventCount ?? traceObject?.eventCount ?? 0}`,
fallback?.conversationId ? `conversationId: ${fallback.conversationId}` : null,
fallback?.sessionId ? `sessionId: ${fallback.sessionId}` : null,
fallback?.threadId ? `threadId: ${fallback.threadId}` : null,
fallback?.agentRun?.runId ? `runId: ${fallback.agentRun.runId}` : null,
fallback?.agentRun?.commandId ? `commandId: ${fallback.agentRun.commandId}` : null
evidence?.conversationId ? `conversationId: ${evidence.conversationId}` : null,
evidence?.sessionId ? `sessionId: ${evidence.sessionId}` : null,
evidence?.threadId ? `threadId: ${evidence.threadId}` : null,
evidence?.agentRun?.runId ? `runId: ${evidence.agentRun.runId}` : null,
evidence?.agentRun?.commandId ? `commandId: ${evidence.agentRun.commandId}` : null
].filter(Boolean).join("\n");
return [{
rowId: `${traceObject?.traceId ?? "trace"}:persisted-summary`,
rowId: `${traceObject?.traceId ?? "trace"}:terminal-evidence`,
seq: null,
tone: "info",
header: "历史 trace 已过期,显示持久化摘要",
header: "历史 trace 已过期,显示 AgentRun 终态证据",
bodyFormat: "text",
terminal: true,
body
@@ -3217,7 +3159,7 @@ function traceResponseAliases(traceBody: any, parsed: ParsedArgs) {
eventCountMismatch: traceBody.eventCountMismatch,
assistantText: traceBody.assistantText,
retention: traceBody.retention,
fallback: traceBody.fallback,
terminalEvidence: traceBody.terminalEvidence,
finalResponse: traceBody.finalResponse,
traceSummary: traceBody.traceSummary
});
@@ -3317,7 +3259,7 @@ function compactAgentRun(value: any) {
}
function assistantText(body: any) {
const direct = text(body?.reply?.content ?? body?.message?.content ?? body?.assistantText ?? body?.finalResponse?.text ?? body?.fallback?.finalResponse?.text);
const direct = text(body?.reply?.content ?? body?.message?.content ?? body?.assistantText ?? body?.finalResponse?.text ?? body?.terminalEvidence?.finalResponse?.text);
if (direct) return direct;
if (Array.isArray(body?.assistantStreams)) {
for (const item of [...body.assistantStreams].reverse()) {
@@ -3464,7 +3406,7 @@ function compactBody(body: any) {
blocker: body.blocker,
summary: body.summary,
retention: body.retention,
fallback: body.fallback,
terminalEvidence: body.terminalEvidence,
finalResponse: body.finalResponse,
traceSummary: body.traceSummary,
targetId: body.targetId,
@@ -64,15 +64,15 @@ test("message trace panel collapses automatically when final response is availab
assert.doesNotMatch(html, /<details[^>]* open=""/u);
});
test("message trace panel renders persisted fallback for expired historical trace", () => {
test("message trace panel renders AgentRun terminal evidence for expired historical trace", () => {
const trace: RunnerTrace = {
traceId: "trc_issue932_expired",
status: "expired",
traceStatus: "expired",
events: [],
eventCount: 31,
fallback: { available: true, source: "agent-session-snapshot" },
traceSummary: { sourceEventCount: 31, terminalStatus: "completed", source: "agent-session-snapshot" },
terminalEvidence: { available: true, source: "agentrun-command-result" },
traceSummary: { sourceEventCount: 31, terminalStatus: "completed", source: "agentrun-command-result" },
finalResponse: { text: "历史最终回复" }
};
@@ -80,6 +80,7 @@ test("message trace panel renders persisted fallback for expired historical trac
assert.match(html, / trace /u);
assert.match(html, //u);
assert.match(html, /AgentRun /u);
assert.match(html, /sourceEventCount=31/u);
assert.doesNotMatch(html, //u);
});
@@ -51,7 +51,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey, collapseWhen
const last = summarizeLastEvent(trace);
const rows = useMemo<TraceEventRow[]>(() => {
const eventRows = traceDisplayRows(trace, events);
return eventRows.length > 0 ? eventRows : persistentTraceFallbackRows(trace);
return eventRows.length > 0 ? eventRows : terminalTraceEvidenceRows(trace);
}, [trace, events]);
const noiseCount = useMemo<number>(() => traceNoiseEventCount(events), [events]);
const lastUpdatedAt = traceUpdatedAt(trace, events, last.ts);
@@ -119,34 +119,34 @@ function traceCountText(trace: RunnerTrace, events: number, rows: number, noiseC
return `${rows}/${events}/${rawTotal}${noise}`;
}
function persistentTraceFallbackRows(trace: RunnerTrace): TraceEventRow[] {
const fallback = objectOrNull(trace.fallback);
function terminalTraceEvidenceRows(trace: RunnerTrace): TraceEventRow[] {
const evidence = objectOrNull(trace.terminalEvidence);
const summary = objectOrNull(trace.traceSummary);
const response = objectOrNull(trace.finalResponse);
const text = firstNonEmptyTraceText(response?.text, response?.content, response?.message, summary?.finalText, summary?.text);
const sourceCount = positiveInteger(summary?.sourceEventCount ?? summary?.eventCount ?? trace.eventCount);
if (!text && fallback?.available !== true && sourceCount === 0) return [];
if (!text && evidence?.available !== true && sourceCount === 0) return [];
const status = firstNonEmptyTraceText(trace.traceStatus, trace.status, summary?.terminalStatus, objectOrNull(trace.agentRun)?.terminalStatus) ?? "expired";
const expired = isExpiredTraceStatus(trace.traceStatus, trace.status) || isExpiredTraceFallback(fallback);
const expired = isExpiredTraceStatus(trace.traceStatus, trace.status) || isExpiredTerminalEvidence(evidence);
const header = expired
? `${trace.traceId ?? "trace"} 历史 trace 已过期,显示持久化摘要`
: `${trace.traceId ?? "trace"} 完整 trace 回放中,先显示持久化摘要`;
? `${trace.traceId ?? "trace"} 历史 trace 已过期,显示 AgentRun 终态证据`
: `${trace.traceId ?? "trace"} 完整 trace 回放中,先显示 AgentRun 终态证据`;
const meta = [
`traceStatus=${status}`,
sourceCount > 0 ? `sourceEventCount=${sourceCount}` : null,
firstNonEmptyTraceText(fallback?.source, summary?.source) ? `source=${firstNonEmptyTraceText(fallback?.source, summary?.source)}` : null
firstNonEmptyTraceText(evidence?.source, summary?.source) ? `source=${firstNonEmptyTraceText(evidence?.source, summary?.source)}` : null
].filter(Boolean).join("\n");
const body = [meta, text].filter(Boolean).join("\n\n") || (expired ? "后端没有保留原始 event,但已返回持久化 trace 摘要。" : "后端声明存在原始 event,正在通过 trace 回放入口补齐完整事件列表。");
return [{ rowId: `fallback:${trace.traceId ?? "trace"}`, seq: null, tone: "source", header, body, terminal: true, bodyFormat: text ? "markdown" : "text" }];
const body = [meta, text].filter(Boolean).join("\n\n") || (expired ? "后端没有保留原始 event,但 AgentRun command result 已返回终态证据。" : "后端声明存在原始 event,正在通过 trace 回放入口补齐完整事件列表。");
return [{ rowId: `terminal:${trace.traceId ?? "trace"}`, seq: null, tone: "source", header, body, terminal: true, bodyFormat: text ? "markdown" : "text" }];
}
function isExpiredTraceStatus(...values: unknown[]): boolean {
return values.some((value) => ["expired", "missing"].includes(String(value ?? "").trim().toLowerCase()));
}
function isExpiredTraceFallback(fallback: Record<string, unknown> | null): boolean {
const store = String(fallback?.liveTraceStore ?? "").trim().toLowerCase();
const source = String(fallback?.source ?? "").trim().toLowerCase();
function isExpiredTerminalEvidence(evidence: Record<string, unknown> | null): boolean {
const store = String(evidence?.liveTraceStore ?? "").trim().toLowerCase();
const source = String(evidence?.source ?? "").trim().toLowerCase();
return store.includes("expired") || source.includes("expired");
}
@@ -2,7 +2,7 @@ import type { AgentChatReply, AgentRunProvenance, CodeAgentAvailability, LiveSur
import { firstNonEmptyString, jsonPreview, nonEmptyString, shortToken } from "../utils";
export interface CodeAgentStatusSummary {
kind: "long-lived-session" | "blocked" | "skill-cli-api-blocked" | "read-only-session-tools" | "stateless-one-shot" | "fallback-text-chat-only" | "degraded" | "unverified";
kind: "long-lived-session" | "blocked" | "skill-cli-api-blocked" | "read-only-session-tools" | "stateless-one-shot" | "degraded" | "unverified";
tone: "ok" | "warn" | "blocked" | "source" | "dev-live";
label: string;
icon: string;
@@ -384,7 +384,7 @@ export function codeAgentSummaryRows(summary: CodeAgentStatusSummary): { key: st
function sessionSummaryTone(summary: CodeAgentStatusSummary): CodeAgentStatusSummary["tone"] {
if (summary.kind === "long-lived-session") return "ok";
if (summary.kind === "blocked") return "blocked";
if (["read-only-session-tools", "stateless-one-shot", "fallback-text-chat-only", "degraded"].includes(summary.kind)) return "warn";
if (["read-only-session-tools", "stateless-one-shot", "degraded"].includes(summary.kind)) return "warn";
return "source";
}
@@ -397,4 +397,4 @@ export function summarizeLastEvent(trace: RunnerTrace | null | undefined): { lab
const status = String(last?.status ?? "");
const tone: CodeAgentStatusSummary["tone"] = status === "completed" ? "ok" : status === "running" || status === "pending" ? "warn" : status === "failed" ? "blocked" : "source";
return { label: lastLabel, ts, tone };
}
}
@@ -128,11 +128,11 @@ test("mergeTraceResults prefers persisted finalResponse text for expired or comp
const finalMarkdown = "**结论**\n\n- 自然结束和刷新后都应该显示这一段。\n- Markdown 换行必须保留。";
const terminal: AgentChatResultResponse = {
status: "completed",
traceId: "trc_issue959_fallback",
traceId: "trc_issue959_terminal",
assistantText: "自然运行时的压平文本: **结论** - 自然结束和刷新后都应该显示这一段。"
};
const trace: TraceSnapshot = {
traceId: "trc_issue959_fallback",
traceId: "trc_issue959_terminal",
status: "completed",
events: [],
eventCount: 63,
@@ -514,7 +514,7 @@ test("fetchTraceSnapshot and replayFullTrace use the active workbench project id
}
});
test("fetchTraceSnapshot accepts expired trace fallback without waiting for raw events", async () => {
test("fetchTraceSnapshot accepts expired AgentRun terminal evidence without waiting for raw events", async () => {
const originalFetch = globalThis.fetch;
const fetches: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
@@ -523,20 +523,20 @@ test("fetchTraceSnapshot accepts expired trace fallback without waiting for raw
return jsonResponse({
status: "completed",
traceStatus: "expired",
traceId: "trc_expired_fallback",
traceId: "trc_expired_terminal",
events: [],
eventCount: 0,
fallback: { available: true, source: "agent-session-snapshot" },
traceSummary: { sourceEventCount: 31, terminalStatus: "completed" },
terminalEvidence: { available: true, source: "agentrun-command-result" },
traceSummary: { source: "agentrun-command-result", sourceEventCount: 31, terminalStatus: "completed" },
finalResponse: { text: "过期 trace 的最终回复" }
});
}) as typeof fetch;
try {
const snapshot = await fetchTraceSnapshot("trc_expired_fallback", "trc_expired_fallback", 1000);
const snapshot = await fetchTraceSnapshot("trc_expired_terminal", "trc_expired_terminal", 1000);
assert.equal(snapshot?.status, "expired");
assert.equal(snapshot?.eventCount, 31);
assert.equal((snapshot?.fallback as { source?: string })?.source, "agent-session-snapshot");
assert.equal((snapshot?.terminalEvidence as { source?: string })?.source, "agentrun-command-result");
assert.equal((snapshot?.finalResponse as { text?: string })?.text, "过期 trace 的最终回复");
assert.equal(fetches.length, 1);
} finally {
@@ -20,7 +20,7 @@ export interface TraceSnapshot {
agentRun?: AgentRunProvenance;
traceStatus?: string;
retention?: unknown;
fallback?: unknown;
terminalEvidence?: unknown;
finalResponse?: unknown;
traceSummary?: unknown;
lastEventLabel?: string;
@@ -77,7 +77,7 @@ export function snapshotToRunnerTrace(snapshot: TraceSnapshot): NonNullable<Chat
eventsCompacted: snapshot.eventsCompacted,
traceStatus: snapshot.traceStatus,
retention: snapshot.retention,
fallback: snapshot.fallback,
terminalEvidence: snapshot.terminalEvidence,
finalResponse: snapshot.finalResponse,
traceSummary: snapshot.traceSummary,
agentRun: snapshot.agentRun,
@@ -116,7 +116,7 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
eventCount: trace.eventCount ?? events.length,
traceStatus: trace.traceStatus ?? terminal.traceStatus,
retention: trace.retention ?? terminal.retention,
fallback: trace.fallback ?? terminal.fallback,
terminalEvidence: trace.terminalEvidence ?? terminal.terminalEvidence,
finalResponse: trace.finalResponse ?? terminal.finalResponse,
traceSummary: trace.traceSummary ?? terminal.traceSummary,
lastEventLabel: trace.lastEventLabel ?? terminal.lastEventLabel ?? events.at(-1)?.label
@@ -357,7 +357,7 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
agentRun: tracePolled.data.agentRun,
traceStatus: typeof tracePolled.data.traceStatus === "string" ? tracePolled.data.traceStatus : undefined,
retention: tracePolled.data.retention,
fallback: tracePolled.data.fallback,
terminalEvidence: tracePolled.data.terminalEvidence,
finalResponse: tracePolled.data.finalResponse,
traceSummary: tracePolled.data.traceSummary,
lastEventLabel: String(lastEvent?.label ?? lastEvent?.type ?? tracePolled.data.lastEventLabel ?? ""),
@@ -426,7 +426,7 @@ export async function fetchTraceSnapshot(traceId: string, traceUrlOrId: string,
if (signal?.aborted) return null;
if (tracePolled.ok && tracePolled.data) {
const snapshot = snapshotFromTracePoll(traceId, tracePolled.data);
if (hasTraceEvents(snapshot) || hasPersistedTraceFallback(snapshot)) return snapshot;
if (hasTraceEvents(snapshot) || hasTerminalTraceEvidence(snapshot)) return snapshot;
}
if (attempts >= maxAttempts || Date.now() - startedAt >= totalTimeoutMs) break;
await new Promise<void>((resolve) => window.setTimeout(resolve, Math.min(TRACE_SNAPSHOT_RETRY_INTERVAL_MS, totalTimeoutMs - (Date.now() - startedAt))));
@@ -450,11 +450,11 @@ function hasTraceEvents(snapshot: TraceSnapshot): boolean {
return Array.isArray(snapshot.events) && snapshot.events.length > 0;
}
function hasPersistedTraceFallback(snapshot: TraceSnapshot): boolean {
const fallback = objectOrNull(snapshot.fallback);
function hasTerminalTraceEvidence(snapshot: TraceSnapshot): boolean {
const evidence = objectOrNull(snapshot.terminalEvidence);
const summary = objectOrNull(snapshot.traceSummary);
return Boolean(
fallback?.available === true ||
evidence?.available === true ||
finalResponseText(snapshot.finalResponse) ||
positiveNumber(summary?.sourceEventCount ?? summary?.eventCount) > 0
);
@@ -472,7 +472,7 @@ function snapshotToTraceSnapshot(result: AgentChatResultResponse): TraceSnapshot
eventCount: traceSnapshotEventCount(result, events),
traceStatus: typeof result.traceStatus === "string" ? result.traceStatus : undefined,
retention: result.retention,
fallback: result.fallback,
terminalEvidence: result.terminalEvidence,
finalResponse: result.finalResponse,
traceSummary: result.traceSummary,
agentRun: result.agentRun,