From 3498951ccbfb790159503043dd2f560b000827cc Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 27 Aug 2026 17:58:21 +0800 Subject: [PATCH 1/8] fix(web-shell): scope mock sessions by workspace Keep the visual mock daemon aligned with workspace-scoped session routes so multi-workspace sidebar screenshots do not duplicate session rows across sections. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../web-shell/client/e2e/utils/mockDaemon.ts | 27 +++++++++++++++-- .../client/e2e/visuals/screenshots.spec.ts | 30 +++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index b72dbbcd24b..fb5af1bbda6 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -632,21 +632,37 @@ function readRequestBody(raw: string | null): unknown { function filterScenarioSessions( scenario: WebShellDaemonScenario, searchParams: URLSearchParams, + workspaceCwd?: string, ): DaemonSessionSummary[] { const group = searchParams.get('group'); const sourceType = searchParams.get('sourceType'); - const sourceSessions = sourceType + const workspaceSessions = workspaceCwd ? scenario.sessions.filter( + (session) => session.workspaceCwd === workspaceCwd, + ) + : scenario.sessions; + const sourceSessions = sourceType + ? workspaceSessions.filter( (session) => session.sourceType === sourceType || (sourceType === 'default' && session.sourceType === undefined), ) - : scenario.sessions; + : workspaceSessions; return group === 'pinned' ? sourceSessions.filter((session) => Boolean(session.isPinned)) : sourceSessions; } +function workspaceCwdFromSessionsPath(path: string): string | undefined { + const workspaceMatch = path.match( + /^\/workspaces\/([^/]+)\/sessions(?:\/live-state)?\/?$/, + ); + if (workspaceMatch) return decodeURIComponent(workspaceMatch[1]); + + const legacyMatch = path.match(/^\/workspace\/(.+)\/sessions\/?$/); + return legacyMatch ? decodeURIComponent(legacyMatch[1]) : undefined; +} + function isDaemonPath(path: string): boolean { return ( path === '/health' || @@ -1009,10 +1025,14 @@ async function handleDaemonRoute( method === 'GET' && /^\/workspaces\/[^/]+\/sessions\/live-state\/?$/.test(path) ) { + const workspaceCwd = workspaceCwdFromSessionsPath(path); await json(route, { v: 1, catalogVersion: scenario.sessionCatalogVersion, sessions: scenario.sessions + .filter( + (session) => !workspaceCwd || session.workspaceCwd === workspaceCwd, + ) .filter( (session) => (session.clientCount ?? 0) > 0 || @@ -1035,8 +1055,9 @@ async function handleDaemonRoute( (/^\/workspace\/.+\/sessions\/?$/.test(path) || /^\/workspaces\/[^/]+\/sessions\/?$/.test(path)) ) { + const workspaceCwd = workspaceCwdFromSessionsPath(path); await json(route, { - sessions: filterScenarioSessions(scenario, searchParams), + sessions: filterScenarioSessions(scenario, searchParams, workspaceCwd), }); return; } diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 617123c6088..2f09e326976 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -5,7 +5,7 @@ */ import { expect, test } from '@playwright/test'; -import type { DaemonEvent } from '@qwen-code/sdk/daemon'; +import type { DaemonEvent, DaemonSessionSummary } from '@qwen-code/sdk/daemon'; import { assistantTextEvent, createWebShellDaemonScenario, @@ -785,9 +785,32 @@ for (const theme of THEMES) { // turn this into a cryptic "not visible" failure. const primaryCwd = '/tmp/qwen-web-shell-e2e'; const primarySessionName = 'Run auth migration'; + const secondaryCwd = '/tmp/qwen-api-service'; + const secondarySessionName = 'Audit API retries'; + const sessions = [ + { + sessionId: 'workspace-primary-session', + workspaceCwd: primaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: primarySessionName, + clientCount: 1, + hasActivePrompt: false, + }, + { + sessionId: 'workspace-secondary-session', + workspaceCwd: secondaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: secondarySessionName, + clientCount: 0, + hasActivePrompt: false, + }, + ] satisfies DaemonSessionSummary[]; const scenario = createWebShellDaemonScenario({ workspaceCwd: primaryCwd, displayName: primarySessionName, + sessions, capabilities: { workspaces: [ { @@ -798,7 +821,7 @@ for (const theme of THEMES) { }, { id: 'ws-api', - cwd: '/tmp/qwen-api-service', + cwd: secondaryCwd, primary: false, trusted: true, }, @@ -823,7 +846,8 @@ for (const theme of THEMES) { // per-workspace fetch. Wait for the loaded session's row before capturing // so the async load has settled — otherwise the row list races the // screenshot and the capture differs between runs. - await expect(sidebar.getByText(primarySessionName)).toBeVisible(); + await expect(sidebar.getByText(primarySessionName)).toHaveCount(1); + await expect(sidebar.getByText(secondarySessionName)).toHaveCount(1); await captureScreenshot(page, `workspace-sidebar-${theme}`); }); From 77a0a2a99203d07f829b9ca8ee5c4dac50bd1aac Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 27 Aug 2026 21:29:29 +0800 Subject: [PATCH 2/8] fix(web-shell): harden workspace-scoped session mocks Require mock session routes to resolve an explicit workspace and cover live-state scoping so visual fixtures cannot leak sessions across workspaces. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../web-shell/client/e2e/utils/mockDaemon.ts | 20 ++--- .../client/e2e/visuals/screenshots.spec.ts | 1 + .../e2e/web-shell.session-live-state.spec.ts | 85 +++++++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index fb5af1bbda6..47ed8f72cd3 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -632,15 +632,13 @@ function readRequestBody(raw: string | null): unknown { function filterScenarioSessions( scenario: WebShellDaemonScenario, searchParams: URLSearchParams, - workspaceCwd?: string, + workspaceCwd: string, ): DaemonSessionSummary[] { const group = searchParams.get('group'); const sourceType = searchParams.get('sourceType'); - const workspaceSessions = workspaceCwd - ? scenario.sessions.filter( - (session) => session.workspaceCwd === workspaceCwd, - ) - : scenario.sessions; + const workspaceSessions = scenario.sessions.filter( + (session) => session.workspaceCwd === workspaceCwd, + ); const sourceSessions = sourceType ? workspaceSessions.filter( (session) => @@ -653,14 +651,16 @@ function filterScenarioSessions( : sourceSessions; } -function workspaceCwdFromSessionsPath(path: string): string | undefined { +function workspaceCwdFromSessionsPath(path: string): string { const workspaceMatch = path.match( /^\/workspaces\/([^/]+)\/sessions(?:\/live-state)?\/?$/, ); if (workspaceMatch) return decodeURIComponent(workspaceMatch[1]); const legacyMatch = path.match(/^\/workspace\/(.+)\/sessions\/?$/); - return legacyMatch ? decodeURIComponent(legacyMatch[1]) : undefined; + if (legacyMatch) return decodeURIComponent(legacyMatch[1]); + + throw new Error(`Unrecognized sessions path: ${path}`); } function isDaemonPath(path: string): boolean { @@ -1030,9 +1030,7 @@ async function handleDaemonRoute( v: 1, catalogVersion: scenario.sessionCatalogVersion, sessions: scenario.sessions - .filter( - (session) => !workspaceCwd || session.workspaceCwd === workspaceCwd, - ) + .filter((session) => session.workspaceCwd === workspaceCwd) .filter( (session) => (session.clientCount ?? 0) > 0 || diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 2f09e326976..1e3adbb64e2 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -811,6 +811,7 @@ for (const theme of THEMES) { workspaceCwd: primaryCwd, displayName: primarySessionName, sessions, + sessionId: 'workspace-primary-session', capabilities: { workspaces: [ { diff --git a/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts b/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts index 232a06fd8ac..d92d5b2bedc 100644 --- a/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; import { createWebShellDaemonScenario, installMockDaemon, @@ -55,3 +56,87 @@ test('uses live-state instead of polling the full session catalog @smoke', async .toBeGreaterThan(liveRequestsAfterSourceChange); expect(fullCatalogRequests()).toBe(requestsAfterSourceChange); }); + +test('scopes live-state sessions to the requested workspace', async ({ + page, +}, testInfo) => { + const primaryCwd = '/tmp/qwen-live-primary'; + const secondaryCwd = '/tmp/qwen-live-secondary'; + const sessions = [ + { + sessionId: 'primary-live', + workspaceCwd: primaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Primary live', + clientCount: 1, + hasActivePrompt: false, + }, + { + sessionId: 'secondary-live', + workspaceCwd: secondaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Secondary live', + clientCount: 1, + hasActivePrompt: false, + }, + ] satisfies DaemonSessionSummary[]; + const scenario = createWebShellDaemonScenario({ + workspaceCwd: primaryCwd, + sessionId: 'primary-live', + displayName: 'Primary live', + sessions, + capabilities: { + features: [ + 'session_events', + 'session_source_metadata', + 'workspace_session_live_state', + ], + workspaces: [ + { + id: 'primary', + cwd: primaryCwd, + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: secondaryCwd, + primary: false, + trusted: true, + }, + ], + }, + }); + await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + const baseURL = String(testInfo.project.use.baseURL); + + const primaryState = await page.evaluate( + async ({ baseURL, cwd }) => { + const response = await fetch( + `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions/live-state`, + ); + return response.json(); + }, + { baseURL, cwd: primaryCwd }, + ); + const secondaryState = await page.evaluate( + async ({ baseURL, cwd }) => { + const response = await fetch( + `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions/live-state`, + ); + return response.json(); + }, + { baseURL, cwd: secondaryCwd }, + ); + + expect(primaryState.sessions.map((session) => session.sessionId)).toEqual([ + 'primary-live', + ]); + expect(secondaryState.sessions.map((session) => session.sessionId)).toEqual([ + 'secondary-live', + ]); +}); From 8ef6f9d111c35ef336e272084390d4a7cbd20943 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Fri, 28 Aug 2026 00:36:28 +0800 Subject: [PATCH 3/8] test(serve): compare retry port exactly Parse the daemon URL port before asserting that EADDRINUSE retry skipped the occupied port, avoiding false failures on ports like 41705. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/cli/src/serve/run-qwen-serve.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 981a3eabfde..4b7efa76389 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -14420,10 +14420,10 @@ describe('runQwenServe channel worker supervisor', () => { expect(portsAttempted).toEqual([4170, 4171]); expect(handle.server.listening).toBe(true); expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); - expect(handle.url).not.toContain(':4170'); expect(new URL(handle.url).port).toBe( String((handle.server.address() as AddressInfo).port), ); + expect(new URL(handle.url).port).not.toBe('4170'); expect( stderrWrites.some((w) => w.includes('port 4170 is in use, trying 4171'), From 8dcc62235004e9648d374cabe1387caa288a920f Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Fri, 28 Aug 2026 01:59:14 +0800 Subject: [PATCH 4/8] fix(web-shell): align mock session route matching Keep mock session route capture in the dispatch path so workspace scoping cannot drift from the route guard. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../web-shell/client/e2e/utils/mockDaemon.ts | 40 +++++++------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 47ed8f72cd3..10c51233616 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -651,18 +651,6 @@ function filterScenarioSessions( : sourceSessions; } -function workspaceCwdFromSessionsPath(path: string): string { - const workspaceMatch = path.match( - /^\/workspaces\/([^/]+)\/sessions(?:\/live-state)?\/?$/, - ); - if (workspaceMatch) return decodeURIComponent(workspaceMatch[1]); - - const legacyMatch = path.match(/^\/workspace\/(.+)\/sessions\/?$/); - if (legacyMatch) return decodeURIComponent(legacyMatch[1]); - - throw new Error(`Unrecognized sessions path: ${path}`); -} - function isDaemonPath(path: string): boolean { return ( path === '/health' || @@ -687,7 +675,7 @@ function isDaemonPath(path: string): boolean { ) || /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-approvals\/?$/.test(path) || /^\/workspaces\/[^/]+\/channels\/[^/]+\/?$/.test(path) || - /^\/workspace\/.+\/sessions\/?$/.test(path) || + /^\/workspace\/[^/]+\/sessions\/?$/.test(path) || /^\/workspaces\/[^/]+\/sessions\/?$/.test(path) || /^\/workspaces\/[^/]+\/sessions\/live-state\/?$/.test(path) || /^\/workspace\/.+\/session-groups\/?$/.test(path) || @@ -778,7 +766,7 @@ function isDaemonRoute(method: string, path: string): boolean { } if ( method === 'GET' && - (/^\/workspace\/.+\/sessions\/?$/.test(path) || + (/^\/workspace\/[^/]+\/sessions\/?$/.test(path) || /^\/workspaces\/[^/]+\/sessions\/?$/.test(path)) ) { return true; @@ -1021,11 +1009,11 @@ async function handleDaemonRoute( await json(route, workspaceMcpResources(scenario, serverName)); return; } - if ( - method === 'GET' && - /^\/workspaces\/[^/]+\/sessions\/live-state\/?$/.test(path) - ) { - const workspaceCwd = workspaceCwdFromSessionsPath(path); + const workspaceLiveStateMatch = path.match( + /^\/workspaces\/([^/]+)\/sessions\/live-state\/?$/, + ); + if (method === 'GET' && workspaceLiveStateMatch) { + const workspaceCwd = decodeURIComponent(workspaceLiveStateMatch[1]); await json(route, { v: 1, catalogVersion: scenario.sessionCatalogVersion, @@ -1048,12 +1036,14 @@ async function handleDaemonRoute( }); return; } - if ( - method === 'GET' && - (/^\/workspace\/.+\/sessions\/?$/.test(path) || - /^\/workspaces\/[^/]+\/sessions\/?$/.test(path)) - ) { - const workspaceCwd = workspaceCwdFromSessionsPath(path); + const workspaceSessionsMatch = path.match( + /^\/workspaces\/([^/]+)\/sessions\/?$/, + ); + const legacySessionsMatch = path.match(/^\/workspace\/([^/]+)\/sessions\/?$/); + if (method === 'GET' && (workspaceSessionsMatch || legacySessionsMatch)) { + const workspaceCwd = decodeURIComponent( + (workspaceSessionsMatch ?? legacySessionsMatch)?.[1] ?? '', + ); await json(route, { sessions: filterScenarioSessions(scenario, searchParams, workspaceCwd), }); From c682cf2f7446fe20359cff5dc806b420d65c2cd7 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Fri, 28 Aug 2026 02:10:56 +0800 Subject: [PATCH 5/8] fix(web-shell): share mock workspace session route parsing Keep the mock daemon route guards and handlers on the same workspace-session parser so review-thread fixes cannot drift between matching and extraction. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../web-shell/client/e2e/utils/mockDaemon.ts | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 10c51233616..8a5035646fa 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -651,6 +651,37 @@ function filterScenarioSessions( : sourceSessions; } +type WorkspaceSessionsRouteMatch = { + workspaceCwd: string; + liveState: boolean; +}; + +function matchWorkspaceSessionsRoute( + path: string, +): WorkspaceSessionsRouteMatch | undefined { + const liveStateMatch = path.match( + /^\/workspaces\/([^/]+)\/sessions\/live-state\/?$/, + ); + if (liveStateMatch) { + return { + workspaceCwd: decodeURIComponent(liveStateMatch[1]), + liveState: true, + }; + } + + const sessionsMatch = + path.match(/^\/workspaces\/([^/]+)\/sessions\/?$/) ?? + path.match(/^\/workspace\/([^/]+)\/sessions\/?$/); + if (!sessionsMatch) { + return undefined; + } + + return { + workspaceCwd: decodeURIComponent(sessionsMatch[1]), + liveState: false, + }; +} + function isDaemonPath(path: string): boolean { return ( path === '/health' || @@ -675,9 +706,7 @@ function isDaemonPath(path: string): boolean { ) || /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-approvals\/?$/.test(path) || /^\/workspaces\/[^/]+\/channels\/[^/]+\/?$/.test(path) || - /^\/workspace\/[^/]+\/sessions\/?$/.test(path) || - /^\/workspaces\/[^/]+\/sessions\/?$/.test(path) || - /^\/workspaces\/[^/]+\/sessions\/live-state\/?$/.test(path) || + Boolean(matchWorkspaceSessionsRoute(path)) || /^\/workspace\/.+\/session-groups\/?$/.test(path) || /^\/workspaces\/[^/]+\/session-groups\/?$/.test(path) || /^\/workspaces\/.+\/git\/?$/.test(path) || @@ -758,17 +787,8 @@ function isDaemonRoute(method: string, path: string): boolean { ) { return true; } - if ( - method === 'GET' && - /^\/workspaces\/[^/]+\/sessions\/live-state\/?$/.test(path) - ) { - return true; - } - if ( - method === 'GET' && - (/^\/workspace\/[^/]+\/sessions\/?$/.test(path) || - /^\/workspaces\/[^/]+\/sessions\/?$/.test(path)) - ) { + const workspaceSessionsRoute = matchWorkspaceSessionsRoute(path); + if (method === 'GET' && workspaceSessionsRoute) { return true; } if ( @@ -1009,11 +1029,9 @@ async function handleDaemonRoute( await json(route, workspaceMcpResources(scenario, serverName)); return; } - const workspaceLiveStateMatch = path.match( - /^\/workspaces\/([^/]+)\/sessions\/live-state\/?$/, - ); - if (method === 'GET' && workspaceLiveStateMatch) { - const workspaceCwd = decodeURIComponent(workspaceLiveStateMatch[1]); + const workspaceSessionsRoute = matchWorkspaceSessionsRoute(path); + if (method === 'GET' && workspaceSessionsRoute?.liveState) { + const { workspaceCwd } = workspaceSessionsRoute; await json(route, { v: 1, catalogVersion: scenario.sessionCatalogVersion, @@ -1036,14 +1054,8 @@ async function handleDaemonRoute( }); return; } - const workspaceSessionsMatch = path.match( - /^\/workspaces\/([^/]+)\/sessions\/?$/, - ); - const legacySessionsMatch = path.match(/^\/workspace\/([^/]+)\/sessions\/?$/); - if (method === 'GET' && (workspaceSessionsMatch || legacySessionsMatch)) { - const workspaceCwd = decodeURIComponent( - (workspaceSessionsMatch ?? legacySessionsMatch)?.[1] ?? '', - ); + if (method === 'GET' && workspaceSessionsRoute) { + const { workspaceCwd } = workspaceSessionsRoute; await json(route, { sessions: filterScenarioSessions(scenario, searchParams, workspaceCwd), }); From 766e9045a4c26ae04a0a40bd562a4fb89af04b9e Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Fri, 28 Aug 2026 03:04:57 +0800 Subject: [PATCH 6/8] fix(web-shell): harden mocked workspace session routes Co-Authored-By: Claude Sonnet 4.6 --- .../web-shell/client/e2e/utils/mockDaemon.ts | 16 ++- .../e2e/web-shell.session-live-state.spec.ts | 113 ++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 8a5035646fa..3eec01d117d 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -656,6 +656,14 @@ type WorkspaceSessionsRouteMatch = { liveState: boolean; }; +function decodeRouteSegment(segment: string): string | undefined { + try { + return decodeURIComponent(segment); + } catch { + return undefined; + } +} + function matchWorkspaceSessionsRoute( path: string, ): WorkspaceSessionsRouteMatch | undefined { @@ -663,8 +671,10 @@ function matchWorkspaceSessionsRoute( /^\/workspaces\/([^/]+)\/sessions\/live-state\/?$/, ); if (liveStateMatch) { + const workspaceCwd = decodeRouteSegment(liveStateMatch[1]); + if (workspaceCwd === undefined) return undefined; return { - workspaceCwd: decodeURIComponent(liveStateMatch[1]), + workspaceCwd, liveState: true, }; } @@ -675,9 +685,11 @@ function matchWorkspaceSessionsRoute( if (!sessionsMatch) { return undefined; } + const workspaceCwd = decodeRouteSegment(sessionsMatch[1]); + if (workspaceCwd === undefined) return undefined; return { - workspaceCwd: decodeURIComponent(sessionsMatch[1]), + workspaceCwd, liveState: false, }; } diff --git a/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts b/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts index d92d5b2bedc..e9aed983f17 100644 --- a/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts @@ -140,3 +140,116 @@ test('scopes live-state sessions to the requested workspace', async ({ 'secondary-live', ]); }); + +test('scopes full and pinned sessions to the requested workspace', async ({ + page, +}, testInfo) => { + const primaryCwd = '/tmp/qwen-catalog-primary'; + const secondaryCwd = '/tmp/qwen-catalog-secondary'; + const sessions = [ + { + sessionId: 'primary-regular', + workspaceCwd: primaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Primary regular', + clientCount: 0, + }, + { + sessionId: 'primary-pinned', + workspaceCwd: primaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Primary pinned', + clientCount: 0, + isPinned: true, + }, + { + sessionId: 'secondary-pinned', + workspaceCwd: secondaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Secondary pinned', + clientCount: 0, + isPinned: true, + }, + ] satisfies DaemonSessionSummary[]; + const scenario = createWebShellDaemonScenario({ + workspaceCwd: primaryCwd, + sessionId: 'primary-regular', + displayName: 'Primary regular', + sessions, + capabilities: { + features: ['session_events', 'session_source_metadata'], + workspaces: [ + { + id: 'primary', + cwd: primaryCwd, + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: secondaryCwd, + primary: false, + trusted: true, + }, + ], + }, + }); + await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + const baseURL = String(testInfo.project.use.baseURL); + + const primaryCatalog = await page.evaluate( + async ({ baseURL, cwd }) => { + const response = await fetch( + `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions`, + ); + return response.json(); + }, + { baseURL, cwd: primaryCwd }, + ); + const secondaryPinned = await page.evaluate( + async ({ baseURL, cwd }) => { + const response = await fetch( + `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions?group=pinned`, + ); + return response.json(); + }, + { baseURL, cwd: secondaryCwd }, + ); + + expect( + primaryCatalog.sessions.map( + (session: DaemonSessionSummary) => session.sessionId, + ), + ).toEqual(['primary-regular', 'primary-pinned']); + expect( + secondaryPinned.sessions.map( + (session: DaemonSessionSummary) => session.sessionId, + ), + ).toEqual(['secondary-pinned']); +}); + +test('ignores malformed workspace session route encodings', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + const daemon = await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + const baseURL = String(testInfo.project.use.baseURL); + + await page.evaluate(async (baseURL) => { + await fetch(`${baseURL}/workspaces/%E0%A4%A/sessions`).catch(() => {}); + }, baseURL); + + expect(daemon.requests).not.toContainEqual( + expect.objectContaining({ + method: 'GET', + path: '/workspaces/%E0%A4%A/sessions', + }), + ); +}); From 63df202e5ee03e0b0c4b54269b18558edb23bcc0 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Fri, 28 Aug 2026 12:28:09 +0800 Subject: [PATCH 7/8] test(web-shell): align pinned session catalog fetch Co-Authored-By: Claude Sonnet 4.6 --- .../web-shell/client/e2e/web-shell.session-live-state.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts b/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts index e9aed983f17..79b2ba0a8fc 100644 --- a/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts @@ -214,7 +214,7 @@ test('scopes full and pinned sessions to the requested workspace', async ({ const secondaryPinned = await page.evaluate( async ({ baseURL, cwd }) => { const response = await fetch( - `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions?group=pinned`, + `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions?view=organized&group=pinned`, ); return response.json(); }, From 0c8a656e7c1f871fe45e64ea59e681247d8a2e9b Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Fri, 28 Aug 2026 21:14:46 +0800 Subject: [PATCH 8/8] test(web-shell): pin workspace session scoping Cover full, source-filtered, and pinned catalogs across multiple workspaces to prevent cross-workspace leakage. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../e2e/web-shell.session-live-state.spec.ts | 98 ++++++++++++++----- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts b/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts index 79b2ba0a8fc..426782f5d19 100644 --- a/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.session-live-state.spec.ts @@ -154,6 +154,7 @@ test('scopes full and pinned sessions to the requested workspace', async ({ updatedAt: '2026-07-03T00:00:00.000Z', displayName: 'Primary regular', clientCount: 0, + sourceType: 'scheduled_task', }, { sessionId: 'primary-pinned', @@ -163,6 +164,24 @@ test('scopes full and pinned sessions to the requested workspace', async ({ displayName: 'Primary pinned', clientCount: 0, isPinned: true, + sourceType: 'scheduled_task', + }, + { + sessionId: 'primary-default', + workspaceCwd: primaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Primary default', + clientCount: 0, + }, + { + sessionId: 'secondary-regular', + workspaceCwd: secondaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Secondary regular', + clientCount: 0, + sourceType: 'scheduled_task', }, { sessionId: 'secondary-pinned', @@ -172,6 +191,15 @@ test('scopes full and pinned sessions to the requested workspace', async ({ displayName: 'Secondary pinned', clientCount: 0, isPinned: true, + sourceType: 'scheduled_task', + }, + { + sessionId: 'secondary-default', + workspaceCwd: secondaryCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Secondary default', + clientCount: 0, }, ] satisfies DaemonSessionSummary[]; const scenario = createWebShellDaemonScenario({ @@ -202,35 +230,53 @@ test('scopes full and pinned sessions to the requested workspace', async ({ }); const baseURL = String(testInfo.project.use.baseURL); - const primaryCatalog = await page.evaluate( - async ({ baseURL, cwd }) => { - const response = await fetch( - `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions`, - ); - return response.json(); - }, - { baseURL, cwd: primaryCwd }, + const fetchSessions = (cwd: string, query: string) => + page.evaluate( + async ({ baseURL, cwd, query }) => { + const response = await fetch( + `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions${query}`, + ); + return response.json(); + }, + { baseURL, cwd, query }, + ) as Promise<{ sessions: DaemonSessionSummary[] }>; + + const [ + primaryCatalog, + secondaryCatalog, + primaryScheduled, + secondaryScheduled, + primaryPinned, + secondaryPinned, + ] = await Promise.all([ + fetchSessions(primaryCwd, ''), + fetchSessions(secondaryCwd, ''), + fetchSessions(primaryCwd, '?sourceType=scheduled_task'), + fetchSessions(secondaryCwd, '?sourceType=scheduled_task'), + fetchSessions(primaryCwd, '?view=organized&group=pinned'), + fetchSessions(secondaryCwd, '?view=organized&group=pinned'), + ]); + + expect(primaryCatalog.sessions.map((session) => session.sessionId)).toEqual([ + 'primary-regular', + 'primary-pinned', + 'primary-default', + ]); + expect(secondaryCatalog.sessions.map((session) => session.sessionId)).toEqual( + ['secondary-regular', 'secondary-pinned', 'secondary-default'], ); - const secondaryPinned = await page.evaluate( - async ({ baseURL, cwd }) => { - const response = await fetch( - `${baseURL}/workspaces/${encodeURIComponent(cwd)}/sessions?view=organized&group=pinned`, - ); - return response.json(); - }, - { baseURL, cwd: secondaryCwd }, + expect(primaryScheduled.sessions.map((session) => session.sessionId)).toEqual( + ['primary-regular', 'primary-pinned'], ); - - expect( - primaryCatalog.sessions.map( - (session: DaemonSessionSummary) => session.sessionId, - ), - ).toEqual(['primary-regular', 'primary-pinned']); expect( - secondaryPinned.sessions.map( - (session: DaemonSessionSummary) => session.sessionId, - ), - ).toEqual(['secondary-pinned']); + secondaryScheduled.sessions.map((session) => session.sessionId), + ).toEqual(['secondary-regular', 'secondary-pinned']); + expect(primaryPinned.sessions.map((session) => session.sessionId)).toEqual([ + 'primary-pinned', + ]); + expect(secondaryPinned.sessions.map((session) => session.sessionId)).toEqual([ + 'secondary-pinned', + ]); }); test('ignores malformed workspace session route encodings', async ({