diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 275fa2956c44..6c62f0921bc4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -1,4 +1,5 @@ import { WorkspaceID } from "@/control-plane/schema" +import { sameDirectory } from "@/session/directory" import { SessionV2 } from "@/v2/session" import { Effect, Schema } from "effect" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -43,7 +44,7 @@ function hasCursorRoutingMismatch( decoded: SessionCursor | undefined, ) { if (!decoded) return false - if (query.directory !== undefined && query.directory !== decoded.directory) return true + if (query.directory !== undefined && !sameDirectory(query.directory, decoded.directory)) return true return query.workspace !== undefined && query.workspace !== decoded.workspaceID } diff --git a/packages/opencode/src/session/directory.ts b/packages/opencode/src/session/directory.ts new file mode 100644 index 000000000000..8cffe6e1c954 --- /dev/null +++ b/packages/opencode/src/session/directory.ts @@ -0,0 +1,31 @@ +import { AppFileSystem } from "@opencode-ai/core/filesystem" + +export function directoryVariants(input: string | undefined) { + if (!input) return [] + const values = new Set() + values.add(input) + + const resolved = resolveDirectory(input) + values.add(resolved) + + if (process.platform !== "win32") return [...values] + + values.add(input.replaceAll("\\", "/")) + values.add(resolved.replaceAll("\\", "/")) + return [...values] +} + +export function sameDirectory(a: string | undefined, b: string | undefined) { + if (a === b) return true + if (!a || !b) return false + const values = new Set(directoryVariants(a)) + return directoryVariants(b).some((value) => values.has(value)) +} + +function resolveDirectory(input: string) { + try { + return AppFileSystem.resolve(input) + } catch { + return input + } +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 797a635eeef8..520e761743cd 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -28,6 +28,7 @@ import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" +import { directoryVariants } from "./directory" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" @@ -894,6 +895,7 @@ function* listByProject( }, ) { const conditions = [eq(SessionTable.project_id, input.projectID)] + const directories = directoryVariants(input.directory) if (input.workspaceID) { conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) @@ -903,14 +905,14 @@ function* listByProject( const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] conditions.push( - input.directory - ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! + directories.length > 0 + ? or(...conds, and(isNull(SessionTable.path), matchDirectory(SessionTable.directory, directories))!)! : or(...conds)!, ) } } else if (input.scope !== "project" && !input.experimentalWorkspaces) { - if (input.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + if (directories.length > 0) { + conditions.push(matchDirectory(SessionTable.directory, directories)) } } if (input.roots) { @@ -949,9 +951,10 @@ export function* listGlobal(input?: { archived?: boolean }) { const conditions: SQL[] = [] + const directories = directoryVariants(input?.directory) - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + if (directories.length > 0) { + conditions.push(matchDirectory(SessionTable.directory, directories)) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -1008,4 +1011,9 @@ export function* listGlobal(input?: { } } +function matchDirectory(column: typeof SessionTable.directory, directories: string[]) { + if (directories.length === 1) return eq(column, directories[0]!) + return or(...directories.map((directory) => eq(column, directory)))! +} + export * as Session from "./session" diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index a3c386f66d4f..eab3fae3e640 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -14,6 +14,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { EventV2Bridge } from "@/event-v2-bridge" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" +import { directoryVariants } from "@/session/directory" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", @@ -181,7 +182,8 @@ export const layer = Layer.effect( if (direction === "previous" && order === "asc") order = "desc" if (direction === "previous" && order === "desc") order = "asc" const conditions: SQL[] = [] - if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) + const directories = directoryVariants(input.directory) + if (directories.length > 0) conditions.push(matchDirectory(directories)) if (input.path) conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) @@ -334,6 +336,11 @@ export const layer = Layer.effect( }), ) +function matchDirectory(directories: string[]) { + if (directories.length === 1) return eq(SessionTable.directory, directories[0]!) + return or(...directories.map((directory) => eq(SessionTable.directory, directory)))! +} + export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as SessionV2 from "./session" diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 4aab0c4b1f37..bd676c1db44d 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -14,6 +14,7 @@ import { Storage } from "@/storage/storage" import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" import { BackgroundJob } from "@/background/job" +import { sameDirectory } from "@/session/directory" void Log.init({ print: false }) const it = testEffect( @@ -37,6 +38,13 @@ afterEach(async () => { }) describe("session.list", () => { + it.effect("treats slash-equivalent Windows directories as equal", () => { + if (process.platform !== "win32") return Effect.void + expect(sameDirectory("C:/repo/demo", "C:\\repo\\demo")).toBe(true) + expect(sameDirectory("C:/repo/demo", "C:/repo/other")).toBe(false) + return Effect.void + }) + it.instance( "does not filter by directory when directory is omitted", () => @@ -65,6 +73,26 @@ describe("session.list", () => { { git: true }, ) + it.instance( + "matches slash-equivalent directory filters on Windows", + () => + Effect.gen(function* () { + if (process.platform !== "win32") return + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) + + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + + const ids = (yield* SessionNs.Service.use((session) => + session.list({ directory: path.join(test.directory, "packages", "opencode").replaceAll("\\", "/") }), + )).map((session) => session.id) + expect(ids).toContain(current.id) + }), + { git: true }, + ) + it.instance( "filters by directory when directory is provided", () => @@ -95,6 +123,35 @@ describe("session.list", () => { { git: true }, ) + it.instance( + "matches legacy slash-stored directory rows on Windows", + () => + Effect.gen(function* () { + if (process.platform !== "win32") return + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true })) + + const currentDir = path.join(test.directory, "packages", "opencode", "src") + const current = yield* withSession({ title: "legacy-current" }).pipe(provideInstance(currentDir)) + + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ path: null, directory: currentDir.replaceAll("\\", "/") }) + .where(eq(SessionTable.id, current.id)) + .run(), + ), + ) + + const ids = (yield* SessionNs.Service.use((session) => + session.list({ directory: currentDir, path: "packages/opencode/src" }), + )).map((session) => session.id) + expect(ids).toContain(current.id) + }), + { git: true }, + ) + it.instance( "filters by path and ignores directory when path is provided", () =>