diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index d79d3c03..da2f8b1e 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -57,6 +57,8 @@ const port = Number(args.get("port") ?? process.env.HWLAB_WORKBENCH_E2E_PORT ?? const distDir = resolve(cwd, args.get("dist") ?? "dist"); const capturePath = resolve(cwd, "tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json"); const capture = JSON.parse(await readFile(capturePath, "utf8")) as { scenario: { selectedSessionId: string; providerProfile: string; sessions: SessionRecord[]; traces: Record } }; +const progressOnlyText = "PROGRESS_ONLY_SHOULD_NOT_RENDER_AS_FINAL"; +const finalOnlyText = "FINAL_ONLY_SHOULD_RENDER"; let state = createScenarioState("baseline"); @@ -271,6 +273,7 @@ function createScenarioState(scenarioId: string): ScenarioState { sessions.push(terminalEmptyTraceSession()); traces.trc_terminal_empty = terminalEmptyTrace(); } + if (id === "progress-only-final-response") markRunningProgressOnly(sessions, traces); if (id === "cross-session-late-events") { sessions.unshift(crossSessionLateB(), crossSessionLateA()); traces.trc_late_A = crossSessionLateTraceA(); @@ -286,6 +289,8 @@ function createScenarioState(scenarioId: string): ScenarioState { ? "ses_completed" : id === "terminal-empty-trace" ? "ses_terminal_empty" + : id === "progress-only-final-response" + ? "ses_running" : base.selectedSessionId; const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null; return { @@ -301,7 +306,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", + terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore" || id === "progress-only-final-response", terminalFailureScript: id === "stale-submit-restore", staleTraceId, liveBackfillTraceId: null, @@ -320,6 +325,23 @@ function markSessionText(sessions: SessionRecord[], sessionId: string, userText: }); } +function markRunningProgressOnly(sessions: SessionRecord[], traces: Record): void { + const createdAt = new Date().toISOString(); + const progressEvent = { seq: 1, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "running", replyAuthority: false, final: false, message: progressOnlyText }; + const trace = { traceId: "trc_running", status: "running", sessionId: "ses_running", threadId: "thr_running", turnId: "turn_running", events: [progressEvent], eventCount: 1, fullTraceLoaded: false, hasMore: false }; + 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 = "progress-only running turn"; + session.messages = (session.messages ?? []).map((message) => { + if (message.role === "user") return { ...message, text: "progress-only running turn" }; + if (message.role !== "agent" || message.traceId !== "trc_running") return message; + return { ...message, text: "", status: "running", runnerTrace: trace }; + }); +} + function createManualSession(body: JsonRecord): SessionRecord { const now = new Date().toISOString(); const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36); @@ -695,12 +717,12 @@ function sse(response: ServerResponse, url: URL): void { setTimeout(() => { if (state.scenarioId !== scenarioId) return; const terminalStatus = state.terminalFailureScript ? "failed" : "completed"; - const terminalText = state.terminalFailureScript ? "恢复后失败:缺少受控依赖。" : "事件重放后完成。"; + 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 }; writeSse(response, "workbench.trace.event", { type: "trace.event", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", event, snapshot: { traceId: "trc_running", sessionId: "ses_running", threadId: "thr_running", status: terminalStatus, events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: terminalText, status: terminalStatus } } }); finishRunningSession(terminalStatus, terminalText); writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", turn: turnPayload("trc_running") }); - }, 350); + }, state.scenarioId === "progress-only-final-response" ? 5_000 : 350); } function writeSse(response: ServerResponse, eventName: string, payload: JsonRecord): void { diff --git a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts index 30e1b91f..263875d7 100644 --- a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts @@ -64,7 +64,7 @@ test("Workbench submit failures do not pollute first visible output SLI", () => assert.ok(events.some((event) => event.kind === "workbench_journey" && event.journey === "submit_to_failure" && event.outcome === "network")); }); -test("Workbench submit first visible waits for assistant or tool output", () => { +test("Workbench submit first visible waits for terminal final or tool output", () => { resetWorkbenchPerformanceForTest(); const wallBase = Date.now(); const createdAt = new Date(wallBase - 12_000).toISOString(); @@ -80,6 +80,10 @@ test("Workbench submit first visible waits for assistant or tool output", () => assert.equal(backendOnlyEvents.some((event) => event.kind === "workbench_event_phase" && event.phase === "sse_to_receive"), false); assert.ok(backendOnlyEvents.some((event) => event.kind === "workbench_backend_event_visible" && event.eventType === "backend" && event.outcome === "stale")); + acknowledgeWorkbenchVisible({ messages: [traceOnlyAgentMessage("ses_secret", "trc_secret", [{ type: "assistant_message", status: "running", message: "progress only" } as TraceEvent])], activeSessionId: "ses_secret", detailLoading: false }); + const progressEvents = drainWorkbenchPerformanceEventsForTest(); + assert.equal(progressEvents.some((event) => event.kind === "workbench_journey" && event.journey === "submit_to_first_visible"), false); + acknowledgeWorkbenchVisible({ messages: [traceOnlyAgentMessage("ses_secret", "trc_secret", [{ type: "tool_call", command: "pwd" } as TraceEvent])], activeSessionId: "ses_secret", detailLoading: false }); const toolEvents = drainWorkbenchPerformanceEventsForTest(); assert.ok(toolEvents.some((event) => event.kind === "workbench_journey" && event.journey === "submit_to_first_visible" && event.outcome === "ok")); @@ -95,7 +99,7 @@ function agentMessage(sessionId: string, traceId: string): ChatMessage { role: "agent", title: "Code Agent", text: "visible", - status: "running", + status: "completed", createdAt: new Date().toISOString(), sessionId, traceId, diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 75c07f83..140c83d9 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -29,7 +29,7 @@ const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy()); const scrollSignature = computed(() => workbench.activeMessages.map((message) => [ message.id, message.status, - message.text?.length ?? 0, + visibleMessageText(message).length, message.updatedAt ?? "", message.runnerTrace?.eventCount ?? message.runnerTrace?.events?.length ?? 0, message.runnerTrace?.status ?? "" @@ -61,16 +61,27 @@ function isRunningMessage(message: ChatMessage): boolean { return message.role === "agent" && ["pending", "running"].includes(String(message.status ?? "").toLowerCase()); } +function isTerminalAgentMessage(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-")); +} + +function visibleMessageText(message: ChatMessage): string { + const text = String(message.text ?? "").trim(); + if (message.role === "agent" && !isTerminalAgentMessage(message)) return ""; + return text; +} + function showStatusBadge(message: ChatMessage): boolean { return message.role !== "agent" || !isRunningMessage(message); } function isAwaitingAgentBody(message: ChatMessage): boolean { - return isRunningMessage(message) && !message.runnerTrace && !String(message.text ?? "").trim(); + return isRunningMessage(message) && !message.runnerTrace && !visibleMessageText(message); } function showMessageText(message: ChatMessage): boolean { - return !messageDiagnosticText(message) && (message.role !== "agent" || Boolean(String(message.text ?? "").trim())); + return !messageDiagnosticText(message) && Boolean(visibleMessageText(message)); } function messageDiagnosticText(message: ChatMessage): string | null { @@ -118,7 +129,7 @@ function traceAutoExpanded(message: ChatMessage): boolean | null {

{{ messageDiagnosticText(message) }}

- +
加载失败:{{ workbench.error }}
发起对话,或从左侧选择 session。
diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index c3beb735..089b4e25 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -797,10 +797,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { const error = message.role === "agent" ? normalizeAgentError(runnerTrace.error ?? message.error) : normalizeAgentError(message.error); const errorText = message.role === "agent" ? agentErrorDisplayText(error) : null; const nextText = message.role === "agent" && terminal - ? firstNonEmptyString(finalResponseText(runnerTrace.finalResponse), traceAssistantText, errorText, message.text) ?? message.text - : message.role === "agent" && !firstNonEmptyString(message.text) - ? firstNonEmptyString(traceAssistantText, finalResponseText(runnerTrace.finalResponse), errorText, message.text) ?? message.text - : message.text; + ? firstNonEmptyString(finalResponseText(runnerTrace.finalResponse), errorText, traceAssistantText, message.text) ?? message.text + : message.text; return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : "running", runnerTrace, error: error ?? message.error ?? null, updatedAt: new Date().toISOString() }; })); markWorkbenchTraceProjected(traceId); @@ -1039,8 +1037,10 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage { const finalText = firstNonEmptyString(finalResponseText((message as Record).finalResponse), finalResponseText(runnerTrace?.finalResponse)); const traceAssistantText = assistantTextFromTraceEvents(Array.isArray(runnerTrace?.events) ? runnerTrace.events : []); const errorText = agentErrorDisplayText(error); - const text = role === "agent" && isTerminalMessageStatus(status) - ? firstNonEmptyString(finalText, errorText, traceAssistantText, baseText) ?? "" + const text = role === "agent" + ? isTerminalMessageStatus(status) + ? firstNonEmptyString(finalText, errorText, traceAssistantText, baseText) ?? "" + : "" : firstNonEmptyString(baseText, finalText, traceAssistantText, errorText) ?? ""; const messageId = firstNonEmptyString((message as Record).messageId, message.id) ?? nextProtocolId("msg"); return { ...message, role, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined }; diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index 00198f75..06916be3 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -330,8 +330,8 @@ function afterNextPaint(callback: () => void): void { } function isVisibleAgentOutputMessage(message: ChatMessage): boolean { - if (String(message.text ?? "").trim()) return true; - return traceEventsFromMessage(message).some(isAssistantOrToolOutputTraceEvent); + if (isTerminalStatusText(message.status) && String(message.text ?? "").trim()) return true; + return traceEventsFromMessage(message).some(isSubmitFirstVisibleTraceEvent); } function isVisibleTraceEventMessage(message: ChatMessage): boolean { @@ -347,10 +347,18 @@ function isUserVisibleTraceEvent(event: TraceEvent): boolean { return type === "assistant" || type === "tool_call" || type === "backend" || type === "terminal" || type === "error"; } -function isAssistantOrToolOutputTraceEvent(event: TraceEvent): boolean { +function isSubmitFirstVisibleTraceEvent(event: TraceEvent): boolean { const type = eventTypeFromTraceEvent(event); if (type === "tool_call") return true; - return type === "assistant" && traceEventHasDisplayText(event); + return type === "assistant" && isTerminalTraceEvent(event) && traceEventHasDisplayText(event); +} + +function isTerminalTraceEvent(event: TraceEvent): boolean { + return event.terminal === true || event.final === true || isTerminalStatusText(event.status); +} + +function isTerminalStatusText(value: unknown): boolean { + return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(safeText(value).toLowerCase().replace(/_/gu, "-")); } function traceEventHasDisplayText(event: TraceEvent): boolean { diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts index d50e4bbc..7f162b68 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts @@ -37,6 +37,25 @@ test.describe("trace disclosure policy", () => { }); }); +test.describe("non-terminal assistant progress", () => { + test.use({ scenarioId: "progress-only-final-response" }); + + test("progress text stays in trace until terminal final response arrives", async ({ page }, testInfo) => { + await gotoWorkbench(page, "/workbench/sessions/ses_running"); + const runningCard = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last(); + await expect(runningCard).toBeVisible(); + await expect(runningCard.locator(selectors.traceTimeline)).toContainText("PROGRESS_ONLY_SHOULD_NOT_RENDER_AS_FINAL"); + const runningFinalTexts = await runningCard.evaluate((node) => Array.from(node.querySelectorAll(".message-markdown.message-text")).map((item) => item.textContent ?? "")); + expect(runningFinalTexts).toEqual([]); + await saveScreenshot(page, testInfo, "running-before-final-response"); + + const completedCard = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last(); + await expect(completedCard.locator(".message-markdown.message-text")).toContainText("FINAL_ONLY_SHOULD_RENDER"); + await expect(completedCard.locator(selectors.traceTimeline)).toBeVisible(); + await saveScreenshot(page, testInfo, "terminal-after-final-response"); + }); +}); + test("failed trace exposes readable failure row", async ({ page }, testInfo) => { await gotoWorkbench(page, "/workbench/sessions/ses_failed"); const trace = page.locator(`${selectors.traceTimeline}[data-status="failed"]`);