Merge pull request #1549 from pikasTech/fix/1542-workbench-final-response
fix: Workbench 非终态进展不渲染为最终回复
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"]`);
|
||||
|
||||
Reference in New Issue
Block a user