| ... | @@ -5,8 +5,10 @@ import * as Context from "effect/Context"; | ... | @@ -5,8 +5,10 @@ import * as Context from "effect/Context"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Exit from "effect/Exit"; | 6 | import * as Exit from "effect/Exit"; |
| 7 | import * as Fiber from "effect/Fiber"; | 7 | import * as Fiber from "effect/Fiber"; |
| | 8 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| | 11 | import * as Path from "effect/Path"; |
| 10 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 11 | import * as Scope from "effect/Scope"; | 13 | import * as Scope from "effect/Scope"; |
| 12 | import * as Stream from "effect/Stream"; | 14 | import * as Stream from "effect/Stream"; |
| ... | @@ -31,6 +33,8 @@ import { | ... | @@ -31,6 +33,8 @@ import { |
| 31 | } from "../opencodeRuntime.ts"; | 33 | } from "../opencodeRuntime.ts"; |
| 32 | import { | 34 | import { |
| 33 | appendOpenCodeAssistantTextDelta, | 35 | appendOpenCodeAssistantTextDelta, |
| | 36 | isOpenCodeNotFound, |
| | 37 | isSameOpenCodeDirectory, |
| 34 | makeOpenCodeAdapter, | 38 | makeOpenCodeAdapter, |
| 35 | mergeOpenCodeAssistantText, | 39 | mergeOpenCodeAssistantText, |
| 36 | } from "./OpenCodeAdapter.ts"; | 40 | } from "./OpenCodeAdapter.ts"; |
| ... | @@ -64,6 +68,12 @@ const runtimeMock = { | ... | @@ -64,6 +68,12 @@ const runtimeMock = { |
| 64 | closeError: null as Error | null, | 68 | closeError: null as Error | null, |
| 65 | messages: [] as MessageEntry[], | 69 | messages: [] as MessageEntry[], |
| 66 | subscribedEvents: [] as unknown[], | 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 | reset() { | 78 | reset() { |
| 69 | this.state.startCalls.length = 0; | 79 | this.state.startCalls.length = 0; |
| ... | @@ -78,6 +88,12 @@ const runtimeMock = { | ... | @@ -78,6 +88,12 @@ const runtimeMock = { |
| 78 | this.state.closeError = null; | 88 | this.state.closeError = null; |
| 79 | this.state.messages = []; | 89 | this.state.messages = []; |
| 80 | this.state.subscribedEvents = []; | 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,10 +118,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 102 | connectToOpenCodeServer: ({ serverUrl }) => | 118 | connectToOpenCodeServer: ({ serverUrl }) => |
| 103 | Effect.gen(function* () { | 119 | Effect.gen(function* () { |
| 104 | const url = serverUrl ?? "http://127.0.0.1:4301"; | 120 | const url = serverUrl ?? "http://127.0.0.1:4301"; |
| 105 | // Unconditionally register a scope finalizer for test observability — | 121 | // Always register a finalizer so the closeCalls/closeError probes fire; |
| 106 | // preserves the `closeCalls` / `closeError` probes that the existing | 122 | // production attaches none for external servers. |
| 107 | // suites rely on. Production code never attaches a finalizer to an | | |
| 108 | // external server (it simply returns `Effect.succeed(...)`). | | |
| 109 | yield* Effect.addFinalizer(() => | 123 | yield* Effect.addFinalizer(() => |
| 110 | Effect.sync(() => { | 124 | Effect.sync(() => { |
| 111 | runtimeMock.state.closeCalls.push(url); | 125 | runtimeMock.state.closeCalls.push(url); |
| ... | @@ -132,6 +146,34 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -132,6 +146,34 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 132 | ); | 146 | ); |
| 133 | return { data: { id: `${baseUrl}/session` } }; | 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 | abort: async ({ sessionID }: { sessionID: string }) => { | 177 | abort: async ({ sessionID }: { sessionID: string }) => { |
| 136 | runtimeMock.state.abortCalls.push(sessionID); | 178 | runtimeMock.state.abortCalls.push(sessionID); |
| 137 | }, | 179 | }, |
| ... | @@ -251,6 +293,256 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -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 | it.effect("fails sendTurn for missing sessions through the typed error channel", () => | 546 | it.effect("fails sendTurn for missing sessions through the typed error channel", () => |
| 255 | Effect.gen(function* () { | 547 | Effect.gen(function* () { |
| 256 | const adapter = yield* OpenCodeAdapter; | 548 | const adapter = yield* OpenCodeAdapter; |
| ... | @@ -673,6 +965,88 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -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 | it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => | 1050 | it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => |
| 677 | Effect.sync(() => { | 1051 | Effect.sync(() => { |
| 678 | const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); | 1052 | const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); |