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 0c91ca02..ac7fa7b3 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 @@ -21,6 +21,27 @@ test("R2 composer steers only when an active agent message or status exists", () assert.equal(composer.targetTraceId, "trc_running"); }); +test("R2 composer never reuses a stale workspace session during routed selection", () => { + const workspace = workspaceRecord({ activeTraceId: null, sessionStatus: "idle" }); + const withoutTarget = resolveComposerState({ workspace, messages: [], conversations: [], activeConversationId: "cnv_route", chatPending: false }); + assert.equal(withoutTarget.disabled, true); + assert.equal(withoutTarget.disabledReason, "session_required"); + assert.equal(withoutTarget.conversationId, "cnv_route"); + assert.equal(withoutTarget.sessionId, null); + + const withTarget = resolveComposerState({ + workspace, + messages: [], + conversations: [{ conversationId: "cnv_route", sessionId: "ses_route", threadId: "thr_route" }], + activeConversationId: "cnv_route", + chatPending: false + }); + assert.equal(withTarget.disabled, false); + assert.equal(withTarget.conversationId, "cnv_route"); + assert.equal(withTarget.sessionId, "ses_route"); + assert.equal(withTarget.threadId, "thr_route"); +}); + test("R2 active trace cleanup preserves workspace and records stale reason", () => { const cleared = workspaceWithClearedActiveTrace(workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }), "trc_stale", "steer-trace-not-found"); assert.equal(cleared?.activeTraceId, null); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 6b1557e1..6b24e107 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -61,8 +61,13 @@ export function resolveComposerState(input: { workspace: WorkspaceRecord | null; const activeByStatus = isActiveStatus(runningMessage?.status) || isActiveStatus(runningMessage?.runnerTrace?.status) || isActiveStatus(currentRequest?.status) || input.chatPending; const terminal = isTerminalStatus(latestMessage?.status) || isTerminalStatus(currentRequest?.status) || (!latestMessage && isTerminalStatus(workspaceStatus)); const conversationId = input.activeConversationId; - const sessionId = firstNonEmptyString(runningMessage?.sessionId, currentRequest?.sessionId, input.workspace?.selectedAgentSessionId, input.workspace?.workspace?.selectedAgentSessionId, activeConversation(input.conversations ?? [], conversationId)?.sessionId); - const threadId = firstNonEmptyString(runningMessage?.threadId, currentRequest?.threadId, input.workspace?.workspace?.threadId, activeConversation(input.conversations ?? [], conversationId)?.threadId); + const active = activeConversation(input.conversations ?? [], conversationId); + const workspaceConversationId = selectedConversationIdFromWorkspace(input.workspace); + const workspaceMatchesConversation = !conversationId || !workspaceConversationId || workspaceConversationId === conversationId; + const workspaceSessionId = workspaceMatchesConversation ? firstNonEmptyString(input.workspace?.selectedAgentSessionId, input.workspace?.workspace?.selectedAgentSessionId) : null; + const workspaceThreadId = workspaceMatchesConversation ? firstNonEmptyString(input.workspace?.workspace?.threadId) : null; + const sessionId = firstNonEmptyString(runningMessage?.sessionId, currentRequest?.sessionId, active?.sessionId, workspaceSessionId); + const threadId = firstNonEmptyString(runningMessage?.threadId, currentRequest?.threadId, active?.threadId, workspaceThreadId); const canSteer = Boolean(conversationId && sessionId && activeTraceId && activeByStatus && !terminal); if (canSteer) return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId, conversationId, sessionId, threadId }; if (!sessionId) return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId: null, threadId }; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 34b8b948..ec2dbad9 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -132,15 +132,18 @@ export const useWorkbenchStore = defineStore("workbench", () => { } const current = await ensureWorkspace(); if (!current) return false; + if (activeConversationId.value === normalized && messages.value.length > 0) return true; + const requestEpoch = beginWorkspaceSelection(); + switchingConversationId.value = normalized; const existing = visibleConversations.value.find((conversation) => conversation.conversationId === normalized) ?? null; if (existing && (existing.messages?.length ?? 0) > 0) { - if (activeConversationId.value === normalized && messages.value.length > 0) return true; await selectConversation(existing); return activeConversationId.value === normalized; } const response = await api.workbench.conversation(normalized); const conversation = response.data?.conversation ?? null; if (!response.ok || !conversation) { + if (isCurrentWorkspaceSelection(requestEpoch, normalized)) clearSwitchingConversation(normalized); error.value = response.error ?? "session URL not found"; return false; }