| ... | ... | @@ -5,8 +5,10 @@ import * as Context from "effect/Context"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | 6 | import * as Exit from "effect/Exit"; |
| 7 | 7 | import * as Fiber from "effect/Fiber"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 8 | 9 | import * as Layer from "effect/Layer"; |
| 9 | 10 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; |
| 10 | 12 | import * as Schema from "effect/Schema"; |
| 11 | 13 | import * as Scope from "effect/Scope"; |
| 12 | 14 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -31,6 +33,8 @@ import { |
| 31 | 33 | } from "../opencodeRuntime.ts"; |
| 32 | 34 | import { |
| 33 | 35 | appendOpenCodeAssistantTextDelta, |
| 36 | isOpenCodeNotFound, |
| 37 | isSameOpenCodeDirectory, |
| 34 | 38 | makeOpenCodeAdapter, |
| 35 | 39 | mergeOpenCodeAssistantText, |
| 36 | 40 | } from "./OpenCodeAdapter.ts"; |
| ... | ... | @@ -64,6 +68,12 @@ const runtimeMock = { |
| 64 | 68 | closeError: null as Error | null, |
| 65 | 69 | messages: [] as MessageEntry[], |
| 66 | 70 | subscribedEvents: [] as unknown[], |
| 71 | sessionGetIds: [] as string[], |
| 72 | missingSessionIds: new Set<string>(), |
| 73 | transientErrorSessionIds: new Set<string>(), |
| 74 | sessionDirectoryById: new Map<string, string>(), |
| 75 | sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, |
| 76 | forkCalls: [] as Array<{ sessionID: string; directory?: string }>, |
| 67 | 77 | }, |
| 68 | 78 | reset() { |
| 69 | 79 | this.state.startCalls.length = 0; |
| ... | ... | @@ -78,6 +88,12 @@ const runtimeMock = { |
| 78 | 88 | this.state.closeError = null; |
| 79 | 89 | this.state.messages = []; |
| 80 | 90 | this.state.subscribedEvents = []; |
| 91 | this.state.sessionGetIds.length = 0; |
| 92 | this.state.missingSessionIds.clear(); |
| 93 | this.state.transientErrorSessionIds.clear(); |
| 94 | this.state.sessionDirectoryById.clear(); |
| 95 | this.state.sessionUpdateCalls.length = 0; |
| 96 | this.state.forkCalls.length = 0; |
| 81 | 97 | }, |
| 82 | 98 | }; |
| 83 | 99 | |
| ... | ... | @@ -102,10 +118,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 102 | 118 | connectToOpenCodeServer: ({ serverUrl }) => |
| 103 | 119 | Effect.gen(function* () { |
| 104 | 120 | const url = serverUrl ?? "http://127.0.0.1:4301"; |
| 105 | | // Unconditionally register a scope finalizer for test observability — |
| 106 | | // preserves the `closeCalls` / `closeError` probes that the existing |
| 107 | | // suites rely on. Production code never attaches a finalizer to an |
| 108 | | // external server (it simply returns `Effect.succeed(...)`). |
| 121 | // Always register a finalizer so the closeCalls/closeError probes fire; |
| 122 | // production attaches none for external servers. |
| 109 | 123 | yield* Effect.addFinalizer(() => |
| 110 | 124 | Effect.sync(() => { |
| 111 | 125 | runtimeMock.state.closeCalls.push(url); |
| ... | ... | @@ -132,6 +146,34 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 132 | 146 | ); |
| 133 | 147 | return { data: { id: `${baseUrl}/session` } }; |
| 134 | 148 | }, |
| 149 | get: async ({ sessionID }: { sessionID: string }) => { |
| 150 | runtimeMock.state.sessionGetIds.push(sessionID); |
| 151 | // The real client is `throwOnError: true`: non-2xx rejects rather |
| 152 | // than resolving, so missing → 404 throw, transient → 500 throw. |
| 153 | if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { |
| 154 | throw new Error("opencode server error", { cause: { status: 500 } }); |
| 155 | } |
| 156 | if (runtimeMock.state.missingSessionIds.has(sessionID)) { |
| 157 | throw new Error(`Session not found: ${sessionID}`, { |
| 158 | cause: { status: 404, body: { name: "NotFoundError" } }, |
| 159 | }); |
| 160 | } |
| 161 | const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); |
| 162 | return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; |
| 163 | }, |
| 164 | update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { |
| 165 | runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); |
| 166 | return { data: { id: sessionID } }; |
| 167 | }, |
| 168 | fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => { |
| 169 | // Fork clones history into a new session bound to the directory. |
| 170 | const forkedId = `${sessionID}_fork`; |
| 171 | runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) }); |
| 172 | if (directory) { |
| 173 | runtimeMock.state.sessionDirectoryById.set(forkedId, directory); |
| 174 | } |
| 175 | return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; |
| 176 | }, |
| 135 | 177 | abort: async ({ sessionID }: { sessionID: string }) => { |
| 136 | 178 | runtimeMock.state.abortCalls.push(sessionID); |
| 137 | 179 | }, |
| ... | ... | @@ -251,6 +293,256 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 251 | 293 | }), |
| 252 | 294 | ); |
| 253 | 295 | |
| 296 | it.effect("returns a durable resume cursor for a freshly created session", () => |
| 297 | Effect.gen(function* () { |
| 298 | const adapter = yield* OpenCodeAdapter; |
| 299 | const threadId = asThreadId("thread-opencode-cursor"); |
| 300 | |
| 301 | const session = yield* adapter.startSession({ |
| 302 | provider: ProviderDriverKind.make("opencode"), |
| 303 | threadId, |
| 304 | runtimeMode: "full-access", |
| 305 | }); |
| 306 | |
| 307 | // Without a persisted cursor, a session is created and its id is |
| 308 | // surfaced as a resume cursor so the upper layer can persist it. |
| 309 | NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); |
| 310 | NodeAssert.deepEqual(session.resumeCursor, { |
| 311 | schemaVersion: 1, |
| 312 | sessionId: "http://127.0.0.1:9999/session", |
| 313 | }); |
| 314 | |
| 315 | yield* adapter.stopSession(threadId); |
| 316 | }), |
| 317 | ); |
| 318 | |
| 319 | it.effect("resumes the persisted OpenCode session instead of creating a new one", () => |
| 320 | Effect.gen(function* () { |
| 321 | const adapter = yield* OpenCodeAdapter; |
| 322 | const threadId = asThreadId("thread-opencode-resume"); |
| 323 | |
| 324 | const session = yield* adapter.startSession({ |
| 325 | provider: ProviderDriverKind.make("opencode"), |
| 326 | threadId, |
| 327 | runtimeMode: "full-access", |
| 328 | resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, |
| 329 | }); |
| 330 | |
| 331 | // The adapter validates the persisted id with session.get and re-adopts |
| 332 | // it — no new session is minted (issue #3604). |
| 333 | NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_persisted"]); |
| 334 | NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); |
| 335 | NodeAssert.deepEqual(session.resumeCursor, { |
| 336 | schemaVersion: 1, |
| 337 | sessionId: "ses_persisted", |
| 338 | }); |
| 339 | // Resume re-asserts the permission ruleset for the current runtimeMode. |
| 340 | NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); |
| 341 | NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_persisted"); |
| 342 | NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); |
| 343 | |
| 344 | yield* adapter.stopSession(threadId); |
| 345 | }), |
| 346 | ); |
| 347 | |
| 348 | it.effect("sends follow-up turns to the resumed session id", () => |
| 349 | Effect.gen(function* () { |
| 350 | const adapter = yield* OpenCodeAdapter; |
| 351 | const threadId = asThreadId("thread-opencode-resume-turn"); |
| 352 | |
| 353 | yield* adapter.startSession({ |
| 354 | provider: ProviderDriverKind.make("opencode"), |
| 355 | threadId, |
| 356 | runtimeMode: "full-access", |
| 357 | resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, |
| 358 | }); |
| 359 | |
| 360 | const result = yield* adapter.sendTurn({ |
| 361 | threadId, |
| 362 | input: "continue where we left off", |
| 363 | modelSelection: createModelSelection( |
| 364 | ProviderInstanceId.make("opencode"), |
| 365 | "anthropic/sonnet", |
| 366 | ), |
| 367 | }); |
| 368 | |
| 369 | // The prompt targets the resumed id, and the turn re-surfaces the cursor. |
| 370 | NodeAssert.deepEqual( |
| 371 | (runtimeMock.state.promptCalls[0] as { sessionID: string }).sessionID, |
| 372 | "ses_persisted", |
| 373 | ); |
| 374 | NodeAssert.deepEqual(result.resumeCursor, { |
| 375 | schemaVersion: 1, |
| 376 | sessionId: "ses_persisted", |
| 377 | }); |
| 378 | |
| 379 | yield* adapter.stopSession(threadId); |
| 380 | }), |
| 381 | ); |
| 382 | |
| 383 | it.effect("falls back to a fresh session when the persisted session is gone", () => |
| 384 | Effect.gen(function* () { |
| 385 | const adapter = yield* OpenCodeAdapter; |
| 386 | const threadId = asThreadId("thread-opencode-stale"); |
| 387 | runtimeMock.state.missingSessionIds.add("ses_stale"); |
| 388 | |
| 389 | const session = yield* adapter.startSession({ |
| 390 | provider: ProviderDriverKind.make("opencode"), |
| 391 | threadId, |
| 392 | runtimeMode: "full-access", |
| 393 | resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" }, |
| 394 | }); |
| 395 | |
| 396 | // get probed the stale id, found nothing, then created a new session and |
| 397 | // emitted a fresh cursor rather than wedging the thread. |
| 398 | NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]); |
| 399 | NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); |
| 400 | NodeAssert.deepEqual(session.resumeCursor, { |
| 401 | schemaVersion: 1, |
| 402 | sessionId: "http://127.0.0.1:9999/session", |
| 403 | }); |
| 404 | |
| 405 | yield* adapter.stopSession(threadId); |
| 406 | }), |
| 407 | ); |
| 408 | |
| 409 | it.effect("ignores a malformed or wrong-version resume cursor", () => |
| 410 | Effect.gen(function* () { |
| 411 | const adapter = yield* OpenCodeAdapter; |
| 412 | const threadId = asThreadId("thread-opencode-badcursor"); |
| 413 | |
| 414 | const session = yield* adapter.startSession({ |
| 415 | provider: ProviderDriverKind.make("opencode"), |
| 416 | threadId, |
| 417 | runtimeMode: "full-access", |
| 418 | resumeCursor: { schemaVersion: 99, sessionId: "ses_persisted" }, |
| 419 | }); |
| 420 | |
| 421 | // A foreign/stale-shaped cursor is treated as "no resume": never probed, |
| 422 | // a fresh session is created. |
| 423 | NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); |
| 424 | NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); |
| 425 | NodeAssert.deepEqual(session.resumeCursor, { |
| 426 | schemaVersion: 1, |
| 427 | sessionId: "http://127.0.0.1:9999/session", |
| 428 | }); |
| 429 | |
| 430 | yield* adapter.stopSession(threadId); |
| 431 | }), |
| 432 | ); |
| 433 | |
| 434 | it.effect("surfaces a non-not-found resume probe error instead of silently starting fresh", () => |
| 435 | Effect.gen(function* () { |
| 436 | const adapter = yield* OpenCodeAdapter; |
| 437 | const threadId = asThreadId("thread-opencode-transient"); |
| 438 | // session.get returns a 500 (not a 404) for this id. |
| 439 | runtimeMock.state.transientErrorSessionIds.add("ses_transient"); |
| 440 | |
| 441 | const exit = yield* Effect.exit( |
| 442 | adapter.startSession({ |
| 443 | provider: ProviderDriverKind.make("opencode"), |
| 444 | threadId, |
| 445 | runtimeMode: "full-access", |
| 446 | resumeCursor: { schemaVersion: 1, sessionId: "ses_transient" }, |
| 447 | }), |
| 448 | ); |
| 449 | |
| 450 | // A transient/transport/auth failure must propagate — NOT be masked as a |
| 451 | // brand-new empty session (the #3604 class of silent context loss). |
| 452 | NodeAssert.equal(Exit.isFailure(exit), true); |
| 453 | NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_transient"]); |
| 454 | NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); |
| 455 | }), |
| 456 | ); |
| 457 | |
| 458 | it.effect("re-applies the current runtimeMode permissions when resuming", () => |
| 459 | Effect.gen(function* () { |
| 460 | const adapter = yield* OpenCodeAdapter; |
| 461 | const threadId = asThreadId("thread-opencode-perms"); |
| 462 | |
| 463 | yield* adapter.startSession({ |
| 464 | provider: ProviderDriverKind.make("opencode"), |
| 465 | // A different runtimeMode than the original create — resume must not |
| 466 | // leave the upstream session on stale permissions. |
| 467 | runtimeMode: "approval-required", |
| 468 | threadId, |
| 469 | resumeCursor: { schemaVersion: 1, sessionId: "ses_perms" }, |
| 470 | }); |
| 471 | |
| 472 | NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_perms"]); |
| 473 | NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); |
| 474 | NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); |
| 475 | NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_perms"); |
| 476 | NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); |
| 477 | |
| 478 | yield* adapter.stopSession(threadId); |
| 479 | }), |
| 480 | ); |
| 481 | |
| 482 | it.effect( |
| 483 | "forks the resumed session into the requested directory instead of losing context", |
| 484 | () => |
| 485 | Effect.gen(function* () { |
| 486 | const adapter = yield* OpenCodeAdapter; |
| 487 | const threadId = asThreadId("thread-opencode-cwd"); |
| 488 | // The persisted session still exists but was created in another working dir |
| 489 | // (e.g. the thread moved from the project root into a git worktree). |
| 490 | runtimeMock.state.sessionDirectoryById.set("ses_otherdir", "/some/other/worktree"); |
| 491 | |
| 492 | const session = yield* adapter.startSession({ |
| 493 | provider: ProviderDriverKind.make("opencode"), |
| 494 | threadId, |
| 495 | runtimeMode: "full-access", |
| 496 | resumeCursor: { schemaVersion: 1, sessionId: "ses_otherdir" }, |
| 497 | }); |
| 498 | |
| 499 | // A cwd change must not mint an empty session: the adapter forks the |
| 500 | // persisted session into the requested cwd, carrying history forward. |
| 501 | NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_otherdir"]); |
| 502 | NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); |
| 503 | NodeAssert.equal(runtimeMock.state.forkCalls.length, 1); |
| 504 | NodeAssert.equal(runtimeMock.state.forkCalls[0]?.sessionID, "ses_otherdir"); |
| 505 | NodeAssert.equal(typeof runtimeMock.state.forkCalls[0]?.directory, "string"); |
| 506 | // Permission ruleset re-asserted on the fork for the current runtimeMode. |
| 507 | NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); |
| 508 | NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_otherdir_fork"); |
| 509 | // Durable cursor now points at the history-complete fork in the new directory. |
| 510 | NodeAssert.deepEqual(session.resumeCursor, { |
| 511 | schemaVersion: 1, |
| 512 | sessionId: "ses_otherdir_fork", |
| 513 | }); |
| 514 | |
| 515 | yield* adapter.stopSession(threadId); |
| 516 | }), |
| 517 | ); |
| 518 | |
| 519 | it.effect("reuses the resumed session when the stored directory differs only lexically", () => |
| 520 | Effect.gen(function* () { |
| 521 | const adapter = yield* OpenCodeAdapter; |
| 522 | const threadId = asThreadId("thread-opencode-samedir"); |
| 523 | // Same working tree, different spelling (trailing slash) — must reuse, |
| 524 | // not fork. |
| 525 | runtimeMock.state.sessionDirectoryById.set("ses_samedir", `${process.cwd()}/`); |
| 526 | |
| 527 | const session = yield* adapter.startSession({ |
| 528 | provider: ProviderDriverKind.make("opencode"), |
| 529 | threadId, |
| 530 | runtimeMode: "full-access", |
| 531 | resumeCursor: { schemaVersion: 1, sessionId: "ses_samedir" }, |
| 532 | }); |
| 533 | |
| 534 | NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_samedir"]); |
| 535 | NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); |
| 536 | NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); |
| 537 | NodeAssert.deepEqual(session.resumeCursor, { |
| 538 | schemaVersion: 1, |
| 539 | sessionId: "ses_samedir", |
| 540 | }); |
| 541 | |
| 542 | yield* adapter.stopSession(threadId); |
| 543 | }), |
| 544 | ); |
| 545 | |
| 254 | 546 | it.effect("fails sendTurn for missing sessions through the typed error channel", () => |
| 255 | 547 | Effect.gen(function* () { |
| 256 | 548 | const adapter = yield* OpenCodeAdapter; |
| ... | ... | @@ -673,6 +965,88 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 673 | 965 | }), |
| 674 | 966 | ); |
| 675 | 967 | |
| 968 | it.effect("classifies a confirmed not-found across the shapes the SDK/runtime can produce", () => |
| 969 | Effect.sync(() => { |
| 970 | // The real production shape: runOpenCodeSdk wraps the thrown Error |
| 971 | // (cause = { body, status }) under OpenCodeRuntimeError. |
| 972 | const wrappedError = new Error("Session not found: ses_x", { |
| 973 | cause: { body: { name: "NotFoundError" }, status: 404 }, |
| 974 | }); |
| 975 | NodeAssert.equal( |
| 976 | isOpenCodeNotFound({ |
| 977 | _tag: "OpenCodeRuntimeError", |
| 978 | operation: "session.get", |
| 979 | detail: "Session not found: ses_x", |
| 980 | cause: wrappedError, |
| 981 | }), |
| 982 | true, |
| 983 | ); |
| 984 | |
| 985 | // 404 expressed only via response.status (the bot's flagged shape). |
| 986 | NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 404 } } }), true); |
| 987 | // 404 via a bare numeric status / statusCode. |
| 988 | NodeAssert.equal(isOpenCodeNotFound(new Error("x", { cause: { status: 404 } })), true); |
| 989 | NodeAssert.equal(isOpenCodeNotFound({ statusCode: 404 }), true); |
| 990 | // OpenCode NotFoundError body name with no status. |
| 991 | NodeAssert.equal(isOpenCodeNotFound({ body: { name: "NotFoundError" } }), true); |
| 992 | |
| 993 | // NOT a miss: only structured signals count, never free text. A non-404 |
| 994 | // error whose message/detail merely contains "not found" must propagate, |
| 995 | // not be misread as a missing session and silently start fresh. |
| 996 | NodeAssert.equal( |
| 997 | isOpenCodeNotFound(new Error("upstream provider not found", { cause: { status: 500 } })), |
| 998 | false, |
| 999 | ); |
| 1000 | NodeAssert.equal(isOpenCodeNotFound({ detail: "status=500 body={...not found...}" }), false); |
| 1001 | // An explicit non-404 status seals its subtree: a 500 whose serialized |
| 1002 | // body echoes a NotFoundError name — or that is itself named |
| 1003 | // *NotFound* — is a real failure, never a miss. |
| 1004 | NodeAssert.equal(isOpenCodeNotFound({ status: 500, body: { name: "NotFoundError" } }), false); |
| 1005 | NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError", status: 500 }), false); |
| 1006 | // A "NotFound"-flavored name that isn't OpenCode's exact `NotFoundError` |
| 1007 | // is not a confirmed miss even without a sealing status. |
| 1008 | NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError" }), false); |
| 1009 | NodeAssert.equal(isOpenCodeNotFound({ cause: { name: "ProviderNotFoundError" } }), false); |
| 1010 | NodeAssert.equal( |
| 1011 | isOpenCodeNotFound( |
| 1012 | new Error("x", { cause: { status: 502, body: { name: "NotFoundError" } } }), |
| 1013 | ), |
| 1014 | false, |
| 1015 | ); |
| 1016 | // Other transient/auth/network failures must propagate too. |
| 1017 | NodeAssert.equal(isOpenCodeNotFound(new Error("boom", { cause: { status: 500 } })), false); |
| 1018 | NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 401 } } }), false); |
| 1019 | NodeAssert.equal(isOpenCodeNotFound(new Error("network error (no response)")), false); |
| 1020 | NodeAssert.equal(isOpenCodeNotFound(undefined), false); |
| 1021 | }), |
| 1022 | ); |
| 1023 | |
| 1024 | it.effect("treats lexically or physically identical directories as the same", () => |
| 1025 | Effect.gen(function* () { |
| 1026 | const fileSystem = yield* FileSystem.FileSystem; |
| 1027 | const path = yield* Path.Path; |
| 1028 | const sameDirectory = (left: string, right: string) => |
| 1029 | isSameOpenCodeDirectory(fileSystem, path, left, right); |
| 1030 | |
| 1031 | // Lexical-only differences (trailing slash, dot segments) short-circuit |
| 1032 | // without touching the filesystem — the paths need not exist. |
| 1033 | NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true); |
| 1034 | NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true); |
| 1035 | // Nonexistent paths degrade to the lexical comparison instead of failing. |
| 1036 | NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false); |
| 1037 | |
| 1038 | // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to |
| 1039 | // the directory it points at, so the two spellings compare equal. |
| 1040 | const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" }); |
| 1041 | const real = path.join(base, "real"); |
| 1042 | const link = path.join(base, "link"); |
| 1043 | yield* fileSystem.makeDirectory(real); |
| 1044 | yield* fileSystem.symlink(real, link); |
| 1045 | NodeAssert.equal(yield* sameDirectory(link, real), true); |
| 1046 | NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false); |
| 1047 | }).pipe(Effect.scoped), |
| 1048 | ); |
| 1049 | |
| 676 | 1050 | it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => |
| 677 | 1051 | Effect.sync(() => { |
| 678 | 1052 | const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); |