diff --git a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts index d7913f78a29e..79710bb0c57b 100644 --- a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import type { ProviderReplayTranscript } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import { classifyClaudeNativeTool } from "../Adapters/ClaudeAdapterV2.ts"; import { @@ -27,12 +26,10 @@ import { THREAD_MERGE_BACK_SOURCE_MARKER, } from "./fixtures/shared.ts"; import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; -import { decodeProviderReplayNdjson } from "./ReplayTranscriptNdjson.ts"; +import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; const readTranscript = Effect.fn("readClaudeReplayFixture")(function* (file: URL) { - const fs = yield* FileSystem.FileSystem; - const text = yield* fs.readFileString(decodeURIComponent(file.pathname)); - return yield* decodeProviderReplayNdjson(text); + return yield* readProviderReplayTranscript(file); }, Effect.provide(NodeServices.layer)); function claudeFixture(name: string) { diff --git a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts index ecc7e14aaeda..9643dabec756 100644 --- a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts @@ -3,7 +3,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { ProviderReplayTranscript } from "@t3tools/contracts"; import * as CodexReplay from "effect-codex-app-server/replay"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; @@ -19,7 +18,7 @@ import { THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER, THREAD_MERGE_BACK_SOURCE_MARKER, } from "./fixtures/shared.ts"; -import { decodeProviderReplayNdjson } from "./ReplayTranscriptNdjson.ts"; +import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; const PROVIDER_THREAD_RESUME_FIRST_FINAL = "provider thread resume fixture first turn complete"; const PROVIDER_THREAD_RESUME_SECOND_FINAL = "provider thread resume fixture second turn complete"; @@ -327,9 +326,7 @@ const decodeCodexTranscript = Schema.decodeUnknownEffect( CodexReplay.CodexAppServerReplayTranscript, ); const readTranscript = Effect.fn("readCodexReplayFixture")(function* (file: URL) { - const fs = yield* FileSystem.FileSystem; - const text = yield* fs.readFileString(decodeURIComponent(file.pathname)); - return yield* decodeProviderReplayNdjson(text); + return yield* readProviderReplayTranscript(file); }, Effect.provide(NodeServices.layer)); function labels( diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts index 6021642ee97f..749e219beb8e 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts @@ -2,7 +2,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { OrchestrationV2Command, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; @@ -13,13 +12,11 @@ import { materializeFixtureInput, type OrchestratorFixtureInput, } from "./fixtures/shared.ts"; -import { decodeProviderReplayNdjson } from "./ReplayTranscriptNdjson.ts"; +import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; const decodeCommand = Schema.decodeUnknownEffect(OrchestrationV2Command); const readTranscript = Effect.fn("readOrchestratorReplayContractTranscript")(function* (file: URL) { - const fs = yield* FileSystem.FileSystem; - const text = yield* fs.readFileString(decodeURIComponent(file.pathname)); - return yield* decodeProviderReplayNdjson(text); + return yield* readProviderReplayTranscript(file); }, Effect.provide(NodeServices.layer)); function assertUnique(values: ReadonlyArray, label: string) { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index ffed1f80dd62..d4fe4a98e2a8 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import type { OrchestrationV2DomainEvent, ProviderReplayTranscript } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; @@ -25,15 +24,13 @@ import { } from "./ProviderReplayHarness.ts"; import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; import { - decodeProviderReplayNdjson, materializeReplayTranscriptRuntimeInstructions, materializeReplayTranscriptWorkspace, + readProviderReplayTranscript, } from "./ReplayTranscriptNdjson.ts"; const readTranscript = Effect.fn("readOrchestratorReplayTranscript")(function* (file: URL) { - const fs = yield* FileSystem.FileSystem; - const text = yield* fs.readFileString(decodeURIComponent(file.pathname)); - return yield* decodeProviderReplayNdjson(text); + return yield* readProviderReplayTranscript(file); }, Effect.provide(NodeServices.layer)); function normalizeTestError(cause: unknown): Error { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index 0fc4bf124c8f..316735feaa1e 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -42,9 +42,9 @@ import { import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; import { - decodeProviderReplayNdjson, materializeReplayTranscriptRuntimeInstructions, materializeReplayTranscriptWorkspace, + readProviderReplayTranscript, } from "./ReplayTranscriptNdjson.ts"; const FIRST_FINAL = "provider thread resume fixture first turn complete"; @@ -54,9 +54,7 @@ const decodeCodexTranscript = Schema.decodeUnknownEffect( CodexReplay.CodexAppServerReplayTranscript, ); const readRawTranscript = Effect.fn("readRecoveryTranscript")(function* (file: URL) { - const fs = yield* FileSystem.FileSystem; - const text = yield* fs.readFileString(decodeURIComponent(file.pathname)); - return yield* decodeProviderReplayNdjson(text); + return yield* readProviderReplayTranscript(file); }); const readCodexTranscript = Effect.fn("readCodexRecoveryTranscript")(function* (workspace: string) { const transcript = yield* readRawTranscript( diff --git a/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.test.ts b/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.test.ts index d494f18b7f8a..8217ebb9a4cc 100644 --- a/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.test.ts @@ -1,11 +1,16 @@ import { ProviderReplayNdjsonParseError } from "./ReplayTranscriptNdjson.ts"; +import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { decodeProviderReplayNdjson, materializeReplayTranscriptWorkspace, + readProviderReplayTranscript, } from "./ReplayTranscriptNdjson.ts"; const encodeParseError = Schema.encodeUnknownEffect(ProviderReplayNdjsonParseError); @@ -96,3 +101,75 @@ describe("decodeProviderReplayNdjson", () => { }), ); }); + +const FILE_URL_TRANSCRIPT = `{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"file-url-read"} +{"type":"runtime_exit","status":"success"} +`; + +it.layer(NodeServices.layer)("readProviderReplayTranscript", (it) => { + it.effect("loads a transcript behind a file URL containing an encoded space", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3 replay fixture " }); + const filePath = path.join(dir, "transcript.ndjson"); + yield* fs.writeFileString(filePath, FILE_URL_TRANSCRIPT); + + const fileUrl = yield* path.toFileUrl(filePath); + assert.isTrue(fileUrl.pathname.includes("%20")); + + const transcript = yield* readProviderReplayTranscript(fileUrl); + assert.equal(transcript.scenario, "file-url-read"); + }), + ); + + it.effect("passes drive-letter and UNC file URLs through the Windows path service", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const requestedPaths: Array = []; + const recordingFs = FileSystem.FileSystem.of({ + ...fs, + readFileString: (filePath: string) => { + requestedPaths.push(filePath); + return Effect.succeed(FILE_URL_TRANSCRIPT); + }, + }); + const readAsWindows = (file: URL) => + readProviderReplayTranscript(file).pipe( + Effect.provideService(FileSystem.FileSystem, recordingFs), + Effect.provide(NodePath.layerWin32), + ); + + const driveLetter = yield* readAsWindows( + new URL("file:///C:/Users/dev/t3%20worktree/transcript.ndjson"), + ); + const unc = yield* readAsWindows(new URL("file://fileserver/shared/transcript.ndjson")); + + assert.equal(driveLetter.scenario, "file-url-read"); + assert.equal(unc.scenario, "file-url-read"); + assert.deepEqual(requestedPaths, [ + "C:\\Users\\dev\\t3 worktree\\transcript.ndjson", + "\\\\fileserver\\shared\\transcript.ndjson", + ]); + }), + ); + + it.effect("rejects non-file URLs instead of decoding their pathname", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const recordingFs = FileSystem.FileSystem.of({ + ...fs, + readFileString: () => Effect.succeed(FILE_URL_TRANSCRIPT), + }); + const error = yield* readProviderReplayTranscript( + new URL("https://example.com/transcript.ndjson"), + ).pipe( + Effect.provideService(FileSystem.FileSystem, recordingFs), + Effect.provide(NodePath.layerPosix), + Effect.flip, + ); + + assert.equal(error._tag, "BadArgument"); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts b/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts index 1031e8ba04a8..1b62ca0f126e 100644 --- a/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts +++ b/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts @@ -6,6 +6,8 @@ import { type ProviderReplayTranscriptHeader, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; @@ -230,3 +232,17 @@ export function decodeProviderReplayNdjson( }); }); } + +/** + * Reads a provider replay transcript from a `file:` URL and decodes it. + * Conversion goes through the `Path` service so drive-letter and UNC fixture + * URLs resolve to native paths on Windows instead of `/C:/...` pathname strings. + */ +export const readProviderReplayTranscript = Effect.fn("readProviderReplayTranscript")(function* ( + file: URL, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const text = yield* fs.readFileString(yield* path.fromFileUrl(file)); + return yield* decodeProviderReplayNdjson(text); +});