From 20c26246f3ac7e0adb9e1505322e0d5239bac14b Mon Sep 17 00:00:00 2001 From: lyon Date: Tue, 16 Jun 2026 14:44:38 +0800 Subject: [PATCH] fix(web): restore running code agent sessions after refresh --- internal/cloud/access-control.ts | 24 +++++++++++++----- .../workbench-r2-session-parity.test.ts | 7 +++--- .../src/stores/workbench-session.ts | 25 ++++++++++++++++--- web/hwlab-cloud-web/src/stores/workbench.ts | 23 ++++++++++++++--- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index a39b25b6..6fbc999f 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2742,25 +2742,37 @@ function resolvedConversationStatus(status, snapshot, messages = []) { const snapshotStatus = textOr(snapshot.sessionStatus ?? snapshot.status, "").toLowerCase(); const latestAgent = latestAgentConversationMessage(messages); const latestStatus = textOr(latestAgent?.status, "").toLowerCase(); - if (["running", "busy", "pending", "active"].includes(stored)) { - if (snapshotStatus && !["running", "busy", "pending", "active"].includes(snapshotStatus)) return snapshotStatus === "cancelled" ? "canceled" : snapshotStatus; - if (latestStatus && !["running", "busy", "pending", "active"].includes(latestStatus)) return latestStatus === "cancelled" ? "canceled" : latestStatus; + if (isActiveConversationStatus(stored)) { + if (snapshotStatus && !isActiveConversationStatus(snapshotStatus)) return snapshotStatus === "cancelled" ? "canceled" : snapshotStatus; + if (latestStatus && !isActiveConversationStatus(latestStatus)) return latestStatus === "cancelled" ? "canceled" : latestStatus; } + if (isActiveConversationStatus(latestStatus) && !isTerminalConversationStatus(stored) && !isTerminalConversationStatus(snapshotStatus)) return latestStatus === "active" ? "running" : latestStatus; return stored || snapshotStatus || latestStatus || "idle"; } function resolvedConversationLastTraceId(storedTraceId, status, messages = [], snapshot = {}) { - const latestTraceId = textOr(latestAgentConversationMessage(messages)?.traceId, ""); + const latestAgent = latestAgentConversationMessage(messages); + const latestTraceId = textOr(latestAgent?.traceId, ""); + const latestStatus = textOr(latestAgent?.status, "").toLowerCase(); const snapshotTraceId = textOr(snapshot.lastTraceId, ""); const stored = textOr(storedTraceId, ""); const snapshotStatus = textOr(snapshot.sessionStatus ?? snapshot.status, "").toLowerCase(); - if (stored && snapshotStatus && !["running", "busy", "pending", "active"].includes(snapshotStatus)) return stored; - if (!["running", "busy", "pending", "active"].includes(textOr(status, "").toLowerCase())) { + if (latestTraceId && (isActiveConversationStatus(latestStatus) || isActiveConversationStatus(status))) return latestTraceId; + if (stored && snapshotStatus && !isActiveConversationStatus(snapshotStatus)) return stored; + if (!isActiveConversationStatus(status)) { return latestTraceId || snapshotTraceId || stored; } return stored || latestTraceId || snapshotTraceId; } +function isActiveConversationStatus(value) { + return ["running", "busy", "pending", "active", "processing", "accepted", "creating"].includes(textOr(value, "").toLowerCase().replace(/_/gu, "-")); +} + +function isTerminalConversationStatus(value) { + return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed", "interrupted", "expired"].includes(textOr(value, "").toLowerCase().replace(/_/gu, "-")); +} + function latestAgentConversationMessage(messages = []) { if (!Array.isArray(messages)) return null; for (const message of [...messages].reverse()) { 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 ac7fa7b3..61455566 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 @@ -169,7 +169,7 @@ test("R2 active in-flight turn updates the selected session tab before list refr assert.equal(tabs[0]?.messageCount, 2); }); -test("R2 session tabs use conversation status before stale snapshot or message status", () => { +test("R2 session tabs keep restored running message ahead of stale idle conversation status", () => { const conversation: ConversationRecord = { conversationId: "cnv_stale_snapshot", sessionId: "ses_stale_snapshot", @@ -177,9 +177,10 @@ test("R2 session tabs use conversation status before stale snapshot or message s snapshot: { sessionStatus: "running" }, messages: [agentMessage({ conversationId: "cnv_stale_snapshot", status: "running", traceId: "trc_stale" })] }; - assert.equal(resolveConversationSessionStatus(conversation), "idle"); + assert.equal(resolveConversationSessionStatus(conversation), "running"); const tabs = sortSessionTabs([conversation], "cnv_stale_snapshot"); - assert.equal(tabs[0]?.running, false); + assert.equal(tabs[0]?.running, true); + assert.equal(tabs[0]?.lastTraceId, "trc_stale"); }); test("R2 agent message titles drop transient running wording", () => { diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index c0ed0a0f..ce9476f4 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -135,7 +135,7 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ const conversationId = conversation.conversationId; const updatedAt = conversationDisplayUpdatedAt(conversation); const status = resolveConversationSessionStatus(conversation); - const trace = firstNonEmptyString(conversation.lastTraceId, latestAgentMessage(conversation.messages)?.traceId, conversation.messages?.at(-1)?.traceId); + const trace = firstNonEmptyString(latestAgentMessage(conversation.messages)?.traceId, conversation.messages?.at(-1)?.traceId, conversation.lastTraceId); const userMessage = conversation.messages?.find((message) => message.role === "user"); const running = isActiveStatus(status); const preview = firstReadableSentence( @@ -179,6 +179,17 @@ export function resolveConversationSessionStatus(conversation: ConversationRecor latestMessage?.status ); if (stored && isActiveStatus(stored) && terminalEvidence) return terminalEvidence; + const activeEvidence = firstActiveStatus( + latestAgent?.status, + latestAgent?.runnerTrace?.status, + latestAgent?.runnerTrace?.traceStatus, + latestMessage?.status, + ...extraStatuses, + conversation.session?.status, + conversation.snapshot?.sessionStatus, + conversation.snapshot?.status + ); + if (activeEvidence && !isTerminalStatus(stored)) return activeEvidence; return stored ?? firstNormalizedSessionStatus( ...extraStatuses, conversation.session?.status, @@ -285,7 +296,7 @@ function selectedConversationStub(workspace: WorkspaceRecord | null, selectedCon const messages = Array.isArray(workspace.workspace?.messages) ? workspace.workspace.messages : []; const sessionId = firstNonEmptyString(workspace.selectedAgentSessionId, workspace.workspace?.selectedAgentSessionId, messages.find((message) => message.sessionId)?.sessionId) ?? null; 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 lastTraceId = firstNonEmptyString([...messages].reverse().find((message) => message.traceId)?.traceId, workspace.workspace?.lastTraceId, workspace.activeTraceId, workspace.workspace?.activeTraceId) ?? null; const lastUserMessageAt = latestUserMessageAtFromMessages(messages); const latestAgent = latestAgentMessage(messages); const stub: ConversationRecord = { @@ -325,7 +336,7 @@ function mergeConversationRecords(existing: ConversationRecord, selected: Conver 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, + lastTraceId: firstNonEmptyString(latestAgentMessage(messages)?.traceId, messages.at(-1)?.traceId, primary.lastTraceId, supplemental.lastTraceId) ?? 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, @@ -378,6 +389,14 @@ function firstNonActiveStatus(...values: unknown[]): string | null { return null; } +function firstActiveStatus(...values: unknown[]): string | null { + for (const value of values) { + const status = normalizeSessionStatus(value); + if (status && isActiveStatus(status)) return status; + } + return null; +} + function firstNormalizedSessionStatus(...values: unknown[]): string | null { for (const value of values) { const status = normalizeSessionStatus(value); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index a404b0c7..82fec6bd 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -66,8 +66,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { rememberWorkbenchProjectId(workspaceProjectId(workspace.value, projectId.value)); messages.value = messagesFromWorkspaceSelection(workspace.value, nextConversations); void hydrateTerminalMessageDiagnostics(); - const activeTraceId = activeTraceIdFromWorkspace(workspace.value); - if (activeTraceId) void validateAndReattachTrace(activeTraceId); + reattachRestoredActiveTrace(); } await refreshProviderOptions(); } @@ -117,6 +116,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { workspace.value = response.data?.workspace ?? current; messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]); void hydrateTerminalMessageDiagnostics(); + reattachRestoredActiveTrace(); currentRequest.value = null; return; } @@ -477,10 +477,16 @@ export const useWorkbenchStore = defineStore("workbench", () => { workspace.value = retried.data?.workspace ?? workspace.value; messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]); void hydrateTerminalMessageDiagnostics(); + reattachRestoredActiveTrace(); currentRequest.value = null; return true; } + function reattachRestoredActiveTrace(): void { + const traceId = firstNonEmptyString(activeTraceIdFromMessages(messages.value), activeTraceIdFromWorkspace(workspace.value)); + if (traceId) void validateAndReattachTrace(traceId); + } + return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity }; }); @@ -537,13 +543,12 @@ function normalizeChatMessageStatus(value: unknown, runnerTrace: ChatMessage["ru 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 (isTraceActiveStatus(explicit) || isTraceActiveStatus(trace)) return "running"; if (explicit === "blocked") return "blocked"; if (explicit === "timeout") return "timeout"; if (explicit === "failed" || explicit === "error") return "failed"; @@ -560,6 +565,16 @@ function terminalChatStatus(value: string | null): ChatMessage["status"] | null return null; } +function activeTraceIdFromMessages(messages: ChatMessage[]): string | null { + for (const message of [...messages].reverse()) { + if (message.role !== "agent") continue; + if (!isTraceActiveStatus(message.status) && !isTraceActiveStatus(message.runnerTrace?.status) && !isTraceActiveStatus(message.runnerTrace?.traceStatus)) continue; + const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); + if (traceId) return traceId; + } + return null; +} + function normalizedStatusText(value: unknown): string | null { const text = firstNonEmptyString(value); return text ? text.trim().toLowerCase().replace(/_/gu, "-") : null;