diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 82d179b53a8..105f971b2c9 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -1,4 +1,5 @@ import { Effect, Layer, LayerMap } from "effect" +import path from "path" import { Agent } from "./agent.js" import { AISDK } from "./aisdk.js" import { Catalog } from "./catalog.js" @@ -49,6 +50,7 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js" import { Tool } from "./tool.js" import { ToolOutput } from "./tool-output.js" import { Vcs } from "./vcs.js" +import { AbsolutePath } from "./schema.js" export { LocationServiceMap } from "./location-service-map.js" @@ -110,11 +112,13 @@ export type LocationError = LayerNode.Error export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { - // Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded - // payloads omit optional keys) and `{ directory, workspaceID: undefined }` are - // different RcMap keys. The RcMap caches by the raw key before the build - // callback runs, so canonicalize at the map boundary to the key-present shape. - const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }) + // Structural Equal distinguishes optional-key shape and Windows separator style. + // The RcMap caches the raw key before the build callback, so normalize both here. + const canonical = (ref: Location.Ref) => + Location.Ref.make({ + directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory), + workspaceID: ref.workspaceID, + }) return Layer.effect( LocationServiceMap.Service, Effect.map( diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 02ac8aefa02..75a988dcf08 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -510,7 +510,7 @@ describe("LocationServiceMap", () => { ), ) - it.live("normalizes ref key shapes to one cached location graph", () => + it.live("normalizes equivalent refs to one cached location graph", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), @@ -520,16 +520,20 @@ describe("LocationServiceMap", () => { Effect.gen(function* () { const locations = yield* LocationServiceMap.Service const directory = AbsolutePath.make(dir.path) - const absent = Location.Ref.make({ directory }) + const alternate = AbsolutePath.make(directory.replaceAll("\\", "/")) + const absent = Location.Ref.make({ directory: alternate }) const present = Location.Ref.make({ directory, workspaceID: undefined }) // The two shapes are not structurally Equal: own-key sets differ. expect(Object.keys(absent)).toEqual(["directory"]) expect(Object.keys(present)).toEqual(["directory", "workspaceID"]) expect(Equal.equals(absent, present)).toBe(false) + if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory) const first = yield* locations.contextEffect(absent) expect(yield* locations.contextEffect(present)).toBe(first) - expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([ + Location.Ref.make({ directory, workspaceID: undefined }), + ]) // Invalidating with the shape opposite to the one that booted must evict. yield* locations.invalidate(present)