fix(v02): repair final response status (#837)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 17:45:25 +08:00
committed by GitHub
parent 62a99084d7
commit be557c09bc
6 changed files with 168 additions and 5 deletions
+55
View File
@@ -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({
+34 -3
View File
@@ -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");
+19
View File
@@ -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({
+2 -1
View File
@@ -33,6 +33,7 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, 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 {
+39 -1
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>): 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<WorkspaceRecord | null> {
if (current?.workspaceId) return current;
const response = await api.workspace(WORKBENCH_PROJECT_ID);
@@ -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>): WorkbenchState {
return {
workspace: workspace("cnv_active", null),