fix(workbench): leave completed no-final response empty

This commit is contained in:
lyon
2026-06-19 03:46:37 +08:00
parent 37e4440b04
commit c44461cc35
3 changed files with 133 additions and 5 deletions
@@ -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<strin
});
}
function markRunningNoFinalResponse(sessions: SessionRecord[], traces: Record<string, JsonRecord>): 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 })) };
}
+49 -3
View File
@@ -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<string, unknown>).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);
@@ -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);