fix: unify workbench session status (#1318)

This commit is contained in:
Lyon
2026-06-16 08:50:39 +08:00
committed by GitHub
parent 98aa1c4178
commit 126ccf73e2
3 changed files with 139 additions and 27 deletions
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts";
import { defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
import { defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
test("R2 composer ignores stale workspace activeTraceId without verified active message", () => {
const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" });
@@ -109,6 +109,47 @@ test("R2 running session tabs expose animation state without rewriting label", (
assert.equal(tabs[0]?.running, true);
});
test("R2 selected workspace stubs cannot clear a running conversation tab", () => {
const conversations: ConversationRecord[] = [{
conversationId: "cnv_r2",
sessionId: "ses_r2",
firstUserMessagePreview: "保持原始标题",
status: "running",
messageCount: 2,
messages: [
userMessage({ conversationId: "cnv_r2", createdAt: "2026-01-04T00:00:00.000Z" }),
agentMessage({ conversationId: "cnv_r2", status: "running", updatedAt: "2026-01-04T00:01:00.000Z" })
]
}];
const workspace = workspaceRecord({ activeTraceId: null, sessionStatus: "idle" });
const merged = mergeSelectedConversation(conversations, workspace);
const tabs = sortSessionTabs(merged, "cnv_r2");
assert.equal(tabs[0]?.status, "running");
assert.equal(tabs[0]?.running, true);
assert.equal(tabs[0]?.messageCount, 2);
assert.equal(tabs[0]?.label, "保持原始标题");
});
test("R2 session tabs use conversation status before stale snapshot or message status", () => {
const conversation: ConversationRecord = {
conversationId: "cnv_stale_snapshot",
sessionId: "ses_stale_snapshot",
status: "idle",
snapshot: { sessionStatus: "running" },
messages: [agentMessage({ conversationId: "cnv_stale_snapshot", status: "running", traceId: "trc_stale" })]
};
assert.equal(resolveConversationSessionStatus(conversation), "idle");
const tabs = sortSessionTabs([conversation], "cnv_stale_snapshot");
assert.equal(tabs[0]?.running, false);
});
test("R2 agent message titles drop transient running wording", () => {
assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent 处理中"), "Code Agent");
assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent running"), "Code Agent");
assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent result"), "Code Agent result");
assert.equal(normalizeWorkbenchMessageTitle("user", "用户"), "用户");
});
test("R2 session tabs keep the workspace-selected conversation when the list window omits it", () => {
const workspace: WorkspaceRecord = {
...workspaceRecord({ activeTraceId: null, sessionStatus: "completed" }),
@@ -121,8 +121,8 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ
const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId);
const conversationId = conversation.conversationId;
const updatedAt = conversationDisplayUpdatedAt(conversation);
const status = firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, conversation.messages?.at(-1)?.status) ?? "source";
const trace = firstNonEmptyString(conversation.lastTraceId, conversation.messages?.at(-1)?.traceId);
const status = resolveConversationSessionStatus(conversation);
const trace = firstNonEmptyString(conversation.lastTraceId, latestAgentMessage(conversation.messages)?.traceId, conversation.messages?.at(-1)?.traceId);
const userMessage = conversation.messages?.find((message) => message.role === "user");
const running = isActiveStatus(status);
const preview = firstReadableSentence(
@@ -149,6 +149,26 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ
};
}
export function resolveConversationSessionStatus(conversation: ConversationRecord): string {
const latestAgent = latestAgentMessage(conversation.messages);
const latestMessage = conversation.messages?.at(-1);
return firstNonEmptyString(
conversation.status,
conversation.snapshot?.sessionStatus,
latestAgent?.status,
latestAgent?.runnerTrace?.status,
latestMessage?.status
) ?? "source";
}
export function normalizeWorkbenchMessageTitle(role: ChatMessage["role"], title: unknown): string {
const fallback = role === "user" ? "用户" : "Code Agent";
const value = firstNonEmptyString(title) ?? fallback;
const text = value.replace(/\s+/gu, " ").trim();
if (role === "agent" && /^Code Agent\s*(?:|running)$/iu.test(text)) return "Code Agent";
return text || fallback;
}
export function sortSessionTabs(conversations: ConversationRecord[], activeConversationId: string | null): SessionTab[] {
return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt));
}
@@ -186,7 +206,7 @@ function activeConversation(conversations: ConversationRecord[], conversationId:
function selectedConversationFromWorkspace(workspace: WorkspaceRecord | null, selectedConversationId: string): ConversationRecord | null {
const selected = workspace?.selectedConversation;
const stub = selectedConversationStub(workspace, selectedConversationId);
if (selected?.conversationId === selectedConversationId) return stub ? mergeConversationRecords(stub, selected) : selected;
if (selected?.conversationId === selectedConversationId) return stub ? mergeConversationRecords(selected, stub) : selected;
return stub;
}
@@ -197,12 +217,13 @@ function selectedConversationStub(workspace: WorkspaceRecord | null, selectedCon
const threadId = firstNonEmptyString(workspace.workspace?.threadId, messages.find((message) => message.threadId)?.threadId) ?? null;
const lastTraceId = firstNonEmptyString(workspace.workspace?.lastTraceId, workspace.activeTraceId, workspace.workspace?.activeTraceId, [...messages].reverse().find((message) => message.traceId)?.traceId) ?? null;
const lastUserMessageAt = latestUserMessageAtFromMessages(messages);
const latestAgent = latestAgentMessage(messages);
return {
conversationId: selectedConversationId,
projectId: firstNonEmptyString(workspace.projectId, workspace.workspace?.projectId) ?? null,
sessionId,
threadId,
status: firstNonEmptyString(workspace.workspace?.sessionStatus, messages.at(-1)?.status) ?? null,
status: firstNonEmptyString(workspace.workspace?.sessionStatus, latestAgent?.status, latestAgent?.runnerTrace?.status, messages.at(-1)?.status) ?? null,
updatedAt: lastUserMessageAt ?? firstNonEmptyString(workspace.createdAt, workspace.updatedAt, workspace.workspace?.updatedAt),
lastUserMessageAt,
lastTraceId,
@@ -212,26 +233,39 @@ function selectedConversationStub(workspace: WorkspaceRecord | null, selectedCon
}
function mergeConversationRecords(existing: ConversationRecord, selected: ConversationRecord): ConversationRecord {
const selectedMessages = selected.messages ?? [];
const existingMessages = existing.messages ?? [];
const lastUserMessageAt = firstNonEmptyString(selected.lastUserMessageAt, selected.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(selectedMessages), existing.lastUserMessageAt, existing.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(existingMessages)) ?? null;
const primary = existing;
const supplemental = selected;
const primaryMessages = primary.messages ?? [];
const supplementalMessages = supplemental.messages ?? [];
const messages = supplementalMessages.length > 0 ? supplementalMessages : primaryMessages;
const lastUserMessageAt = firstNonEmptyString(primary.lastUserMessageAt, primary.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(primaryMessages), supplemental.lastUserMessageAt, supplemental.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(supplementalMessages)) ?? null;
const status = firstNonEmptyString(primary.status, primary.snapshot?.sessionStatus, supplemental.status, supplemental.snapshot?.sessionStatus, statusFromMessages(messages)) ?? null;
return {
...existing,
...selected,
conversationId: existing.conversationId,
projectId: firstNonEmptyString(selected.projectId, existing.projectId) ?? null,
sessionId: firstNonEmptyString(selected.sessionId, selected.session?.sessionId, existing.sessionId, existing.session?.sessionId) ?? null,
threadId: firstNonEmptyString(selected.threadId, selected.session?.threadId, existing.threadId, existing.session?.threadId) ?? null,
status: firstNonEmptyString(selected.status, existing.status) ?? null,
lastTraceId: firstNonEmptyString(selected.lastTraceId, existing.lastTraceId) ?? null,
firstUserMessagePreview: firstNonEmptyString(selected.firstUserMessagePreview, existing.firstUserMessagePreview) ?? null,
messageCount: selected.messageCount ?? (selectedMessages.length > 0 ? selectedMessages.length : existing.messageCount ?? existingMessages.length),
messages: selectedMessages.length > 0 ? selectedMessages : existing.messages,
...supplemental,
...primary,
conversationId: primary.conversationId,
projectId: firstNonEmptyString(primary.projectId, supplemental.projectId) ?? null,
sessionId: firstNonEmptyString(primary.sessionId, primary.session?.sessionId, supplemental.sessionId, supplemental.session?.sessionId) ?? null,
threadId: firstNonEmptyString(primary.threadId, primary.session?.threadId, supplemental.threadId, supplemental.session?.threadId) ?? null,
status,
lastTraceId: firstNonEmptyString(primary.lastTraceId, supplemental.lastTraceId, latestAgentMessage(messages)?.traceId, messages.at(-1)?.traceId) ?? null,
firstUserMessagePreview: firstNonEmptyString(primary.firstUserMessagePreview, primary.snapshot?.firstUserMessagePreview, supplemental.firstUserMessagePreview, supplemental.snapshot?.firstUserMessagePreview) ?? null,
messageCount: messages.length > 0 ? messages.length : primary.messageCount ?? supplemental.messageCount ?? primaryMessages.length,
messages: messages.length > 0 ? messages : primary.messages,
lastUserMessageAt,
updatedAt: lastUserMessageAt ?? firstNonEmptyString(selected.startedAt, existing.startedAt, selected.updatedAt, existing.updatedAt, selected.snapshot?.updatedAt, existing.snapshot?.updatedAt)
updatedAt: lastUserMessageAt ?? firstNonEmptyString(primary.startedAt, primary.updatedAt, primary.snapshot?.updatedAt, supplemental.startedAt, supplemental.updatedAt, supplemental.snapshot?.updatedAt)
};
}
function statusFromMessages(messages: ChatMessage[]): string | null {
const latestAgent = latestAgentMessage(messages);
return firstNonEmptyString(latestAgent?.status, latestAgent?.runnerTrace?.status, messages.at(-1)?.status) ?? null;
}
function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage | null {
return [...(messages ?? [])].reverse().find((message) => message.role === "agent") ?? null;
}
function findActiveAgentMessage(messages: ChatMessage[], activeConversationId: string | null): ChatMessage | null {
for (const message of [...messages].reverse()) {
if (message.role !== "agent") continue;
+44 -7
View File
@@ -4,7 +4,7 @@ import { api } from "@/api";
import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types";
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
@@ -396,7 +396,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
return { ...message, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
const status = statusFromResult(result.status);
return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
bumpConversationActivity({ conversationId: firstNonEmptyString((result as Record<string, unknown>).conversationId), sessionId: result.sessionId, threadId: result.threadId, traceId, status: statusFromResult(result.status), updatedAt: resultActivityUpdatedAt(result) });
}
@@ -495,22 +496,58 @@ function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: Co
}
function messagesFromConversation(conversation: ConversationRecord): ChatMessage[] {
return (conversation.messages ?? []).map(normalizeChatMessage);
return (conversation.messages ?? []).map((message) => normalizeChatMessage(message, firstNonEmptyString(conversation.status, conversation.snapshot?.sessionStatus)));
}
function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] {
const selected = workspace?.selectedConversation?.messages;
const embedded = workspace?.workspace?.messages;
const source = Array.isArray(selected) ? selected : Array.isArray(embedded) ? embedded : [];
return source.map(normalizeChatMessage);
const sessionStatus = firstNonEmptyString(workspace?.selectedConversation?.status, workspace?.selectedConversation?.snapshot?.sessionStatus, workspace?.workspace?.sessionStatus);
return source.map((message) => normalizeChatMessage(message, sessionStatus));
}
function normalizeChatMessage(message: ChatMessage): ChatMessage {
function normalizeChatMessage(message: ChatMessage, sessionStatus?: string | null): ChatMessage {
const text = firstNonEmptyString(message.text, messageText((message as Record<string, unknown>).content), messageText((message as Record<string, unknown>).message), finalResponseText((message as Record<string, unknown>).finalResponse)) ?? "";
const runnerTrace = normalizeMessageRunnerTrace(message);
const error = normalizeAgentError(message.error ?? runnerTrace?.error);
const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun);
return { ...message, text, id: message.id ?? nextProtocolId("msg"), title: message.title ?? (message.role === "user" ? "用户" : "Code Agent"), createdAt: message.createdAt ?? new Date().toISOString(), status: message.status ?? "source", runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined };
const status = normalizeChatMessageStatus(message.status, runnerTrace, sessionStatus);
return { ...message, text, id: message.id ?? nextProtocolId("msg"), title: normalizeWorkbenchMessageTitle(message.role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined };
}
function normalizeChatMessageStatus(value: unknown, runnerTrace: ChatMessage["runnerTrace"], sessionStatus?: string | null): ChatMessage["status"] {
const session = normalizedStatusText(sessionStatus);
const explicit = normalizedStatusText(value);
const trace = normalizedStatusText(firstNonEmptyString(runnerTrace?.status, runnerTrace?.traceStatus));
const sessionTerminal = terminalChatStatus(session);
if (sessionTerminal && !terminalChatStatus(explicit)) return sessionTerminal;
if (session && !isTraceActiveStatus(session) && isTraceActiveStatus(explicit)) return "source";
const traceTerminal = terminalChatStatus(trace);
if (traceTerminal && (!explicit || explicit === "idle" || explicit === "source" || isTraceActiveStatus(explicit))) return traceTerminal;
const explicitTerminal = terminalChatStatus(explicit);
if (explicitTerminal) return explicitTerminal;
if (explicit === "sent") return "sent";
if (isTraceActiveStatus(explicit) || (!session || isTraceActiveStatus(session)) && isTraceActiveStatus(trace)) return "running";
if (explicit === "blocked") return "blocked";
if (explicit === "timeout") return "timeout";
if (explicit === "failed" || explicit === "error") return "failed";
if (explicit === "canceled" || explicit === "cancelled") return "canceled";
return "source";
}
function terminalChatStatus(value: string | null): ChatMessage["status"] | null {
if (value === "completed") return "completed";
if (value === "blocked") return "blocked";
if (value === "timeout") return "timeout";
if (value === "canceled" || value === "cancelled") return "canceled";
if (["failed", "error", "stale", "thread-resume-failed"].includes(value ?? "")) return "failed";
return null;
}
function normalizedStatusText(value: unknown): string | null {
const text = firstNonEmptyString(value);
return text ? text.trim().toLowerCase().replace(/_/gu, "-") : null;
}
function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: AgentChatResultResponse): NonNullable<ChatMessage["runnerTrace"]> {
@@ -666,7 +703,7 @@ function statusFromResult(status: string | undefined): ChatMessage["status"] {
return "failed";
}
function isTraceActiveStatus(status: string | undefined): boolean {
function isTraceActiveStatus(status: unknown): boolean {
return ["accepted", "pending", "processing", "running", "busy", "creating"].includes(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}