From 126ccf73e2a2f35704ff59fb39fd0b29ddb11d28 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:50:39 +0800 Subject: [PATCH] fix: unify workbench session status (#1318) --- .../workbench-r2-session-parity.test.ts | 43 ++++++++++- .../src/stores/workbench-session.ts | 72 ++++++++++++++----- web/hwlab-cloud-web/src/stores/workbench.ts | 51 +++++++++++-- 3 files changed, 139 insertions(+), 27 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts index 9014f4d8..3d0d1b74 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts @@ -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" }), diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 9ffd7ead..13a65683 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -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; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index ae786456..bf118cab 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -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).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).content), messageText((message as Record).message), finalResponseText((message as Record).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 { @@ -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, "-")); }