fix: unify workbench session status (#1318)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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, "-"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user