diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 5361b741..a0461814 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1860,6 +1860,80 @@ test("cloud api exposes final response for legacy progress-polluted snapshots (# } }); +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("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 95384401..3d69ce49 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1888,9 +1888,11 @@ function mergeAgentSessionOwnerEvidence(nextValue, existingValue) { function normalizeFinalResponseConversationMessages(messages, finalResponse) { const finalText = textOr(finalResponse?.text, ""); const traceId = safeTraceIdLocal(finalResponse?.traceId); - if (!Array.isArray(messages) || !finalText || !traceId) return messages; + if (!Array.isArray(messages) || !finalText) return messages; return messages.map((message) => { - if (message?.role !== "agent" || safeTraceIdLocal(message.traceId) !== traceId) return message; + if (message?.role !== "agent") return message; + const sameTrace = traceId && safeTraceIdLocal(message.traceId) === traceId; + if (!sameTrace && !isFoldedProgressBeforeFinalResponse(message.text, finalText)) return message; return redactConversationMessage({ ...message, text: finalText, @@ -1899,6 +1901,16 @@ 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); + if (index <= 0) return false; + const prefix = text.slice(0, index).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 mergeAgentSessionOwnerMessages(nextMessages = [], existingMessages = [], context = {}) { const next = dedupeConversationMessages(nextMessages); if (next.some((message) => message.role === "user")) return ensureTerminalUserMessage(next, context).slice(-50);