diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 859b98fc..02d9949c 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1740,6 +1740,61 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac } }); +test("cloud api exposes terminal conversation status when stored session status is stale running", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + now: () => "2026-06-04T09:30: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", password: "alice-pass" }, adminLogin.cookie); + assert.equal(aliceCreate.status, 201); + const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue834", password: "alice-pass" }); + + const stored = await putJson(port, "/v1/agent/conversations/cnv_issue834_final", { + projectId: "prj_device_pod_workbench", + sessionId: "ses_issue834_final", + threadId: "thread-issue-834", + sessionStatus: "running", + lastTraceId: "trc_issue834_stale_running", + messages: [ + { id: "msg_issue834_user", role: "user", title: "用户", text: "final response 显示异常", status: "sent", conversationId: "cnv_issue834_final", sessionId: "ses_issue834_final", threadId: "thread-issue-834" }, + { id: "msg_issue834_agent", role: "agent", title: "Agent", text: "最终回复已经生成。", status: "completed", traceId: "trc_issue834_final", conversationId: "cnv_issue834_final", sessionId: "ses_issue834_final", threadId: "thread-issue-834" } + ] + }, aliceLogin.cookie); + assert.equal(stored.status, 200); + assert.equal(stored.body.conversation.status, "completed"); + assert.equal(stored.body.conversation.session.status, "completed"); + assert.equal(stored.body.conversation.lastTraceId, "trc_issue834_final"); + + const direct = await getJson(port, "/v1/agent/conversations/cnv_issue834_final", aliceLogin.cookie); + assert.equal(direct.status, 200); + assert.equal(direct.body.conversation.status, "completed"); + assert.equal(direct.body.conversation.session.status, "completed"); + assert.equal(direct.body.conversation.lastTraceId, "trc_issue834_final"); + assert.equal(direct.body.conversation.messages[1].text, "最终回复已经生成。"); + + const list = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(list.status, 200); + const listed = list.body.conversations.find((conversation) => conversation.conversationId === "cnv_issue834_final"); + assert.ok(listed, "expected stale-running conversation to be listed"); + assert.equal(listed.status, "completed"); + assert.equal(listed.session.status, "completed"); + assert.equal(listed.lastTraceId, "trc_issue834_final"); + } 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 e0dfb79f..4f7050b2 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2549,19 +2549,21 @@ function conversationsFromAgentSessions(sessions = []) { function publicAgentConversation(session) { const snapshot = normalizeObject(session.session); const messages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; + const status = resolvedConversationStatus(session.status, snapshot, messages); + const lastTraceId = resolvedConversationLastTraceId(session.lastTraceId, status, messages, snapshot); return { conversationId: session.conversationId, sessionId: session.id, threadId: session.threadId, - status: session.status, + status, projectId: session.projectId, agentId: session.agentId, ownerUserId: session.ownerUserId, - lastTraceId: session.lastTraceId, + lastTraceId, updatedAt: session.updatedAt, startedAt: session.startedAt, endedAt: session.endedAt, - session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status: session.status }), + session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status }), messages, messageCount: numberOrNull(snapshot.messageCount) ?? messages.length, firstUserMessagePreview: textOr(snapshot.firstUserMessagePreview, null), @@ -2574,6 +2576,35 @@ function publicAgentConversation(session) { valuesRedacted: true }; } + +function resolvedConversationStatus(status, snapshot, messages = []) { + const stored = textOr(status, "").toLowerCase(); + const snapshotStatus = textOr(snapshot.sessionStatus ?? snapshot.status, "").toLowerCase(); + const latestAgent = latestAgentConversationMessage(messages); + const latestStatus = textOr(latestAgent?.status, "").toLowerCase(); + if (["running", "busy", "pending", "active"].includes(stored)) { + if (snapshotStatus && !["running", "busy", "pending", "active"].includes(snapshotStatus)) return snapshotStatus === "cancelled" ? "canceled" : snapshotStatus; + if (latestStatus && !["running", "busy", "pending", "active"].includes(latestStatus)) return latestStatus === "cancelled" ? "canceled" : latestStatus; + } + return stored || snapshotStatus || latestStatus || "idle"; +} + +function resolvedConversationLastTraceId(storedTraceId, status, messages = [], snapshot = {}) { + const latestTraceId = textOr(latestAgentConversationMessage(messages)?.traceId, ""); + const snapshotTraceId = textOr(snapshot.lastTraceId, ""); + if (!["running", "busy", "pending", "active"].includes(textOr(status, "").toLowerCase())) { + return latestTraceId || snapshotTraceId || storedTraceId; + } + return textOr(storedTraceId, "") || latestTraceId || snapshotTraceId; +} + +function latestAgentConversationMessage(messages = []) { + if (!Array.isArray(messages)) return null; + for (const message of [...messages].reverse()) { + if (String(message?.role ?? "").toLowerCase() === "agent") return message; + } + return null; +} function boundedText(value, maxBytes) { const text = String(value ?? ""); const buffer = Buffer.from(text, "utf8"); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 9770ea87..03c8274c 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -1402,6 +1402,25 @@ test("hwlab-cli client agent trace can render with the Web trace row path", asyn assert.equal(JSON.stringify(result.payload.body.rows).includes("session:reused"), false); }); +test("hwlab-cli Web trace render does not treat AgentRun result-ready as final assistant text", async () => { + const result = await runHwlabCli(["client", "agent", "trace", "trc_result_ready", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { + fetchImpl: async () => new Response(JSON.stringify({ + status: "completed", + traceId: "trc_result_ready", + events: [ + { traceId: "trc_result_ready", seq: 1, label: "agentrun:request:accepted", status: "running", type: "request", createdAt: "2026-06-01T13:00:00.000Z" }, + { traceId: "trc_result_ready", seq: 2, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, message: "AgentRun result is ready for HWLAB short-connection polling.", createdAt: "2026-06-01T13:00:02.000Z" } + ] + }), { status: 200 }) + }); + + assert.equal(result.exitCode, 0); + const text = JSON.stringify(result.payload.body.rows); + assert.equal(text.includes("助手最后一条消息"), false); + assert.match(text, /轮次完成/u); + assert.equal(text.includes("AgentRun result is ready for HWLAB short-connection polling"), false); +}); + test("hwlab-cli Web trace render reports suppressed noise and command tool details", async () => { const result = await runHwlabCli(["client", "agent", "trace", "trc_render_agentrun", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { fetchImpl: async () => new Response(JSON.stringify({ diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 0fb8cdd2..f57f833c 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -33,6 +33,7 @@ export function traceDisplayRows(trace: Record = {}, events: Tr continue; } if (isTerminalAssistantTraceEvent(event)) { + if (pendingAssistantEvent && pendingAssistantEvent !== event) rows.push(traceDisplayRow(trace, pendingAssistantEvent)); pendingAssistantEvent = event; continue; } @@ -150,7 +151,7 @@ function isTerminalAssistantTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); if (label === "assistant:completed") return true; if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true; - return event.type === "assistant_message" && (event.status === "completed" || event.final === true); + return event.type === "assistant_message" && (event.status === "completed" || event.final === true || event.terminal === true); } function isNoisyTraceEvent(event: TraceEvent): boolean { diff --git a/web/hwlab-cloud-web/src/state/conversation.ts b/web/hwlab-cloud-web/src/state/conversation.ts index 270bf51e..4368a8d4 100644 --- a/web/hwlab-cloud-web/src/state/conversation.ts +++ b/web/hwlab-cloud-web/src/state/conversation.ts @@ -23,6 +23,7 @@ export function messageFromAgentResponse(messageId: string, pending: ChatMessage replyText, response.reply, response.text, + terminalAssistantText(response), response.summary, errorObject?.message, typeof response.error === "string" ? response.error : null @@ -32,7 +33,7 @@ export function messageFromAgentResponse(messageId: string, pending: ChatMessage ...pending, id: messageId, title: failed ? "Code Agent 返回阻塞" : normalizedStatus === "running" ? "Code Agent 处理中" : "Code Agent 回复", - text: text ?? (failed ? "后端没有返回可显示正文。" : "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。"), + text: text ?? (failed ? "后端没有返回可显示正文。" : "Code Agent 已完成,但本次结果缺少可显示的 final response;请回放 trace 或重试。"), status: normalizedStatus, updatedAt: new Date().toISOString(), traceId: response.traceId ?? pending.traceId, @@ -51,6 +52,43 @@ function replyContentText(reply: AgentChatReply | string | undefined): string | return firstNonEmptyString(reply.content, reply.messageId); } +function terminalAssistantText(response: AgentChatResponse | AgentChatResultResponse): string | null { + const trace = response.runnerTrace && typeof response.runnerTrace === "object" ? response.runnerTrace : null; + const traceStreams = unknownArray(trace?.assistantStreams); + const responseStreams = unknownArray((response as { assistantStreams?: unknown }).assistantStreams); + const streams = traceStreams.length > 0 ? traceStreams : responseStreams; + for (const stream of [...streams].reverse()) { + if (!stream || typeof stream !== "object") continue; + const item = stream as Record; + const text = firstNonEmptyString(item.text, item.lastChunk); + if (text) return text; + } + const responseEvents = unknownArray((response as { events?: unknown }).events); + const traceEvents = unknownArray(response.traceEvents); + const runnerEvents = unknownArray(trace?.events); + const events = responseEvents.length > 0 ? responseEvents : traceEvents.length > 0 ? traceEvents : runnerEvents; + for (const event of [...events].reverse()) { + if (!event || typeof event !== "object") continue; + const item = event as Record; + if (!isTerminalAssistantEvent(item)) continue; + const text = firstNonEmptyString(item.message, item.outputSummary, item.text, item.chunk); + if (text) return text; + } + return null; +} + +function unknownArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function isTerminalAssistantEvent(event: Record): boolean { + const label = String(event.label ?? ""); + const type = String(event.type ?? ""); + if (label === "assistant:completed") return true; + if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true; + return type === "assistant_message" && (event.status === "completed" || event.final === true || event.terminal === true); +} + export async function ensureWorkspace(current: WorkspaceRecord | null): Promise { if (current?.workspaceId) return current; const response = await api.workspace(WORKBENCH_PROJECT_ID); diff --git a/web/hwlab-cloud-web/src/state/workbench-reducer.test.ts b/web/hwlab-cloud-web/src/state/workbench-reducer.test.ts index 870dfd75..42965c3f 100644 --- a/web/hwlab-cloud-web/src/state/workbench-reducer.test.ts +++ b/web/hwlab-cloud-web/src/state/workbench-reducer.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; import { composerFromState, workbenchReducer } from "./workbench-reducer"; +import { messageFromAgentResponse } from "./conversation"; import type { ChatMessage } from "../types/domain"; import type { WorkbenchState } from "./workbench-state"; @@ -100,6 +101,24 @@ test("composer disables only when no session or workspace exists", () => { assert.equal(composer.submitMode, "turn"); }); +test("completed AgentRun trace uses terminal assistant text instead of still-processing fallback", () => { + const pending = agentMessage({ conversationId: "cnv_done", traceId: "trc_done", status: "running" }); + pending.text = "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。"; + + const completed = messageFromAgentResponse(pending.id, pending, { + status: "completed", + traceId: "trc_done", + events: [ + { label: "agentrun:assistant:message", type: "assistant", status: "completed", final: true, replyAuthority: true, message: "最终回答已经生成。" }, + { label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, message: "AgentRun result is ready for HWLAB short-connection polling." } + ] + }); + + assert.equal(completed.status, "completed"); + assert.equal(completed.text, "最终回答已经生成。"); + assert.equal(completed.text.includes("仍在处理"), false); +}); + function baseState(patch: Partial): WorkbenchState { return { workspace: workspace("cnv_active", null),