diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 6001e9f5..618bb97b 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -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目前可用的 HWPOD:0 个。"; - 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({ diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index b0487e1f..608613f4 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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 { diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 3aa8cb60..72b51276 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -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}`, diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 9f02819a..65ac0a93 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -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 }; } diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index ec51d458..c46e2ced 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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); }); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 73f26884..7d99b656 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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, diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx index 5f6cc3f8..a6f7ef3d 100644 --- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx @@ -64,15 +64,15 @@ test("message trace panel collapses automatically when final response is availab assert.doesNotMatch(html, /]* 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); }); diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx index 671c9287..244d73dc 100644 --- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx @@ -51,7 +51,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey, collapseWhen const last = summarizeLastEvent(trace); const rows = useMemo(() => { const eventRows = traceDisplayRows(trace, events); - return eventRows.length > 0 ? eventRows : persistentTraceFallbackRows(trace); + return eventRows.length > 0 ? eventRows : terminalTraceEvidenceRows(trace); }, [trace, events]); const noiseCount = useMemo(() => 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 | null): boolean { - const store = String(fallback?.liveTraceStore ?? "").trim().toLowerCase(); - const source = String(fallback?.source ?? "").trim().toLowerCase(); +function isExpiredTerminalEvidence(evidence: Record | null): boolean { + const store = String(evidence?.liveTraceStore ?? "").trim().toLowerCase(); + const source = String(evidence?.source ?? "").trim().toLowerCase(); return store.includes("expired") || source.includes("expired"); } diff --git a/web/hwlab-cloud-web/src/state/code-agent-status.ts b/web/hwlab-cloud-web/src/state/code-agent-status.ts index f7c6175d..ac297642 100644 --- a/web/hwlab-cloud-web/src/state/code-agent-status.ts +++ b/web/hwlab-cloud-web/src/state/code-agent-status.ts @@ -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 }; -} \ No newline at end of file +} diff --git a/web/hwlab-cloud-web/src/state/runner-trace.test.ts b/web/hwlab-cloud-web/src/state/runner-trace.test.ts index 2e3e294a..c6a08dbe 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts @@ -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 { diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index 3dca1854..6ea76900 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -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= maxAttempts || Date.now() - startedAt >= totalTimeoutMs) break; await new Promise((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,