diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 4cd4bf29..1eb15a2a 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1670,8 +1670,8 @@ function agentRunLongLivedSessionGate(base = {}) { }; } -function agentRunToolCalls(result = {}, status = "completed") { - const replyText = agentRunReplyText(result); +function agentRunToolCalls(result = {}, status = "completed", replyTextOverride = null) { + const replyText = messageAuthorityTextValue(replyTextOverride) || agentRunReplyText(result); return [{ name: "agentrun.v01.command.turn", status, @@ -1701,8 +1701,10 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, a const terminalEventCreatedAt = agentRunResultTraceCreatedAt(runnerTrace, now); const providerTrace = agentRunProviderTrace({ base, result, terminalStatus }); const replyText = agentRunReplyText(result); - if (terminalStatus === "completed" && replyText) { - const finalResponse = agentRunCompletedFinalResponse({ base, result, traceId, now, replyText }); + if (terminalStatus === "completed") { + const traceReplyText = agentRunTraceTerminalAssistantText(runnerTrace); + const completedReplyText = replyText || traceReplyText; + const finalResponse = completedReplyText ? agentRunCompletedFinalResponse({ base, result, traceId, now, replyText: completedReplyText }) : null; const traceSummary = agentRunCompletedTraceSummary({ base, runnerTrace, finalResponse, traceId }); if (appendResultEvent) { traceStore.append(traceId, agentRunTraceEvent({ @@ -1727,18 +1729,18 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, a sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true), runner: agentRunRunnerSummary(base.agentRun), runnerTrace: traceStore.snapshot(traceId, agentRunTraceMeta({}, {})), - toolCalls: agentRunToolCalls(result, "completed"), + toolCalls: agentRunToolCalls(result, "completed", completedReplyText), skills: { status: "delegated", provider: ADAPTER_ID, count: 0, items: [], valuesPrinted: false }, longLivedSessionGate: agentRunLongLivedSessionGate(base), providerTrace, - finalResponse, + finalResponse: finalResponse ?? null, traceSummary, - reply: { + reply: finalResponse ? { messageId: finalResponse.messageId, role: "assistant", content: finalResponse.text, createdAt: finalResponse.createdAt - }, + } : null, usage: null, agentRun: { ...base.agentRun, terminalStatus, completed: true, reuseEligible: true, providerTrace, valuesPrinted: false }, valuesPrinted: false @@ -1831,6 +1833,25 @@ function agentRunCompletedFinalResponse({ base, result, traceId, now, replyText }; } +function agentRunTraceTerminalAssistantText(runnerTrace = {}) { + const events = Array.isArray(runnerTrace?.events) ? runnerTrace.events : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (!event || typeof event !== "object") continue; + if (!isAgentRunAssistantTraceEvent(event)) continue; + if (!(event.terminal === true || event.final === true || event.replyAuthority === true)) continue; + const textValue = messageAuthorityTextValue(event.finalResponse ?? event.text ?? event.content ?? event.message ?? event.summary ?? event.payload?.text ?? event.payload?.content ?? event.payload?.message); + if (textValue) return textValue; + } + return ""; +} + +function isAgentRunAssistantTraceEvent(event = {}) { + const type = String(event.type ?? event.eventType ?? "").trim().toLowerCase(); + if (type === "assistant" || type === "assistant_message") return true; + return /assistant:message|assistant_message/u.test(String(event.label ?? "").toLowerCase()); +} + function agentRunTerminalFailureFinalResponse({ base, traceId, now, status, text }) { const textValue = String(text ?? "").trim(); return { @@ -1880,14 +1901,14 @@ function agentRunCompletedTraceSummary({ base, runnerTrace, finalResponse, trace source: "agentrun-command-result", sourceEventCount: Number(runnerTrace?.eventCount ?? events.length ?? 0), terminalStatus: "completed", - finalAssistantRow: { + finalAssistantRow: finalResponse ? { role: finalResponse.role, status: finalResponse.status, textChars: finalResponse.textChars, textPreview: finalResponse.text.slice(0, 240), messageId: finalResponse.messageId, valuesPrinted: false - }, + } : null, agentRun: { runId: base.agentRun?.runId ?? null, commandId: base.agentRun?.commandId ?? null, diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index d9d9fe29..9776f545 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -1054,6 +1054,116 @@ test("AgentRun sync converts terminal command result even when run remains claim } }); +test("AgentRun sync seals completed final response from authoritative terminal assistant trace event (#1629)", async () => { + const calls = []; + const traceId = "trc_issue1629_terminal_assistant_final"; + const runId = "run_issue1629_trace_final"; + const commandId = "cmd_issue1629_trace_final"; + const finalText = [ + "全部六份数据到手!下面是完整的六语言终极性能对比:", + "", + "| language | runtime | status |", + "|---|---|---|", + "| Lua | LuaJIT | pass |" + ].join("\n"); + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + calls.push({ method: request.method, path: url.pathname, search: url.search }); + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_issue1629" })}\n`); + }; + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands`) { + return send({ items: [ + { id: commandId, runId, state: "completed", type: "turn", seq: 1, idempotencyKey: traceId, payload: { traceId, conversationId: "cnv_issue1629", hwlabSessionId: "ses_issue1629", threadId: "thread_issue1629" } } + ] }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) { + return send({ items: [ + { id: "evt_issue1629_progress", runId, seq: 21, type: "assistant_message", payload: { commandId, text: "正在补 Lua 基准测试。" }, createdAt: "2026-06-19T15:47:13.000Z" }, + { id: "evt_issue1629_final", runId, seq: 28, type: "assistant_message", payload: { commandId, text: finalText, final: true, replyAuthority: true }, createdAt: "2026-06-19T15:47:28.000Z" }, + { id: "evt_issue1629_terminal", runId, seq: 29, type: "terminal_status", payload: { commandId, terminalStatus: "completed" }, createdAt: "2026-06-19T15:47:29.000Z" } + ] }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${commandId}/result`) { + return send({ + runId, + commandId, + attemptId: "attempt_issue1629", + runnerId: "runner_issue1629", + jobName: "agentrun-v01-runner-issue1629", + namespace: "agentrun-v01", + status: "completed", + runStatus: "completed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + reply: null, + finalResponse: null, + lastSeq: 29, + eventCount: 29, + sessionRef: { sessionId: "ses_issue1629", conversationId: "cnv_issue1629", threadId: "thread_issue1629" } + }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + const agentRunPort = agentRunServer.address().port; + const traceStore = createCodeAgentTraceStore(); + try { + const currentResult = { + ok: true, + accepted: true, + shortConnection: true, + status: "running", + traceId, + conversationId: "cnv_issue1629", + sessionId: "ses_issue1629", + threadId: "thread_issue1629", + agentRun: { + adapter: "agentrun-v01", + managerUrl: `http://127.0.0.1:${agentRunPort}`, + runId, + commandId, + status: "running", + runStatus: "running", + commandState: "running", + terminalStatus: null, + lastSeq: 0, + valuesPrinted: false + }, + valuesPrinted: false + }; + const synced = await syncAgentRunChatResult({ + traceId, + currentResult, + traceStore, + options: { + env: { + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1" + } + } + }); + assert.equal(synced.resultSynced, true); + assert.equal(synced.result.status, "completed"); + assert.equal(synced.result.finalResponse.text, finalText); + assert.equal(synced.result.assistantText ?? synced.result.finalResponse.text, finalText); + assert.equal(synced.result.reply.content, finalText); + assert.equal(synced.result.traceSummary.finalAssistantRow.textChars, finalText.length); + const assistantEvent = synced.result.runnerTrace.events.find((event) => event.label === "agentrun:assistant:message" && event.message === finalText); + assert.equal(assistantEvent?.message, finalText); + assert.deepEqual(calls.map((call) => call.path), [ + `/api/v1/runs/${runId}/commands`, + `/api/v1/runs/${runId}/events`, + `/api/v1/runs/${runId}/commands/${commandId}/result` + ]); + } finally { + await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api AgentRun adapter reports persistent thread resume when a completed run needs a new runner", async () => { const calls = []; const hwlabSessionId = "ses_server-test-thread-resume"; diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index 13ea81a5..0690a482 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -58,6 +58,51 @@ test("workbench projection diagnostics keeps projecting health distinct from cau assert.equal(diagnostics.projectionHealth, "projecting"); }); +test("workbench turn projection seals final response from authoritative terminal assistant trace event (#1629)", () => { + const traceId = "trc_workbench_terminal_assistant_final"; + const finalText = [ + "全部六份数据到手!下面是完整的六语言终极性能对比:", + "", + "| language | runtime | status |", + "|---|---|---|", + "| Lua | LuaJIT | pass |" + ].join("\n"); + const trace = { + traceId, + status: "completed", + events: [ + { seq: 1, type: "assistant_message", status: "running", message: "正在补 Lua 基准测试。" }, + { seq: 2, type: "assistant_message", status: "completed", replyAuthority: true, final: true, message: finalText }, + { seq: 3, type: "result", status: "completed", terminal: true, label: "agentrun:terminal:completed" } + ], + eventCount: 3 + }; + const projection = createWorkbenchTurnProjection({ traceId, result: { traceId, status: "completed" }, trace }); + assert.equal(projection.status, "completed"); + assert.equal(projection.terminal, true); + assert.equal(projection.finalResponse.text, finalText); + assert.equal(projection.assistantText, finalText); + assert.equal(projection.terminalEvidence.finalResponse.text, finalText); +}); + +test("workbench turn projection keeps progress-only assistant trace text out of final response (#1629)", () => { + const traceId = "trc_workbench_terminal_without_authoritative_final"; + const trace = { + traceId, + status: "completed", + events: [ + { seq: 1, type: "assistant_message", status: "running", message: "这只是运行中的进度。" }, + { seq: 2, type: "result", status: "completed", terminal: true, label: "agentrun:terminal:completed" } + ], + eventCount: 2 + }; + const projection = createWorkbenchTurnProjection({ traceId, result: { traceId, status: "completed" }, trace }); + assert.equal(projection.status, "completed"); + assert.equal(projection.terminal, true); + assert.equal(projection.finalResponse, null); + assert.equal(projection.assistantText, null); +}); + test("workbench trace event page exposes monotonic cursor range for restored mixed events", async () => { const traceId = "trc_workbench_trace_mixed_cursor"; const session = { diff --git a/internal/cloud/workbench-turn-projection.ts b/internal/cloud/workbench-turn-projection.ts index b8014395..24171421 100644 --- a/internal/cloud/workbench-turn-projection.ts +++ b/internal/cloud/workbench-turn-projection.ts @@ -9,12 +9,13 @@ export const RUNNING_STATUSES = new Set(["running", "pending", "queued", "accept export function createWorkbenchTurnProjection({ turnId = null, traceId = null, result = null, session = null, trace = null } = {}) { const projectionTraceId = textValue(traceId ?? trace?.traceId ?? result?.traceId ?? session?.lastTraceId) || null; const projectionTurnId = textValue(turnId) || projectionTraceId; - const terminalEvidence = terminalTurnEvidence({ result, trace }); + const traceTerminal = traceTerminalEvidence(trace); + const terminalEvidence = terminalTurnEvidence({ result, traceTerminal }); const activeEvidence = activeTurnEvidence({ result, session, trace }); const status = terminalEvidence?.status ?? activeEvidence?.status ?? "unknown"; const running = RUNNING_STATUSES.has(status); const terminal = Boolean(terminalEvidence && TERMINAL_STATUSES.has(status) && !running); - const finalText = terminal ? projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse) : null; + const finalText = terminal ? projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, terminalEvidence?.finalResponse) : null; const agentRun = objectValue(result?.agentRun ?? trace?.agentRun); const lastEvent = traceLastEvent(trace); return { @@ -88,10 +89,10 @@ export function normalizeWorkbenchStatus(value) { return text || "unknown"; } -function terminalTurnEvidence({ result = null, trace = null } = {}) { +function terminalTurnEvidence({ result = null, traceTerminal = null } = {}) { const resultStatus = terminalStatusFromValue(result?.status ?? result?.terminalStatus ?? result?.agentRun?.terminalStatus); - if (resultStatus) return { source: "result", status: resultStatus, valuesRedacted: true }; - return traceTerminalEvidence(trace); + if (resultStatus) return { source: "result", status: resultStatus, finalResponse: traceTerminal?.finalResponse ?? null, valuesRedacted: true }; + return traceTerminal; } function activeTurnEvidence({ result = null, session = null, trace = null } = {}) { @@ -128,7 +129,9 @@ function normalizeActiveStatus(status) { } function terminalTraceEventEvidence(events = []) { - for (const event of [...events].reverse()) { + const finalResponse = terminalAssistantEventFinalResponse(events); + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; if (!event || typeof event !== "object") continue; const terminal = event.terminal === true || event.final === true || event.replyAuthority === true; if (!terminal) continue; @@ -136,14 +139,43 @@ function terminalTraceEventEvidence(events = []) { return { source: "trace-terminal-event", status, - seq: eventSeq(event, events.indexOf(event)), + seq: eventSeq(event, index), eventType: textValue(event.type ?? event.label) || null, + finalResponse, + evidence: finalResponse ? { textPresent: true, source: "trace-terminal-assistant-event", valuesRedacted: true } : null, valuesRedacted: true }; } return null; } +function terminalAssistantEventFinalResponse(events = []) { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (!event || typeof event !== "object") continue; + if (!isAssistantTraceEvent(event)) continue; + if (!(event.terminal === true || event.final === true || event.replyAuthority === true)) continue; + const text = projectionText(event.finalResponse, event.text, event.content, event.message, event.summary, event.payload?.text, event.payload?.content, event.payload?.message); + if (!text) continue; + const status = terminalStatusFromValue(event.status ?? event.terminalStatus ?? event.payload?.terminalStatus) ?? "completed"; + return { + text, + status, + traceId: textValue(event.traceId) || null, + seq: eventSeq(event, index), + eventType: textValue(event.type ?? event.label) || null, + valuesPrinted: false + }; + } + return null; +} + +function isAssistantTraceEvent(event = {}) { + const type = String(event.type ?? event.eventType ?? "").trim().toLowerCase(); + if (type === "assistant" || type === "assistant_message") return true; + return /assistant:message|assistant_message/u.test(String(event.label ?? "").toLowerCase()); +} + function activeTraceEventStatus(events = []) { for (const event of [...events].reverse()) { const status = normalizeWorkbenchStatus(event?.status ?? event?.type); diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 0ad86f0f..6a5d369f 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -78,6 +78,15 @@ const markdownFinalText = [ "|---|---|", "| local | pass |" ].join("\n"); +const terminalAssistantFinalText = [ + "全部六份数据到手!下面是完整的六语言终极性能对比:", + "", + "| language | runtime | status |", + "|---|---|---|", + "| Lua | LuaJIT | pass |", + "| Python | CPython | pass |", + "| Rust | native | pass |" +].join("\n"); let state = createScenarioState("baseline"); const sseClients = new Set(); @@ -383,6 +392,10 @@ function createScenarioState(scenarioId: string): ScenarioState { sessions.unshift(markdownFinalSession()); traces.trc_markdown_final = markdownFinalTrace(); } + if (id === "terminal-assistant-event-final-response") { + sessions.unshift(terminalAssistantFinalSession()); + traces.trc_terminal_assistant_final = terminalAssistantFinalTrace(); + } if (id === "progress-only-final-response") markRunningProgressOnly(sessions, traces); if (id === "terminal-completed-no-final-response") markRunningNoFinalResponse(sessions, traces); if (id === "tool-completed-projection-running") markToolCompletedProjectionRunning(sessions, traces); @@ -431,6 +444,8 @@ function createScenarioState(scenarioId: string): ScenarioState { ? "ses_terminal_empty" : id === "markdown-final-response" ? "ses_markdown_final" + : id === "terminal-assistant-event-final-response" + ? "ses_terminal_assistant_final" : id === "progress-only-final-response" || id === "terminal-completed-no-final-response" || id === "tool-completed-projection-running" ? "ses_running" : id === "projector-resume-from-checkpoint" @@ -972,6 +987,47 @@ function markdownFinalTrace(): JsonRecord { }; } +function terminalAssistantFinalSession(): SessionRecord { + const now = new Date().toISOString(); + const trace = terminalAssistantFinalTrace(); + return { + sessionId: "ses_terminal_assistant_final", + conversationId: "cnv_terminal_assistant_final", + threadId: "thr_terminal_assistant_final", + status: "completed", + lastTraceId: "trc_terminal_assistant_final", + startedAt: now, + updatedAt: now, + messageCount: 2, + firstUserMessagePreview: "再把lua加入对比测试", + messages: [ + { id: "msg_terminal_assistant_user", messageId: "msg_terminal_assistant_user", role: "user", title: "用户", text: "再把lua加入对比测试", status: "sent", createdAt: now, sessionId: "ses_terminal_assistant_final", threadId: "thr_terminal_assistant_final", traceId: "trc_terminal_assistant_final", turnId: "trc_terminal_assistant_final" }, + { id: "msg_terminal_assistant_agent", messageId: "msg_terminal_assistant_agent", role: "agent", title: "Code Agent", text: terminalAssistantFinalText, parts: [{ type: "text", text: terminalAssistantFinalText, status: "completed" }], status: "completed", createdAt: now, updatedAt: now, sessionId: "ses_terminal_assistant_final", threadId: "thr_terminal_assistant_final", traceId: "trc_terminal_assistant_final", turnId: "trc_terminal_assistant_final", runnerTrace: trace, finalResponse: { text: terminalAssistantFinalText, status: "completed" } } + ] + }; +} + +function terminalAssistantFinalTrace(): JsonRecord { + const createdAt = new Date().toISOString(); + const events = [ + { seq: 1, sourceSeq: 21, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "running", replyAuthority: false, final: false, terminal: false, message: "现在写 Lua 基准测试脚本。" }, + { seq: 2, sourceSeq: 28, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", replyAuthority: true, final: true, terminal: true, message: terminalAssistantFinalText }, + { seq: 3, sourceSeq: 29, createdAt, label: "agentrun:terminal:completed", type: "result", status: "completed", terminal: true, message: "AgentRun command completed." } + ]; + return { + traceId: "trc_terminal_assistant_final", + status: "completed", + sessionId: "ses_terminal_assistant_final", + threadId: "thr_terminal_assistant_final", + events, + eventCount: events.length, + fullTraceLoaded: true, + hasMore: false, + assistantText: terminalAssistantFinalText, + finalResponse: { text: terminalAssistantFinalText, status: "completed" } + }; +} + function manyRailSession(index: number): SessionRecord { const id = String(index).padStart(2, "0"); const now = new Date(Date.now() - (index + 1) * 1000).toISOString(); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts index c6915a5d..7a11a843 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts @@ -43,6 +43,41 @@ test.describe("terminal completed without final response", () => { }); }); +test.describe("terminal assistant event final response", () => { + test.use({ scenarioId: "terminal-assistant-event-final-response" }); + + test("renders the sealed Workbench final response from the authoritative terminal assistant event", async ({ page }, testInfo) => { + const sessionId = "ses_terminal_assistant_final"; + const traceId = "trc_terminal_assistant_final"; + const messagesResponse = await page.request.get(`/v1/workbench/sessions/${sessionId}/messages?limit=100`); + expect(messagesResponse.status()).toBe(200); + const messagesPayload = await messagesResponse.json(); + const agentMessage = messagesPayload.messages.find((message: { role?: string }) => message.role === "agent"); + expect(agentMessage?.text).toContain("全部六份数据到手"); + expect(agentMessage?.finalResponse?.text).toBe(agentMessage?.text); + + const turnResponse = await page.request.get(`/v1/workbench/turns/${traceId}`); + expect(turnResponse.status()).toBe(200); + const turnPayload = await turnResponse.json(); + expect(turnPayload.turn.finalResponse.text).toBe(agentMessage?.text); + + const traceResponse = await page.request.get(`/v1/workbench/traces/${traceId}/events?limit=20`); + expect(traceResponse.status()).toBe(200); + const tracePayload = await traceResponse.json(); + const finalEvent = tracePayload.events.find((event: { label?: string; final?: boolean }) => event.label === "agentrun:assistant:message" && event.final === true); + expect(finalEvent?.replyAuthority).toBe(true); + expect(finalEvent?.message).toBe(agentMessage?.text); + + await gotoWorkbench(page, `/workbench/sessions/${sessionId}`); + const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last(); + await expect(card.locator(".message-markdown.message-text")).toContainText("全部六份数据到手"); + await expect(card.locator(".message-markdown.message-text table")).toHaveCount(1); + await card.locator("summary.trace-disclosure-summary").click(); + await expect(card.locator(".trace-row-markdown table")).toHaveCount(1); + await saveScreenshot(page, testInfo, "terminal-assistant-event-final-response"); + }); +}); + test.describe("non-terminal tool completed event", () => { test.use({ scenarioId: "tool-completed-projection-running" });