diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index edcfc4bd..1e46d241 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -279,6 +279,7 @@ function createScenarioState(scenarioId: string): ScenarioState { traces.trc_terminal_empty = terminalEmptyTrace(); } if (id === "progress-only-final-response") markRunningProgressOnly(sessions, traces); + if (id === "terminal-completed-no-final-response") markRunningNoFinalResponse(sessions, traces); if (id === "scroll-follow-long-trace") { const session = scrollFollowSession(); sessions.unshift(session); @@ -301,7 +302,7 @@ function createScenarioState(scenarioId: string): ScenarioState { ? "ses_completed" : id === "terminal-empty-trace" ? "ses_terminal_empty" - : id === "progress-only-final-response" + : id === "progress-only-final-response" || id === "terminal-completed-no-final-response" ? "ses_running" : base.selectedSessionId; const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null; @@ -318,7 +319,7 @@ function createScenarioState(scenarioId: string): ScenarioState { sessionDelayMs: id === "loading" ? 2_500 : 0, sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : 0, chatDelayMs: id === "submit-authority-race" ? 1_000 : 0, - terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore" || id === "progress-only-final-response", + terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore" || id === "progress-only-final-response" || id === "terminal-completed-no-final-response", terminalFailureScript: id === "stale-submit-restore", staleTraceId, liveBackfillTraceId: null, @@ -354,6 +355,45 @@ function markRunningProgressOnly(sessions: SessionRecord[], traces: Record): void { + const trace = noFinalRunningTrace(); + traces.trc_running = trace; + const session = sessions.find((item) => item.sessionId === "ses_running"); + if (!session) return; + session.status = "running"; + session.lastTraceId = "trc_running"; + session.firstUserMessagePreview = "completed turn without final response"; + session.messages = (session.messages ?? []).map((message) => { + if (message.role === "user") return { ...message, text: "completed turn without final response" }; + if (message.role !== "agent" || message.traceId !== "trc_running") return message; + return { ...message, text: "", status: "running", runnerTrace: trace }; + }); +} + +function noFinalRunningTrace(): JsonRecord { + const createdAt = new Date().toISOString(); + return { + traceId: "trc_running", + status: "running", + sessionId: "ses_running", + threadId: "thr_running", + turnId: "turn_running", + events: [{ seq: 1, createdAt, label: "agentrun:backend:turn/running", type: "backend_status", status: "running" }], + eventCount: 1, + fullTraceLoaded: false, + hasMore: false + }; +} + +function completedNoFinalTrace(): JsonRecord { + const createdAt = new Date().toISOString(); + const events = [ + { seq: 1, createdAt, label: "agentrun:backend:turn/running", type: "backend_status", status: "running" }, + { seq: 2, createdAt, label: "agentrun:backend:turn/completed", type: "backend_status", status: "completed", terminal: true } + ]; + return { traceId: "trc_running", status: "completed", sessionId: "ses_running", threadId: "thr_running", turnId: "turn_running", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false }; +} + function scrollFollowSession(): SessionRecord { const now = new Date().toISOString(); const trace = scrollFollowTrace(90); @@ -865,6 +905,14 @@ function sse(request: IncomingMessage, response: ServerResponse, url: URL): void const scenarioId = state.scenarioId; setTimeout(() => { if (state.scenarioId !== scenarioId) return; + if (state.scenarioId === "terminal-completed-no-final-response") { + const trace = completedNoFinalTrace(); + const event = (trace.events as JsonRecord[]).at(-1) ?? { status: "completed", terminal: true }; + writeSse(response, "workbench.trace.event", { type: "trace.event", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", event, snapshot: trace }); + finishRunningNoFinalResponse(trace); + writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", turn: turnPayload("trc_running") }); + return; + } const terminalStatus = state.terminalFailureScript ? "failed" : "completed"; const terminalText = state.terminalFailureScript ? "恢复后失败:缺少受控依赖。" : state.scenarioId === "progress-only-final-response" ? finalOnlyText : "事件重放后完成。"; const event = { seq: 3, createdAt: new Date().toISOString(), label: "agentrun:assistant:message", type: "assistant_message", status: terminalStatus, replyAuthority: true, final: true, message: terminalText, terminal: true }; @@ -895,6 +943,15 @@ function finishRunningSession(status: "completed" | "failed", text: string): voi session.messages = (session.messages ?? []).map((message) => message.role === "agent" ? { ...message, text, status, runnerTrace: state.traces.trc_running } : message); } +function finishRunningNoFinalResponse(trace: JsonRecord = completedNoFinalTrace()): void { + state.traces.trc_running = trace; + const session = sessionById("ses_running"); + if (!session) return; + session.status = "completed"; + session.updatedAt = new Date().toISOString(); + session.messages = (session.messages ?? []).map((message) => message.role === "agent" ? { ...message, text: "", status: "completed", runnerTrace: trace } : message); +} + function stateSummary(): JsonRecord { return { scenarioId: state.scenarioId, selectedSessionId: state.selectedSessionId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, chatRequests: state.chatRequests, staleTraceId: state.staleTraceId, sessions: state.sessions.map((item) => ({ sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId, hidden: item.hidden === true })) }; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 1ba235f7..f3b2b130 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -369,7 +369,16 @@ export const useWorkbenchStore = defineStore("workbench", () => { const replyText = agentReplyText((result as AgentChatResultResponse).reply); const traceAssistantText = assistantTextFromTraceEvents(firstArray((result as AgentChatResultResponse).events, (result as AgentChatResultResponse).traceEvents, runnerTrace?.events)); const text = terminal - ? firstNonEmptyString((result as AgentChatResultResponse).assistantText, finalResponseText((result as AgentChatResultResponse).finalResponse), replyText, errorText, (result as AgentChatResultResponse).text, (result as AgentChatResultResponse).summary, traceAssistantText, message.text, "Code Agent 已完成,但没有返回可展示的 final response。") ?? message.text + ? terminalAgentMessageText({ + status, + assistantText: (result as AgentChatResultResponse).assistantText, + finalText: finalResponseText((result as AgentChatResultResponse).finalResponse), + replyText, + errorText, + resultText: (result as AgentChatResultResponse).text, + summaryText: (result as AgentChatResultResponse).summary, + traceAssistantText + }) : message.text; return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; })); @@ -854,8 +863,17 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return; const resultTrace = recordValue(result.runnerTrace); const traceAssistantText = assistantTextFromTraceEvents(firstArray(result.events, result.traceEvents, resultTrace?.events)); - const text = firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), typeof result.reply === "string" ? result.reply : result.reply?.content, agentErrorDisplayText(result.error), result.text, result.summary, traceAssistantText) ?? "Code Agent 已完成,但没有返回可展示的 final response。"; const terminalStatus = result.status === "completed" ? "completed" : statusFromResult(result.status); + const text = terminalAgentMessageText({ + status: terminalStatus, + assistantText: result.assistantText, + finalText: finalResponseText(result.finalResponse), + replyText: typeof result.reply === "string" ? result.reply : result.reply?.content, + errorText: agentErrorDisplayText(result.error), + resultText: result.text, + summaryText: result.summary, + traceAssistantText + }); updateActiveMessages((source) => source.map((message) => { if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message; const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result); @@ -1083,7 +1101,7 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage { const errorText = agentErrorDisplayText(error); const text = role === "agent" ? isTerminalMessageStatus(status) - ? firstNonEmptyString(finalText, errorText, traceAssistantText, baseText) ?? "" + ? terminalAgentMessageText({ status, finalText, errorText, traceAssistantText, baseText }) : "" : firstNonEmptyString(baseText, finalText, traceAssistantText, errorText) ?? ""; const messageId = firstNonEmptyString((message as Record).messageId, message.id) ?? nextProtocolId("msg"); @@ -1276,6 +1294,34 @@ function agentReplyText(value: AgentChatResultResponse["reply"]): string | null return value && typeof value === "object" ? firstNonEmptyString(value.content) : null; } +function terminalAgentMessageText(input: { + status: unknown; + assistantText?: unknown; + finalText?: string | null; + replyText?: string | null; + errorText?: string | null; + resultText?: unknown; + summaryText?: unknown; + traceAssistantText?: string | null; + baseText?: string | null; +}): string { + const finalText = firstNonEmptyString( + input.assistantText, + input.finalText, + input.replyText, + input.resultText, + input.summaryText, + input.traceAssistantText, + input.baseText + ); + if (!isFailureTerminalStatus(input.status)) return finalText ?? ""; + return firstNonEmptyString(input.errorText, finalText) ?? ""; +} + +function isFailureTerminalStatus(value: unknown): boolean { + return ["failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-")); +} + function messageText(value: unknown): string | null { if (typeof value === "string") return value.trim() || null; if (typeof value === "number" || typeof value === "boolean") return String(value); 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 b3e4939b..76f929a6 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 @@ -18,6 +18,31 @@ test("SSE terminal event and REST gap fill replay to the same terminal UI", asyn await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible(); }); +test.describe("terminal completed without final response", () => { + test.use({ scenarioId: "terminal-completed-no-final-response" }); + + test("keeps the assistant final response area empty", async ({ page }, testInfo) => { + await gotoWorkbench(page); + const agentCard = page.locator(`${selectors.messageCard}[data-role="agent"]`).last(); + await expect(agentCard).toHaveAttribute("data-status", "running"); + await expect(agentCard).toHaveAttribute("data-status", "completed"); + await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible(); + + const snapshot = await agentCard.evaluate((element) => { + const placeholder = "Code Agent 已完成,但没有返回可展示的 final response。"; + return { + status: element.getAttribute("data-status"), + messageTexts: Array.from(element.querySelectorAll(".message-markdown.message-text")).map((item) => (item.textContent ?? "").trim()), + bodyHasPlaceholder: document.body.textContent?.includes(placeholder) === true + }; + }); + expect(snapshot.status).toBe("completed"); + expect(snapshot.messageTexts.filter(Boolean)).toEqual([]); + expect(snapshot.bodyHasPlaceholder).toBe(false); + await saveScreenshot(page, testInfo, "terminal-completed-no-final-response"); + }); +}); + test("fresh deep link replays completed turn from the session authority", async ({ page }, testInfo) => { const detail = await page.request.get("/v1/workbench/sessions/ses_completed"); expect(detail.status()).toBe(200);