From 686a25fb6549066ca09f479ebdbaeba99c3ea848 Mon Sep 17 00:00:00 2001 From: accx Date: Sun, 17 May 2026 20:32:28 +0800 Subject: [PATCH] Fix Windows import session path matching (#1012) --- .../src/server/agent/import-sessions.ts | 4 +- .../agent/providers/codex-app-server-agent.ts | 4 +- .../agent/providers/opencode-agent.test.ts | 41 ++++++++- .../server/agent/providers/opencode-agent.ts | 8 +- packages/server/src/utils/path.test.ts | 22 +++++ packages/server/src/utils/path.ts | 85 ++++++++++++++++++- 6 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/utils/path.test.ts diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index fac29eb9f..9741b1418 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -20,6 +20,7 @@ import type { RecentProviderSessionDescriptorPayload, } from "../../shared/messages.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; +import { createPathEquivalenceMatcher } from "../../utils/path.js"; type ImportAgentRequestMessage = z.infer; @@ -111,8 +112,9 @@ export async function listImportableProviderSessions( }); let filteredAlreadyImportedCount = 0; const candidates: PersistedAgentDescriptor[] = []; + const matchesRequestCwd = request.cwd ? createPathEquivalenceMatcher(request.cwd) : null; for (const descriptor of descriptors) { - if (request.cwd && descriptor.cwd !== request.cwd) { + if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) { continue; } if (sinceTimestamp !== null && descriptor.lastActivityAt.getTime() < sinceTimestamp) { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 392defffd..bdac82a7d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -53,6 +53,7 @@ import { type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; +import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; @@ -737,7 +738,8 @@ function filterCodexThreadsByCwd( // falls back to process.cwd() if the field is missing, so we only match // here when the row genuinely carries a cwd string — otherwise threads // with no cwd would falsely match the daemon's own cwd. - return threads.filter((thread) => typeof thread.cwd === "string" && thread.cwd === cwd); + const matchesCwd = createPathEquivalenceMatcher(cwd); + return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd)); } function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined { diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 7d68c1dc9..7e2c0dec1 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1267,12 +1267,51 @@ describe("OpenCode persisted sessions", () => { ]); expect(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]); expect(openCodeClient.calls.experimentalSessionList).toEqual([ - { directory: cwd, archived: true, roots: true, limit: 1 }, + { archived: true, roots: true, limit: 200 }, ]); expect(openCodeClient.calls.sessionMessages).toEqual([ { sessionID: "ses_new", directory: cwd }, ]); }); + + test("listPersistedAgents matches Windows cwd paths with forward slashes", async () => { + const runtime = new TestOpenCodeRuntime(); + const openCodeClient = new TestOpenCodeClient(); + const requestedCwd = "C:/Users/Administrator/GhostFactory"; + const storedCwd = "C:\\Users\\Administrator\\GhostFactory"; + + openCodeClient.experimentalSessionListResponse = { + data: [ + { + id: "ses_windows", + directory: storedCwd, + title: "Windows session", + time: { created: 2000, updated: 3000 }, + }, + { + id: "ses_other", + directory: "C:\\Users\\Administrator\\OtherProject", + title: "Other cwd", + time: { created: 4000, updated: 4000 }, + }, + ], + }; + runtime.enqueueClient(openCodeClient); + + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); + const descriptors = await client.listPersistedAgents({ cwd: requestedCwd, limit: 1 }); + + expect(descriptors).toHaveLength(1); + expect(descriptors[0]).toMatchObject({ + provider: "opencode", + sessionId: "ses_windows", + cwd: storedCwd, + title: "Windows session", + }); + expect(openCodeClient.calls.experimentalSessionList).toEqual([ + { archived: true, roots: true, limit: 200 }, + ]); + }); }); function createTestDeferred(): { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3fc5d2678..cf5fee38e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -11,6 +11,7 @@ import { type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; +import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import type { Logger } from "pino"; import { z } from "zod"; @@ -697,11 +698,11 @@ async function collectOpenCodePersistedAgentsFromSdk( options?: ListPersistedAgentsOptions, ): Promise { const limit = options?.limit ?? OPENCODE_PERSISTED_SESSION_LIMIT; + const sessionListLimit = options?.cwd ? Math.max(limit, OPENCODE_PERSISTED_SESSION_LIMIT) : limit; const response = await client.experimental.session.list({ - ...(options?.cwd ? { directory: options.cwd } : {}), archived: true, roots: true, - limit, + limit: sessionListLimit, }); if (response.error) { @@ -709,8 +710,9 @@ async function collectOpenCodePersistedAgentsFromSdk( } const sessions = response.data ?? []; + const matchesCwd = options?.cwd ? createPathEquivalenceMatcher(options.cwd) : null; const candidates = sessions - .filter((session) => !options?.cwd || session.directory === options.cwd) + .filter((session) => !matchesCwd || matchesCwd(session.directory)) .sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left)) .slice(0, limit); diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts new file mode 100644 index 000000000..644b436cc --- /dev/null +++ b/packages/server/src/utils/path.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; + +import { areEquivalentPaths, createPathEquivalenceMatcher } from "./path.js"; + +describe("path equivalence", () => { + test.each([ + ["C:/Users/Administrator/GhostFactory", "C:\\Users\\Administrator\\GhostFactory"], + ["d:\\Projects\\paseo", "D:\\Projects\\paseo"], + ["C:\\Users\\Administrator\\GhostFactory\\", "C:\\Users\\Administrator\\GhostFactory"], + [String.raw`\\?\C:\Users\Administrator\GhostFactory`, "C:\\Users\\Administrator\\GhostFactory"], + [String.raw`\\?\UNC\server\share\GhostFactory`, String.raw`\\server\share\GhostFactory`], + ])("matches Windows-equivalent cwd forms", (left, right) => { + expect(areEquivalentPaths(left, right)).toBe(true); + expect(createPathEquivalenceMatcher(left)(right)).toBe(true); + }); + + test("keeps POSIX path casing significant", () => { + expect( + areEquivalentPaths("/Users/Administrator/GhostFactory", "/users/administrator/ghostfactory"), + ).toBe(false); + }); +}); diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 48d838f34..738346361 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -1,4 +1,5 @@ -import os from "os"; +import os from "node:os"; +import nodePath from "node:path"; /** * Expand tilde in path to home directory @@ -13,3 +14,85 @@ export function expandTilde(path: string): string { } return path; } + +/** + * Compare two path strings as filesystem-equivalent for cwd filtering. + * + * This is a string-only comparison: it normalizes separators and dot segments, + * ignores trailing separators, strips Windows namespace prefixes, and + * case-folds only when comparing as Windows. It does not resolve symlinks or + * check whether either path exists. + */ +export function areEquivalentPaths(left: string, right: string): boolean { + const compareAsWindows = shouldCompareAsWindows(left, right); + return ( + normalizePathForComparison(left, compareAsWindows) === + normalizePathForComparison(right, compareAsWindows) + ); +} + +export function createPathEquivalenceMatcher(target: string): (candidate: string) => boolean { + const targetLooksWindows = looksLikeDefiniteWindowsPath(target); + const compareAsWindows = targetLooksWindows; + const normalizedTarget = normalizePathForComparison(target, compareAsWindows); + + return (candidate) => { + const candidateCompareAsWindows = compareAsWindows || looksLikeDefiniteWindowsPath(candidate); + const comparableTarget = + candidateCompareAsWindows === compareAsWindows + ? normalizedTarget + : normalizePathForComparison(target, candidateCompareAsWindows); + return normalizePathForComparison(candidate, candidateCompareAsWindows) === comparableTarget; + }; +} + +function shouldCompareAsWindows(left: string, right: string): boolean { + return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right); +} + +function looksLikeDefiniteWindowsPath(value: string): boolean { + return ( + /^[a-zA-Z]:[\\/]/u.test(value) || + /^[/\\]{2}\?[/\\]/u.test(value) || + /^\\{2}[^/\\]+[/\\][^/\\]+/u.test(value) + ); +} + +function normalizePathForComparison(value: string, compareAsWindows: boolean): string { + const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; + const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value; + const platformNormalized = platformPath.normalize(comparableValue); + const normalized = stripTrailingSeparators( + platformNormalized, + platformPath.parse(platformNormalized).root, + compareAsWindows, + ); + return compareAsWindows ? normalized.toLowerCase() : normalized; +} + +function stripWindowsNamespacePrefix(value: string): string { + const driveMatch = value.match(/^[/\\]{2}\?[/\\]([a-zA-Z]:)[/\\](.*)$/u); + const drivePrefix = driveMatch?.[1]; + if (drivePrefix) { + return `${drivePrefix}\\${driveMatch[2] ?? ""}`; + } + + const uncMatch = value.match(/^[/\\]{2}\?[/\\]UNC[/\\]([^/\\]+)[/\\]([^/\\]+)(?:[/\\](.*))?$/iu); + const uncServer = uncMatch?.[1]; + const uncShare = uncMatch?.[2]; + if (uncServer && uncShare) { + const uncRest = uncMatch[3]; + return `\\\\${uncServer}\\${uncShare}${uncRest !== undefined ? `\\${uncRest}` : ""}`; + } + + return value; +} + +function stripTrailingSeparators(value: string, root: string, compareAsWindows: boolean): string { + const separatorPattern = compareAsWindows ? /[\\/]/u : /\//u; + let result = value; + while (result.length > root.length && separatorPattern.test(result.at(-1) ?? "")) { + result = result.slice(0, -1); + } + return result; +}