Merge pull request #1549 from pikasTech/fix/1542-workbench-final-response

fix: Workbench 非终态进展不渲染为最终回复
This commit is contained in:
Lyon
2026-06-19 01:30:23 +08:00
committed by GitHub
6 changed files with 83 additions and 19 deletions
@@ -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<string, JsonRecord> } };
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<string, JsonRecord>): 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 {
@@ -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,
@@ -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 {
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" />
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
<p v-if="messageDiagnosticText(message)" class="message-text projection-diagnostic">{{ messageDiagnosticText(message) }}</p>
<MessageMarkdown v-else-if="showMessageText(message)" class="message-text" :source="message.text" />
<MessageMarkdown v-else-if="showMessageText(message)" class="message-text" :source="visibleMessageText(message)" />
</article>
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败{{ workbench.error }}</div>
<div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话或从左侧选择 session</div>
+6 -6
View File
@@ -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<string, unknown>).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<string, unknown>).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 };
@@ -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 {
@@ -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"]`);