| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; |
| 4 | import { |
| 5 | CheckpointId, |
| 6 | NodeId, |
| 7 | ProjectId, |
| 8 | ProviderInstanceId, |
| 9 | ProviderSessionId, |
| 10 | ProviderThreadId, |
| 11 | ProviderTurnId, |
| 12 | RunAttemptId, |
| 13 | RunId, |
| 14 | MessageId, |
| 15 | ThreadId, |
| 16 | type OrchestrationV2ProviderThread, |
| 17 | type OrchestrationV2ProviderTurn, |
| 18 | } from "@t3tools/contracts"; |
| 19 | import { OpenCodeSettings } from "../settings.ts"; |
| 20 | import * as Effect from "effect/Effect"; |
| 21 | import * as Clock from "effect/Clock"; |
| 22 | import * as DateTime from "effect/DateTime"; |
| 23 | import * as Deferred from "effect/Deferred"; |
| 24 | import * as Fiber from "effect/Fiber"; |
| 25 | import * as Exit from "effect/Exit"; |
| 26 | import * as Queue from "effect/Queue"; |
| 27 | import * as Layer from "effect/Layer"; |
| 28 | import * as Option from "effect/Option"; |
| 29 | import * as Schema from "effect/Schema"; |
| 30 | import * as Scope from "effect/Scope"; |
| 31 | import * as Stream from "effect/Stream"; |
| 32 | import * as TestClock from "effect/testing/TestClock"; |
| 33 | |
| 34 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 35 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 36 | import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 37 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; |
| 38 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 39 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 40 | |
| 41 | import { |
| 42 | advanceOpenCodePromptAdmission, |
| 43 | cancelOpenCodePromptAdmission, |
| 44 | openCodeBoundaryAfterProviderTurn, |
| 45 | openCodeChildPermissionRules, |
| 46 | openCodePermissionRules, |
| 47 | openCodePermissionRequestKind, |
| 48 | openCodeToolProjectionKind, |
| 49 | makeOpenCodeProtocolLogger, |
| 50 | makeOpenCodeAdapterV2, |
| 51 | OPENCODE_PROVIDER, |
| 52 | reconcileOpenCodePromptAdmissionStatus, |
| 53 | } from "./adapter.ts"; |
| 54 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 55 | |
| 56 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 57 | const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ |
| 58 | serverUrl: "http://test.invalid", |
| 59 | }); |
| 60 | |
| 61 | function promiseGate<A>() { |
| 62 | let resolve!: (value: A) => void; |
| 63 | const promise = new Promise<A>((done) => { |
| 64 | resolve = done; |
| 65 | }); |
| 66 | return { promise, resolve }; |
| 67 | } |
| 68 | |
| 69 | function asyncEventStream() { |
| 70 | const values: Array<{ value: unknown; handled: ReturnType<typeof promiseGate<void>> }> = []; |
| 71 | const waiters: Array<(value: IteratorResult<unknown>) => void> = []; |
| 72 | let previousHandled: ReturnType<typeof promiseGate<void>> | undefined; |
| 73 | let closed = false; |
| 74 | return { |
| 75 | close() { |
| 76 | closed = true; |
| 77 | for (const waiter of waiters.splice(0)) waiter({ done: true, value: undefined }); |
| 78 | }, |
| 79 | push(value: unknown) { |
| 80 | const handled = promiseGate<void>(); |
| 81 | const waiter = waiters.shift(); |
| 82 | if (waiter) { |
| 83 | previousHandled = handled; |
| 84 | waiter({ done: false, value }); |
| 85 | } else values.push({ value, handled }); |
| 86 | return handled.promise; |
| 87 | }, |
| 88 | stream: { |
| 89 | [Symbol.asyncIterator]() { |
| 90 | return { |
| 91 | next: () => { |
| 92 | previousHandled?.resolve(); |
| 93 | if (closed) return Promise.resolve({ done: true as const, value: undefined }); |
| 94 | const entry = values.shift(); |
| 95 | if (entry !== undefined) { |
| 96 | previousHandled = entry.handled; |
| 97 | return Promise.resolve({ done: false as const, value: entry.value }); |
| 98 | } |
| 99 | return new Promise<IteratorResult<unknown>>((resolve) => waiters.push(resolve)); |
| 100 | }, |
| 101 | }; |
| 102 | }, |
| 103 | }, |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({}); |
| 108 | |
| 109 | function runtimePolicy( |
| 110 | runtimeMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["runtimeMode"], |
| 111 | override: Partial<ProviderAdapter.ProviderAdapterV2RuntimePolicy> = {}, |
| 112 | ): ProviderAdapter.ProviderAdapterV2RuntimePolicy { |
| 113 | return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 114 | runtimeMode, |
| 115 | interactionMode: "default", |
| 116 | cwd: null, |
| 117 | ...override, |
| 118 | }); |
| 119 | } |
| 120 | |
| 121 | function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) { |
| 122 | return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission) |
| 123 | ?.action; |
| 124 | } |
| 125 | |
| 126 | function providerTurn(input: { |
| 127 | readonly id: string; |
| 128 | readonly ordinal: number; |
| 129 | readonly nativeId: string | null; |
| 130 | }): OrchestrationV2ProviderTurn { |
| 131 | return { |
| 132 | id: ProviderTurnId.make(input.id), |
| 133 | providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"), |
| 134 | nodeId: NodeId.make(`node:${input.id}`), |
| 135 | runAttemptId: null, |
| 136 | nativeTurnRef: |
| 137 | input.nativeId === null |
| 138 | ? null |
| 139 | : { driver: OPENCODE_PROVIDER, nativeId: input.nativeId, strength: "weak" }, |
| 140 | ordinal: input.ordinal, |
| 141 | status: "completed", |
| 142 | startedAt: null, |
| 143 | completedAt: null, |
| 144 | }; |
| 145 | } |
| 146 | |
| 147 | const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(function* ( |
| 148 | suffix: string, |
| 149 | nativeSessionId: string, |
| 150 | client: object, |
| 151 | ) { |
| 152 | const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); |
| 153 | const threadId = ThreadId.make(`thread-opencode-${suffix}`); |
| 154 | const modelSelection = { |
| 155 | instanceId, |
| 156 | model: "anthropic/claude-sonnet", |
| 157 | options: [], |
| 158 | }; |
| 159 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); |
| 160 | const adapter = yield* makeOpenCodeAdapterV2({ |
| 161 | instanceId, |
| 162 | settings: OPEN_CODE_TEST_SETTINGS, |
| 163 | environment: {}, |
| 164 | }).pipe( |
| 165 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { |
| 166 | connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), |
| 167 | createOpenCodeSdkClient: () => client, |
| 168 | } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]), |
| 169 | Effect.provideService(ProviderHost.ProviderHost, { |
| 170 | paths: { cwd: "/workspace" }, |
| 171 | resolveAttachmentPath: () => null, |
| 172 | } as unknown as ProviderHost.ProviderHost["Service"]), |
| 173 | ); |
| 174 | const runtime = yield* adapter.openSession({ |
| 175 | threadId, |
| 176 | providerSessionId: ProviderSessionId.make(`session-opencode-${suffix}`), |
| 177 | modelSelection, |
| 178 | runtimePolicy: policy, |
| 179 | }); |
| 180 | const providerThread = yield* runtime.ensureThread({ |
| 181 | threadId, |
| 182 | modelSelection, |
| 183 | runtimePolicy: policy, |
| 184 | }); |
| 185 | const now = yield* DateTime.now; |
| 186 | const startTurn = ( |
| 187 | text = "hello", |
| 188 | startProviderThread: OrchestrationV2ProviderThread = providerThread, |
| 189 | ) => |
| 190 | runtime.startTurn({ |
| 191 | appThread: { |
| 192 | id: threadId, |
| 193 | projectId: ProjectId.make(`project-opencode-${suffix}`), |
| 194 | title: suffix, |
| 195 | providerInstanceId: modelSelection.instanceId, |
| 196 | modelSelection, |
| 197 | runtimeMode: "full-access", |
| 198 | interactionMode: "default", |
| 199 | branch: null, |
| 200 | worktreePath: null, |
| 201 | activeProviderThreadId: providerThread.id, |
| 202 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, |
| 203 | forkedFrom: null, |
| 204 | createdBy: "user", |
| 205 | creationSource: "web", |
| 206 | createdAt: now, |
| 207 | updatedAt: now, |
| 208 | archivedAt: null, |
| 209 | settledOverride: null, |
| 210 | settledAt: null, |
| 211 | lastVisitedAt: null, |
| 212 | deletedAt: null, |
| 213 | }, |
| 214 | threadId, |
| 215 | runId: RunId.make(`run-opencode-${suffix}`), |
| 216 | runOrdinal: 1, |
| 217 | providerTurnOrdinal: 1, |
| 218 | attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`), |
| 219 | rootNodeId: NodeId.make(`node-opencode-${suffix}`), |
| 220 | providerThread: startProviderThread, |
| 221 | message: { |
| 222 | createdBy: "user", |
| 223 | creationSource: "web", |
| 224 | messageId: MessageId.make(`message-opencode-${suffix}`), |
| 225 | text, |
| 226 | attachments: [], |
| 227 | }, |
| 228 | modelSelection, |
| 229 | runtimePolicy: policy, |
| 230 | }); |
| 231 | return { |
| 232 | nativeSessionId, |
| 233 | now, |
| 234 | policy, |
| 235 | providerThread, |
| 236 | runId: RunId.make(`run-opencode-${suffix}`), |
| 237 | runtime, |
| 238 | startTurn, |
| 239 | threadId, |
| 240 | }; |
| 241 | }); |
| 242 | |
| 243 | describe("OpenCodeAdapterV2", () => { |
| 244 | it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( |
| 245 | "normalizes OpenCode step usage for %s turns", |
| 246 | (ending) => |
| 247 | Effect.gen(function* () { |
| 248 | const nativeEvents = asyncEventStream(); |
| 249 | let promptId = ""; |
| 250 | const harness = yield* makeOpenCodeRuntimeHarness(`usage-${ending}`, "root", { |
| 251 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, |
| 252 | session: { |
| 253 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), |
| 254 | promptAsync: async (input: { messageID: string }) => { |
| 255 | promptId = input.messageID; |
| 256 | return { data: true }; |
| 257 | }, |
| 258 | abort: async () => ({ data: true }), |
| 259 | children: async () => ({ data: [] }), |
| 260 | }, |
| 261 | }); |
| 262 | yield* harness.startTurn(); |
| 263 | const received = yield* harness.runtime.events.pipe( |
| 264 | Stream.takeUntil((event) => event.type === "turn.terminal"), |
| 265 | Stream.runCollect, |
| 266 | Effect.forkScoped, |
| 267 | ); |
| 268 | yield* Effect.promise(() => |
| 269 | nativeEvents.push({ |
| 270 | type: "message.updated", |
| 271 | properties: { |
| 272 | sessionID: "root", |
| 273 | info: { id: promptId, role: "user", time: { created: 1 } }, |
| 274 | }, |
| 275 | }), |
| 276 | ); |
| 277 | const step = (id: string, messageID = "assistant") => ({ |
| 278 | type: "message.part.updated", |
| 279 | properties: { |
| 280 | part: { |
| 281 | type: "step-finish", |
| 282 | id, |
| 283 | sessionID: "root", |
| 284 | messageID, |
| 285 | reason: "stop", |
| 286 | cost: 0.01, |
| 287 | tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 3, write: 4 } }, |
| 288 | }, |
| 289 | }, |
| 290 | }); |
| 291 | if (ending !== "unavailable") { |
| 292 | yield* Effect.promise(() => nativeEvents.push(step("one"))); |
| 293 | yield* Effect.promise(() => |
| 294 | nativeEvents.push({ |
| 295 | type: "message.updated", |
| 296 | properties: { |
| 297 | sessionID: "root", |
| 298 | info: { |
| 299 | id: "assistant", |
| 300 | role: "assistant", |
| 301 | time: { created: 1 }, |
| 302 | parentID: promptId, |
| 303 | }, |
| 304 | }, |
| 305 | }), |
| 306 | ); |
| 307 | yield* Effect.promise(() => nativeEvents.push(step("one"))); |
| 308 | yield* Effect.promise(() => nativeEvents.push(step("two"))); |
| 309 | yield* Effect.promise(() => |
| 310 | nativeEvents.push({ |
| 311 | type: "message.part.removed", |
| 312 | properties: { sessionID: "root", messageID: "assistant", partID: "one" }, |
| 313 | }), |
| 314 | ); |
| 315 | yield* Effect.promise(() => |
| 316 | nativeEvents.push({ |
| 317 | type: "message.updated", |
| 318 | properties: { |
| 319 | sessionID: "root", |
| 320 | info: { |
| 321 | id: "old-assistant", |
| 322 | role: "assistant", |
| 323 | time: { created: 1 }, |
| 324 | parentID: "old-prompt", |
| 325 | }, |
| 326 | }, |
| 327 | }), |
| 328 | ); |
| 329 | yield* Effect.promise(() => nativeEvents.push(step("old", "old-assistant"))); |
| 330 | } |
| 331 | if (ending === "unresolved") |
| 332 | yield* Effect.promise(() => nativeEvents.push(step("unknown", "unknown-assistant"))); |
| 333 | if (ending === "reconnect") { |
| 334 | yield* Effect.promise(() => |
| 335 | nativeEvents.push({ type: "server.connected", properties: {} }), |
| 336 | ); |
| 337 | yield* Effect.promise(() => |
| 338 | nativeEvents.push({ type: "server.connected", properties: {} }), |
| 339 | ); |
| 340 | } |
| 341 | if (ending === "failed") { |
| 342 | yield* Effect.promise(() => |
| 343 | nativeEvents.push({ |
| 344 | type: "session.error", |
| 345 | properties: { |
| 346 | sessionID: "root", |
| 347 | error: { name: "UnknownError", data: { message: "failed" } }, |
| 348 | }, |
| 349 | }), |
| 350 | ); |
| 351 | } else { |
| 352 | yield* Effect.promise(() => |
| 353 | nativeEvents.push({ |
| 354 | type: "session.status", |
| 355 | properties: { sessionID: "root", status: { type: "busy" } }, |
| 356 | }), |
| 357 | ); |
| 358 | yield* Effect.promise(() => |
| 359 | nativeEvents.push({ |
| 360 | type: "session.status", |
| 361 | properties: { sessionID: "root", status: { type: "idle" } }, |
| 362 | }), |
| 363 | ); |
| 364 | } |
| 365 | const events = yield* Fiber.join(received); |
| 366 | const completed = events.findLast((event) => event.type === "provider_turn.updated"); |
| 367 | assert.deepEqual( |
| 368 | completed?.providerTurn.turnTokenUsage, |
| 369 | ending === "unavailable" |
| 370 | ? { usageStatus: "unavailable", usageScope: "main_agent", hasSubagents: false } |
| 371 | : { |
| 372 | usageStatus: ending === "completed" ? "complete" : "partial", |
| 373 | usageScope: "main_agent", |
| 374 | inputTokens: 34, |
| 375 | cachedInputTokens: 6, |
| 376 | cacheCreationTokens: 8, |
| 377 | outputTokens: 14, |
| 378 | reasoningTokens: 4, |
| 379 | hasSubagents: false, |
| 380 | }, |
| 381 | ); |
| 382 | }).pipe( |
| 383 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 384 | Effect.scoped, |
| 385 | ), |
| 386 | ); |
| 387 | |
| 388 | it.effect.each(["permission", "question"] as const)( |
| 389 | "cancels an undelivered %s reply at its deadline", |
| 390 | (kind) => |
| 391 | Effect.gen(function* () { |
| 392 | const nativeEvents = asyncEventStream(); |
| 393 | const called = promiseGate<void>(); |
| 394 | let signal: AbortSignal | undefined; |
| 395 | let deliver = false; |
| 396 | const harness = yield* makeOpenCodeRuntimeHarness(`reply-${kind}`, "root", { |
| 397 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, |
| 398 | session: { |
| 399 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), |
| 400 | promptAsync: async () => ({ data: true }), |
| 401 | abort: async () => ({ data: true }), |
| 402 | children: async () => ({ data: [] }), |
| 403 | }, |
| 404 | [kind]: { |
| 405 | reply: async (_input: unknown, options: { signal: AbortSignal }) => { |
| 406 | if (deliver) return { data: true }; |
| 407 | signal = options.signal; |
| 408 | called.resolve(); |
| 409 | return new Promise(() => {}); |
| 410 | }, |
| 411 | }, |
| 412 | }); |
| 413 | yield* harness.startTurn(); |
| 414 | const received = yield* harness.runtime.events.pipe( |
| 415 | Stream.filter((event) => event.type === "runtime_request.updated"), |
| 416 | Stream.take(1), |
| 417 | Stream.runCollect, |
| 418 | Effect.forkScoped, |
| 419 | ); |
| 420 | yield* Effect.promise(() => |
| 421 | nativeEvents.push({ |
| 422 | type: `${kind}.asked`, |
| 423 | properties: |
| 424 | kind === "permission" |
| 425 | ? { |
| 426 | id: "request", |
| 427 | sessionID: "root", |
| 428 | permission: "bash", |
| 429 | patterns: ["*"], |
| 430 | always: [], |
| 431 | metadata: {}, |
| 432 | } |
| 433 | : { |
| 434 | id: "request", |
| 435 | sessionID: "root", |
| 436 | questions: [ |
| 437 | { |
| 438 | header: "Choice", |
| 439 | question: "Which?", |
| 440 | options: [{ label: "Yes", description: "Proceed" }], |
| 441 | }, |
| 442 | ], |
| 443 | }, |
| 444 | }), |
| 445 | ); |
| 446 | const request = (yield* Fiber.join(received))[0]!.runtimeRequest; |
| 447 | const response = { |
| 448 | requestId: request.id, |
| 449 | ...(kind === "permission" |
| 450 | ? { decision: "accept" as const } |
| 451 | : { answers: { Choice: "Yes" } }), |
| 452 | }; |
| 453 | const reply = yield* harness.runtime |
| 454 | .respondToRuntimeRequest(response) |
| 455 | .pipe(Effect.exit, Effect.forkScoped); |
| 456 | yield* Effect.promise(() => called.promise); |
| 457 | yield* TestClock.adjust("10 seconds"); |
| 458 | assert.isTrue(Exit.isFailure(yield* Fiber.join(reply))); |
| 459 | assert.isTrue(signal?.aborted); |
| 460 | deliver = true; |
| 461 | // Failed delivery leaves the request available for an explicit retry. |
| 462 | yield* harness.runtime.respondToRuntimeRequest(response); |
| 463 | }).pipe( |
| 464 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 465 | Effect.scoped, |
| 466 | ), |
| 467 | ); |
| 468 | |
| 469 | it.effect("aborts external root and descendants before closing the event stream", () => |
| 470 | Effect.gen(function* () { |
| 471 | const scope = yield* Scope.make(); |
| 472 | const nativeEvents = asyncEventStream(); |
| 473 | const calls: string[] = []; |
| 474 | yield* makeOpenCodeRuntimeHarness("release", "root", { |
| 475 | event: { |
| 476 | subscribe: async (_input: unknown, options: { signal: AbortSignal }) => { |
| 477 | options.signal.addEventListener("abort", () => { |
| 478 | calls.push("stream.close"); |
| 479 | nativeEvents.close(); |
| 480 | }); |
| 481 | return { stream: nativeEvents.stream }; |
| 482 | }, |
| 483 | }, |
| 484 | session: { |
| 485 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), |
| 486 | abort: async ({ sessionID }: { sessionID: string }) => { |
| 487 | calls.push(`abort:${sessionID}`); |
| 488 | return { data: true }; |
| 489 | }, |
| 490 | children: async ({ sessionID }: { sessionID: string }) => { |
| 491 | calls.push(`children:${sessionID}`); |
| 492 | return { data: sessionID === "root" ? [{ id: "child" }] : [] }; |
| 493 | }, |
| 494 | }, |
| 495 | }).pipe(Effect.provideService(Scope.Scope, scope)); |
| 496 | yield* Scope.close(scope, Exit.void); |
| 497 | assert.deepEqual(calls, [ |
| 498 | "abort:root", |
| 499 | "children:root", |
| 500 | "abort:child", |
| 501 | "children:child", |
| 502 | "stream.close", |
| 503 | ]); |
| 504 | }).pipe( |
| 505 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 506 | Effect.scoped, |
| 507 | ), |
| 508 | ); |
| 509 | |
| 510 | it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)( |
| 511 | "reports descendant cleanup %s", |
| 512 | (failure) => |
| 513 | Effect.gen(function* () { |
| 514 | const nativeEvents = asyncEventStream(); |
| 515 | const called = promiseGate<void>(); |
| 516 | let childSignal: AbortSignal | undefined; |
| 517 | const harness = yield* makeOpenCodeRuntimeHarness(`cleanup-${failure}`, "root", { |
| 518 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, |
| 519 | session: { |
| 520 | create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), |
| 521 | promptAsync: async () => ({ data: true }), |
| 522 | get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), |
| 523 | messages: async () => ({ data: [] }), |
| 524 | children: async ({ sessionID }: { sessionID: string }) => { |
| 525 | if (failure === "enumeration") throw new Error("cannot enumerate"); |
| 526 | return { data: sessionID === "root" ? [{ id: "child" }] : [] }; |
| 527 | }, |
| 528 | abort: async ( |
| 529 | { sessionID }: { sessionID: string }, |
| 530 | options: { signal: AbortSignal }, |
| 531 | ) => { |
| 532 | if (sessionID === "root") return { data: true }; |
| 533 | if (failure === "timeout" && childSignal?.aborted) return { data: true }; |
| 534 | childSignal = options.signal; |
| 535 | called.resolve(); |
| 536 | if (failure === "timeout") { |
| 537 | return new Promise(() => {}); |
| 538 | } |
| 539 | if (failure === "not-found") throw { status: 404 }; |
| 540 | throw new Error("child abort failed"); |
| 541 | }, |
| 542 | }, |
| 543 | }); |
| 544 | yield* harness.startTurn(); |
| 545 | const snapshot = yield* harness.runtime.readThreadSnapshot({ |
| 546 | providerThread: harness.providerThread, |
| 547 | }); |
| 548 | const stop = yield* harness.runtime |
| 549 | .interruptTurn({ |
| 550 | providerThread: harness.providerThread, |
| 551 | providerTurnId: snapshot.providerTurns.at(-1)!.id, |
| 552 | }) |
| 553 | .pipe(Effect.exit, Effect.forkScoped); |
| 554 | if (failure === "timeout") { |
| 555 | yield* Effect.promise(() => called.promise); |
| 556 | yield* TestClock.adjust("15 seconds"); |
| 557 | } |
| 558 | const result = yield* Fiber.join(stop); |
| 559 | assert.equal(Exit.isSuccess(result), failure === "not-found"); |
| 560 | if (failure === "timeout") assert.isTrue(childSignal?.aborted); |
| 561 | }).pipe( |
| 562 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 563 | Effect.scoped, |
| 564 | ), |
| 565 | ); |
| 566 | |
| 567 | it.effect( |
| 568 | "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads", |
| 569 | () => |
| 570 | Effect.gen(function* () { |
| 571 | const nativeEvents = asyncEventStream(); |
| 572 | const nativeSessionId = "native-opencode-tool-lifecycle"; |
| 573 | const harness = yield* makeOpenCodeRuntimeHarness("tool-lifecycle", nativeSessionId, { |
| 574 | event: { |
| 575 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 576 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 577 | return { stream: nativeEvents.stream }; |
| 578 | }, |
| 579 | }, |
| 580 | session: { |
| 581 | create: async () => ({ |
| 582 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, |
| 583 | }), |
| 584 | promptAsync: async () => ({ data: true }), |
| 585 | }, |
| 586 | }); |
| 587 | yield* harness.startTurn(); |
| 588 | const received = yield* harness.runtime.events.pipe( |
| 589 | Stream.takeUntil( |
| 590 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", |
| 591 | ), |
| 592 | Stream.runCollect, |
| 593 | Effect.forkScoped, |
| 594 | ); |
| 595 | const states = [ |
| 596 | { status: "pending", input: { command: "pwd" }, raw: "" }, |
| 597 | { |
| 598 | status: "running", |
| 599 | input: { command: "pwd" }, |
| 600 | title: "Working directory", |
| 601 | time: { start: 1 }, |
| 602 | }, |
| 603 | { |
| 604 | status: "completed", |
| 605 | input: { command: "pwd" }, |
| 606 | output: "/repo\n", |
| 607 | title: "Working directory", |
| 608 | metadata: {}, |
| 609 | time: { start: 1, end: 2 }, |
| 610 | }, |
| 611 | { |
| 612 | status: "error", |
| 613 | input: { command: "pwd" }, |
| 614 | error: "Command failed", |
| 615 | time: { start: 3, end: 4 }, |
| 616 | }, |
| 617 | ] satisfies ReadonlyArray<ToolPart["state"]>; |
| 618 | for (const state of states) { |
| 619 | yield* Effect.promise(() => |
| 620 | nativeEvents.push({ |
| 621 | type: "message.part.updated", |
| 622 | properties: { |
| 623 | sessionID: nativeSessionId, |
| 624 | part: { |
| 625 | id: state.status === "error" ? "part-failed" : "part-working", |
| 626 | sessionID: nativeSessionId, |
| 627 | messageID: "late-assistant", |
| 628 | type: "tool", |
| 629 | callID: state.status === "error" ? "call-failed" : "call-working", |
| 630 | tool: "bash", |
| 631 | state, |
| 632 | }, |
| 633 | }, |
| 634 | }), |
| 635 | ); |
| 636 | } |
| 637 | yield* Effect.promise(() => |
| 638 | nativeEvents.push({ |
| 639 | type: "message.part.updated", |
| 640 | properties: { |
| 641 | sessionID: nativeSessionId, |
| 642 | part: { |
| 643 | id: "part-read", |
| 644 | sessionID: nativeSessionId, |
| 645 | messageID: "late-assistant", |
| 646 | type: "tool", |
| 647 | callID: "call-read", |
| 648 | tool: "read", |
| 649 | state: { status: "pending", input: { filePath: "/outside/file" }, raw: "" }, |
| 650 | }, |
| 651 | }, |
| 652 | }), |
| 653 | ); |
| 654 | yield* Effect.promise(() => |
| 655 | nativeEvents.push({ |
| 656 | type: "permission.asked", |
| 657 | properties: { |
| 658 | id: "read-permission", |
| 659 | sessionID: nativeSessionId, |
| 660 | permission: "external_directory", |
| 661 | patterns: ["/outside/*"], |
| 662 | always: [], |
| 663 | metadata: {}, |
| 664 | tool: { messageID: "late-assistant", callID: "call-read" }, |
| 665 | }, |
| 666 | }), |
| 667 | ); |
| 668 | yield* Effect.promise(() => |
| 669 | nativeEvents.push({ |
| 670 | type: "message.part.updated", |
| 671 | properties: { |
| 672 | sessionID: nativeSessionId, |
| 673 | part: { |
| 674 | id: "part-text", |
| 675 | sessionID: nativeSessionId, |
| 676 | messageID: "late-assistant", |
| 677 | type: "text", |
| 678 | text: "Tool results received", |
| 679 | time: { start: 4 }, |
| 680 | }, |
| 681 | }, |
| 682 | }), |
| 683 | ); |
| 684 | yield* Effect.promise(() => |
| 685 | nativeEvents.push({ |
| 686 | type: "message.updated", |
| 687 | properties: { |
| 688 | sessionID: nativeSessionId, |
| 689 | info: { |
| 690 | id: "late-assistant", |
| 691 | sessionID: nativeSessionId, |
| 692 | role: "assistant", |
| 693 | time: { created: 1, completed: 4 }, |
| 694 | }, |
| 695 | }, |
| 696 | }), |
| 697 | ); |
| 698 | yield* Effect.promise(() => |
| 699 | nativeEvents.push({ |
| 700 | type: "session.compacted", |
| 701 | properties: { sessionID: nativeSessionId }, |
| 702 | }), |
| 703 | ); |
| 704 | const events = yield* Fiber.join(received); |
| 705 | const items = events.flatMap((event) => |
| 706 | event.type === "turn_item.updated" ? [event.turnItem] : [], |
| 707 | ); |
| 708 | const commands = items.filter((item) => item.type === "command_execution"); |
| 709 | assert.deepEqual( |
| 710 | commands.map((item) => item.status), |
| 711 | ["pending", "running", "completed", "failed"], |
| 712 | ); |
| 713 | assert.deepEqual( |
| 714 | commands.map((item) => item.input), |
| 715 | ["pwd", "pwd", "pwd", "pwd"], |
| 716 | ); |
| 717 | assert.equal(commands[2]?.output, "/repo\n"); |
| 718 | assert.equal(commands[3]?.output, "Command failed"); |
| 719 | assert.isTrue( |
| 720 | events.some( |
| 721 | (event) => |
| 722 | event.type === "runtime_request.updated" && event.runtimeRequest.kind === "file-read", |
| 723 | ), |
| 724 | ); |
| 725 | const assistant = items.filter((item) => item.type === "assistant_message"); |
| 726 | assert.equal(assistant.at(-1)?.text, "Tool results received"); |
| 727 | assert.equal(assistant.at(-1)?.status, "completed"); |
| 728 | }).pipe( |
| 729 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 730 | Effect.scoped, |
| 731 | ), |
| 732 | ); |
| 733 | |
| 734 | // Event order from a live OpenCode 1.18.32 run of a `task` call with |
| 735 | // background=true: the task part completes at launch, the root session |
| 736 | // settles, and the child session stays busy until its own work ends. |
| 737 | it.effect("reports a background task child as pending work after the root turn settles", () => |
| 738 | Effect.gen(function* () { |
| 739 | const nativeEvents = asyncEventStream(); |
| 740 | const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); |
| 741 | const root = "ses_root"; |
| 742 | const child = "ses_child"; |
| 743 | let promptId = ""; |
| 744 | const harness = yield* makeOpenCodeRuntimeHarness("background-child", root, { |
| 745 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, |
| 746 | session: { |
| 747 | create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), |
| 748 | get: async () => ({ |
| 749 | data: { id: child, parentID: root, permission: [], time: { created: 2, updated: 2 } }, |
| 750 | }), |
| 751 | update: async () => ({ data: { id: child, parentID: root } }), |
| 752 | promptAsync: async (input: { messageID: string }) => { |
| 753 | promptId = input.messageID; |
| 754 | return { data: true }; |
| 755 | }, |
| 756 | abort: async () => ({ data: true }), |
| 757 | children: async () => ({ data: [] }), |
| 758 | }, |
| 759 | }); |
| 760 | const hasPendingBackgroundWork = harness.runtime.hasPendingBackgroundWork; |
| 761 | if (hasPendingBackgroundWork === undefined) { |
| 762 | return yield* Effect.die("OpenCode runtime must expose hasPendingBackgroundWork."); |
| 763 | } |
| 764 | yield* harness.startTurn(); |
| 765 | const terminal = yield* harness.runtime.events.pipe( |
| 766 | Stream.filter((event) => event.type === "turn.terminal"), |
| 767 | Stream.runHead, |
| 768 | Effect.forkScoped, |
| 769 | ); |
| 770 | const taskPart = (status: "running" | "completed") => ({ |
| 771 | type: "message.part.updated", |
| 772 | properties: { |
| 773 | sessionID: root, |
| 774 | part: { |
| 775 | id: "prt_task", |
| 776 | sessionID: root, |
| 777 | messageID: "msg_root_assistant", |
| 778 | type: "tool", |
| 779 | tool: "task", |
| 780 | callID: "call_task", |
| 781 | state: { |
| 782 | status, |
| 783 | input: { |
| 784 | description: "Background sleep task", |
| 785 | prompt: "sleep", |
| 786 | subagent_type: "general", |
| 787 | }, |
| 788 | title: "Background sleep task", |
| 789 | metadata: { parentSessionId: root, sessionId: child, background: true }, |
| 790 | time: { start: 3, ...(status === "completed" ? { end: 3 } : {}) }, |
| 791 | ...(status === "completed" ? { output: "Background task started" } : {}), |
| 792 | }, |
| 793 | }, |
| 794 | }, |
| 795 | }); |
| 796 | const status = (sessionID: string, type: "busy" | "idle") => ({ |
| 797 | type: "session.status", |
| 798 | properties: { sessionID, status: { type } }, |
| 799 | }); |
| 800 | |
| 801 | yield* push({ |
| 802 | type: "message.updated", |
| 803 | properties: { |
| 804 | sessionID: root, |
| 805 | info: { id: promptId, sessionID: root, role: "user", time: { created: 1 } }, |
| 806 | }, |
| 807 | }); |
| 808 | yield* push(status(root, "busy")); |
| 809 | yield* push(taskPart("running")); |
| 810 | yield* push({ |
| 811 | type: "session.created", |
| 812 | properties: { |
| 813 | sessionID: child, |
| 814 | info: { id: child, parentID: root, time: { created: 2, updated: 2 } }, |
| 815 | }, |
| 816 | }); |
| 817 | yield* push(status(child, "busy")); |
| 818 | yield* push(taskPart("completed")); |
| 819 | yield* push(status(root, "idle")); |
| 820 | assert.equal(Option.getOrUndefined(yield* Fiber.join(terminal))?.status, "completed"); |
| 821 | assert.isTrue(yield* hasPendingBackgroundWork, "the running child must pin idle release"); |
| 822 | |
| 823 | yield* push(status(child, "idle")); |
| 824 | yield* push({ type: "session.idle", properties: { sessionID: child } }); |
| 825 | assert.isFalse(yield* hasPendingBackgroundWork, "an idle child must not pin idle release"); |
| 826 | }).pipe( |
| 827 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 828 | Effect.scoped, |
| 829 | ), |
| 830 | ); |
| 831 | |
| 832 | it.effect("titles OpenCode reads and searches from their input", () => |
| 833 | Effect.gen(function* () { |
| 834 | const nativeEvents = asyncEventStream(); |
| 835 | const nativeSessionId = "native-opencode-search"; |
| 836 | const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, { |
| 837 | event: { |
| 838 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 839 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 840 | return { stream: nativeEvents.stream }; |
| 841 | }, |
| 842 | }, |
| 843 | session: { |
| 844 | create: async () => ({ |
| 845 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, |
| 846 | }), |
| 847 | promptAsync: async () => ({ data: true }), |
| 848 | }, |
| 849 | }); |
| 850 | yield* harness.startTurn(); |
| 851 | const received = yield* harness.runtime.events.pipe( |
| 852 | Stream.takeUntil( |
| 853 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", |
| 854 | ), |
| 855 | Stream.runCollect, |
| 856 | Effect.forkScoped, |
| 857 | ); |
| 858 | for (const [tool, input, output] of [ |
| 859 | ["read", { filePath: "src/env.ts" }, "---\nfile body"], |
| 860 | ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], |
| 861 | ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], |
| 862 | ["glob", { pattern: "missing", path: "apps/web" }, ""], |
| 863 | ["codesearch", {}, " \n\t"], |
| 864 | ] as const) { |
| 865 | yield* Effect.promise(() => |
| 866 | nativeEvents.push({ |
| 867 | type: "message.part.updated", |
| 868 | properties: { |
| 869 | sessionID: nativeSessionId, |
| 870 | part: { |
| 871 | id: `part-${tool}`, |
| 872 | sessionID: nativeSessionId, |
| 873 | messageID: "assistant-search", |
| 874 | type: "tool", |
| 875 | callID: `call-${tool}`, |
| 876 | tool, |
| 877 | state: { |
| 878 | status: "completed", |
| 879 | input, |
| 880 | output, |
| 881 | title: tool, |
| 882 | metadata: {}, |
| 883 | time: { start: 1, end: 2 }, |
| 884 | }, |
| 885 | }, |
| 886 | }, |
| 887 | }), |
| 888 | ); |
| 889 | } |
| 890 | yield* Effect.promise(() => |
| 891 | nativeEvents.push({ |
| 892 | type: "session.compacted", |
| 893 | properties: { sessionID: nativeSessionId }, |
| 894 | }), |
| 895 | ); |
| 896 | const items = (yield* Fiber.join(received)).flatMap((event) => |
| 897 | event.type === "turn_item.updated" ? [event.turnItem] : [], |
| 898 | ); |
| 899 | const read = items.find((item) => item.type === "dynamic_tool"); |
| 900 | assert.equal(read?.title, "Read src/env.ts"); |
| 901 | const grep = items.find((item) => item.type === "file_search"); |
| 902 | assert.equal(grep?.title, "Searched TODO in web"); |
| 903 | assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); |
| 904 | assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [ |
| 905 | { fileName: "apps/web", preview: "---\nfile body" }, |
| 906 | ]); |
| 907 | const webSearch = items.find((item) => item.type === "web_search"); |
| 908 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ |
| 909 | "OpenCode documentation", |
| 910 | ]); |
| 911 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [ |
| 912 | { snippet: "---\nfile body" }, |
| 913 | ]); |
| 914 | const emptyFileSearch = items.find( |
| 915 | (item) => item.type === "file_search" && item.pattern === "missing", |
| 916 | ); |
| 917 | assert.ok(emptyFileSearch?.type === "file_search"); |
| 918 | assert.equal(emptyFileSearch.results, undefined); |
| 919 | const emptyWebSearch = items.find( |
| 920 | (item) => item.type === "web_search" && item.patterns === undefined, |
| 921 | ); |
| 922 | assert.ok(emptyWebSearch?.type === "web_search"); |
| 923 | assert.equal(emptyWebSearch.results, undefined); |
| 924 | }).pipe( |
| 925 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 926 | Effect.scoped, |
| 927 | ), |
| 928 | ); |
| 929 | |
| 930 | it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () => |
| 931 | Effect.gen(function* () { |
| 932 | const nativeEvents = asyncEventStream(); |
| 933 | const nativeSessionId = "native-opencode-mcp"; |
| 934 | let statusReads = 0; |
| 935 | const harness = yield* makeOpenCodeRuntimeHarness("mcp-presentation", nativeSessionId, { |
| 936 | event: { |
| 937 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 938 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 939 | return { stream: nativeEvents.stream }; |
| 940 | }, |
| 941 | }, |
| 942 | session: { |
| 943 | create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), |
| 944 | promptAsync: async () => ({ data: true }), |
| 945 | }, |
| 946 | mcp: { |
| 947 | status: async () => { |
| 948 | statusReads++; |
| 949 | return { |
| 950 | data: { |
| 951 | "my.server_with_underscores": { status: "connected" }, |
| 952 | ambiguous: { status: "connected" }, |
| 953 | ambiguous_server: { status: "connected" }, |
| 954 | code: { status: "connected" }, |
| 955 | apply: { status: "connected" }, |
| 956 | }, |
| 957 | }; |
| 958 | }, |
| 959 | }, |
| 960 | }); |
| 961 | yield* harness.startTurn(); |
| 962 | const received = yield* harness.runtime.events.pipe( |
| 963 | Stream.takeUntil( |
| 964 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", |
| 965 | ), |
| 966 | Stream.runCollect, |
| 967 | Effect.forkScoped, |
| 968 | ); |
| 969 | for (const status of ["running", "completed", "error"] as const) { |
| 970 | yield* Effect.promise(() => |
| 971 | nativeEvents.push({ |
| 972 | type: "message.part.updated", |
| 973 | properties: { |
| 974 | sessionID: nativeSessionId, |
| 975 | part: { |
| 976 | id: "mcp-part", |
| 977 | sessionID: nativeSessionId, |
| 978 | messageID: "mcp-assistant", |
| 979 | type: "tool", |
| 980 | callID: "mcp-call", |
| 981 | tool: "my_server_with_underscores_edit_document", |
| 982 | state: { |
| 983 | status, |
| 984 | input: { document: "remote-doc" }, |
| 985 | title: "Edit remote document", |
| 986 | metadata: {}, |
| 987 | output: "saved", |
| 988 | error: "failed", |
| 989 | time: { start: 1, end: 2 }, |
| 990 | }, |
| 991 | }, |
| 992 | }, |
| 993 | }), |
| 994 | ); |
| 995 | } |
| 996 | yield* Effect.promise(() => |
| 997 | nativeEvents.push({ |
| 998 | type: "message.part.updated", |
| 999 | properties: { |
| 1000 | sessionID: nativeSessionId, |
| 1001 | part: { |
| 1002 | id: "ambiguous-part", |
| 1003 | sessionID: nativeSessionId, |
| 1004 | messageID: "mcp-assistant", |
| 1005 | type: "tool", |
| 1006 | callID: "ambiguous-call", |
| 1007 | tool: "ambiguous_server_edit_document", |
| 1008 | state: { |
| 1009 | status: "completed", |
| 1010 | input: {}, |
| 1011 | title: "Ambiguous tool", |
| 1012 | metadata: {}, |
| 1013 | output: "", |
| 1014 | time: { start: 1, end: 2 }, |
| 1015 | }, |
| 1016 | }, |
| 1017 | }, |
| 1018 | }), |
| 1019 | ); |
| 1020 | for (const tool of ["code_search", "apply_patch"]) { |
| 1021 | yield* Effect.promise(() => |
| 1022 | nativeEvents.push({ |
| 1023 | type: "message.part.updated", |
| 1024 | properties: { |
| 1025 | sessionID: nativeSessionId, |
| 1026 | part: { |
| 1027 | id: `native-${tool}`, |
| 1028 | sessionID: nativeSessionId, |
| 1029 | messageID: "mcp-assistant", |
| 1030 | type: "tool", |
| 1031 | callID: `native-${tool}`, |
| 1032 | tool, |
| 1033 | state: { |
| 1034 | status: "completed", |
| 1035 | input: { query: "needle", filePath: "local.ts" }, |
| 1036 | title: `Native ${tool}`, |
| 1037 | metadata: {}, |
| 1038 | output: "done", |
| 1039 | time: { start: 1, end: 2 }, |
| 1040 | }, |
| 1041 | }, |
| 1042 | }, |
| 1043 | }), |
| 1044 | ); |
| 1045 | } |
| 1046 | yield* Effect.promise(() => |
| 1047 | nativeEvents.push({ |
| 1048 | type: "session.compacted", |
| 1049 | properties: { sessionID: nativeSessionId }, |
| 1050 | }), |
| 1051 | ); |
| 1052 | const allItems = (yield* Fiber.join(received)).flatMap((event) => |
| 1053 | event.type === "turn_item.updated" ? [event.turnItem] : [], |
| 1054 | ); |
| 1055 | assert.equal( |
| 1056 | allItems.find((item) => item.title === "Native code_search")?.type, |
| 1057 | "web_search", |
| 1058 | ); |
| 1059 | assert.equal( |
| 1060 | allItems.find((item) => item.title === "Native apply_patch")?.type, |
| 1061 | "file_change", |
| 1062 | ); |
| 1063 | const items = allItems.filter((item) => item.type === "dynamic_tool"); |
| 1064 | assert.deepEqual( |
| 1065 | items.slice(0, 3).map((item) => item.status), |
| 1066 | ["running", "completed", "failed"], |
| 1067 | ); |
| 1068 | assert.deepEqual( |
| 1069 | items.slice(0, 3).map((item) => item.title), |
| 1070 | ["Edit remote document", "Edit remote document", "edit document"], |
| 1071 | ); |
| 1072 | for (const item of items.slice(0, 3)) { |
| 1073 | assert.deepEqual(item.toolSource, { |
| 1074 | key: "mcp:my.server_with_underscores", |
| 1075 | name: "my.server with underscores", |
| 1076 | kind: "integration", |
| 1077 | }); |
| 1078 | assert.deepEqual(item.input, { document: "remote-doc" }); |
| 1079 | } |
| 1080 | assert.equal(items[3]?.toolName, "ambiguous_server_edit_document"); |
| 1081 | assert.isUndefined(items[3]?.toolSource); |
| 1082 | assert.equal(statusReads, 1); |
| 1083 | }).pipe( |
| 1084 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1085 | Effect.scoped, |
| 1086 | ), |
| 1087 | ); |
| 1088 | |
| 1089 | it.effect.each(["failure", "timeout"] as const)("retries MCP status after %s", (failure) => |
| 1090 | Effect.gen(function* () { |
| 1091 | const nativeEvents = asyncEventStream(); |
| 1092 | const called = promiseGate<void>(); |
| 1093 | const sessionId = `mcp-status-${failure}`; |
| 1094 | let statusReads = 0; |
| 1095 | let signal: AbortSignal | undefined; |
| 1096 | const harness = yield* makeOpenCodeRuntimeHarness(sessionId, sessionId, { |
| 1097 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, |
| 1098 | session: { |
| 1099 | create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), |
| 1100 | promptAsync: async () => ({ data: true }), |
| 1101 | }, |
| 1102 | mcp: { |
| 1103 | status: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1104 | statusReads++; |
| 1105 | if (statusReads === 1) { |
| 1106 | signal = options?.signal; |
| 1107 | called.resolve(); |
| 1108 | if (failure === "timeout") return new Promise(() => {}); |
| 1109 | throw new Error("MCP status unavailable"); |
| 1110 | } |
| 1111 | return { data: { weather: { status: "connected" } } }; |
| 1112 | }, |
| 1113 | }, |
| 1114 | }); |
| 1115 | yield* harness.startTurn(); |
| 1116 | const received = yield* harness.runtime.events.pipe( |
| 1117 | Stream.takeUntil( |
| 1118 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", |
| 1119 | ), |
| 1120 | Stream.runCollect, |
| 1121 | Effect.forkScoped, |
| 1122 | ); |
| 1123 | for (const status of ["running", "completed"] as const) { |
| 1124 | const emitted = yield* Effect.promise(() => |
| 1125 | nativeEvents.push({ |
| 1126 | type: "message.part.updated", |
| 1127 | properties: { |
| 1128 | sessionID: sessionId, |
| 1129 | part: { |
| 1130 | id: "weather-part", |
| 1131 | sessionID: sessionId, |
| 1132 | messageID: "weather-message", |
| 1133 | type: "tool", |
| 1134 | callID: "weather-call", |
| 1135 | tool: "weather_edit_document", |
| 1136 | state: { |
| 1137 | status, |
| 1138 | input: { document: "remote-doc" }, |
| 1139 | title: "Edit remote document", |
| 1140 | metadata: {}, |
| 1141 | output: "saved", |
| 1142 | time: { start: 1, end: 2 }, |
| 1143 | }, |
| 1144 | }, |
| 1145 | }, |
| 1146 | }), |
| 1147 | ).pipe(Effect.forkScoped); |
| 1148 | if (status === "running" && failure === "timeout") { |
| 1149 | yield* Effect.promise(() => called.promise); |
| 1150 | yield* TestClock.adjust("1 second"); |
| 1151 | } |
| 1152 | yield* Fiber.join(emitted); |
| 1153 | } |
| 1154 | yield* Effect.promise(() => |
| 1155 | nativeEvents.push({ type: "session.compacted", properties: { sessionID: sessionId } }), |
| 1156 | ); |
| 1157 | const completed = (yield* Fiber.join(received)).find( |
| 1158 | (event) => |
| 1159 | event.type === "turn_item.updated" && |
| 1160 | event.turnItem.type === "dynamic_tool" && |
| 1161 | event.turnItem.status === "completed", |
| 1162 | ); |
| 1163 | assert.equal(statusReads, 2); |
| 1164 | assert.equal( |
| 1165 | completed?.type === "turn_item.updated" && completed.turnItem.title, |
| 1166 | "Edit remote document", |
| 1167 | ); |
| 1168 | assert.deepEqual(completed?.type === "turn_item.updated" && completed.turnItem.toolSource, { |
| 1169 | key: "mcp:weather", |
| 1170 | name: "weather", |
| 1171 | kind: "integration", |
| 1172 | }); |
| 1173 | if (failure === "timeout") assert.isTrue(signal?.aborted); |
| 1174 | }).pipe( |
| 1175 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1176 | Effect.scoped, |
| 1177 | ), |
| 1178 | ); |
| 1179 | |
| 1180 | it.effect("admits a native command on its user receipt before generation completes", () => |
| 1181 | Effect.gen(function* () { |
| 1182 | const nativeEvents = asyncEventStream(); |
| 1183 | const release = promiseGate<void>(); |
| 1184 | const calls = yield* Queue.unbounded<{ |
| 1185 | messageID: string; |
| 1186 | command: string; |
| 1187 | arguments: string; |
| 1188 | model: string; |
| 1189 | }>(); |
| 1190 | let promptCalls = 0; |
| 1191 | const sessionId = "native-command"; |
| 1192 | const harness = yield* makeOpenCodeRuntimeHarness("native-command", sessionId, { |
| 1193 | event: { |
| 1194 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1195 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 1196 | return { stream: nativeEvents.stream }; |
| 1197 | }, |
| 1198 | }, |
| 1199 | command: { list: async () => ({ data: [{ name: "review" }] }) }, |
| 1200 | session: { |
| 1201 | create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), |
| 1202 | command: async (input: { |
| 1203 | messageID: string; |
| 1204 | command: string; |
| 1205 | arguments: string; |
| 1206 | model: string; |
| 1207 | }) => { |
| 1208 | Queue.offerUnsafe(calls, input); |
| 1209 | await release.promise; |
| 1210 | return { data: true }; |
| 1211 | }, |
| 1212 | promptAsync: async () => { |
| 1213 | promptCalls++; |
| 1214 | return { data: true }; |
| 1215 | }, |
| 1216 | }, |
| 1217 | }); |
| 1218 | const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped); |
| 1219 | const call = yield* Queue.take(calls); |
| 1220 | assert.equal(call.command, "review"); |
| 1221 | assert.equal(call.arguments, "staged changes"); |
| 1222 | assert.equal(call.model, "anthropic/claude-sonnet"); |
| 1223 | yield* Effect.promise(() => |
| 1224 | nativeEvents.push({ |
| 1225 | type: "message.updated", |
| 1226 | properties: { |
| 1227 | sessionID: sessionId, |
| 1228 | info: { |
| 1229 | id: call.messageID, |
| 1230 | sessionID: sessionId, |
| 1231 | role: "user", |
| 1232 | time: { created: DateTime.toEpochMillis(harness.now) }, |
| 1233 | }, |
| 1234 | }, |
| 1235 | }), |
| 1236 | ); |
| 1237 | yield* Fiber.join(start); |
| 1238 | assert.equal(promptCalls, 0); |
| 1239 | release.resolve(); |
| 1240 | }).pipe( |
| 1241 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1242 | Effect.scoped, |
| 1243 | ), |
| 1244 | ); |
| 1245 | |
| 1246 | it.effect("sends unadvertised slash commands as ordinary prompts", () => |
| 1247 | Effect.gen(function* () { |
| 1248 | const nativeEvents = asyncEventStream(); |
| 1249 | const prompts: unknown[] = []; |
| 1250 | const harness = yield* makeOpenCodeRuntimeHarness("unknown-command", "unknown-command", { |
| 1251 | event: { |
| 1252 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1253 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 1254 | return { stream: nativeEvents.stream }; |
| 1255 | }, |
| 1256 | }, |
| 1257 | command: { list: async () => ({ data: [{ name: "review" }] }) }, |
| 1258 | session: { |
| 1259 | create: async () => ({ |
| 1260 | data: { id: "unknown-command", time: { created: 1, updated: 1 } }, |
| 1261 | }), |
| 1262 | promptAsync: async (input: { parts: unknown }) => { |
| 1263 | prompts.push(input.parts); |
| 1264 | return { data: true }; |
| 1265 | }, |
| 1266 | }, |
| 1267 | }); |
| 1268 | yield* harness.startTurn("/unknown words"); |
| 1269 | assert.deepEqual(prompts, [[{ type: "text", text: "/unknown words" }]]); |
| 1270 | }).pipe( |
| 1271 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1272 | Effect.scoped, |
| 1273 | ), |
| 1274 | ); |
| 1275 | |
| 1276 | it.effect("compacts with the native summarize API and emits a completed compaction", () => |
| 1277 | Effect.gen(function* () { |
| 1278 | const nativeEvents = asyncEventStream(); |
| 1279 | const summarizeCalls: Array<unknown> = []; |
| 1280 | let promptCalls = 0; |
| 1281 | const harness = yield* makeOpenCodeRuntimeHarness("compact", "native-opencode-compact", { |
| 1282 | event: { |
| 1283 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1284 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 1285 | return { stream: nativeEvents.stream }; |
| 1286 | }, |
| 1287 | }, |
| 1288 | session: { |
| 1289 | create: async () => ({ |
| 1290 | data: { id: "native-opencode-compact", time: { created: 1, updated: 1 } }, |
| 1291 | }), |
| 1292 | summarize: async (input: unknown) => { |
| 1293 | summarizeCalls.push(input); |
| 1294 | return { data: true }; |
| 1295 | }, |
| 1296 | promptAsync: async () => { |
| 1297 | promptCalls += 1; |
| 1298 | return { data: true }; |
| 1299 | }, |
| 1300 | }, |
| 1301 | }); |
| 1302 | yield* harness.startTurn("/compact"); |
| 1303 | const events = yield* harness.runtime.events.pipe( |
| 1304 | Stream.takeUntil((event) => event.type === "turn.terminal"), |
| 1305 | Stream.runCollect, |
| 1306 | ); |
| 1307 | assert.deepEqual(summarizeCalls, [ |
| 1308 | { |
| 1309 | sessionID: "native-opencode-compact", |
| 1310 | providerID: "anthropic", |
| 1311 | modelID: "claude-sonnet", |
| 1312 | auto: false, |
| 1313 | }, |
| 1314 | ]); |
| 1315 | assert.equal(promptCalls, 0); |
| 1316 | assert.equal( |
| 1317 | events.filter( |
| 1318 | (event) => |
| 1319 | event.type === "turn_item.updated" && |
| 1320 | event.turnItem.type === "compaction" && |
| 1321 | event.turnItem.status === "completed", |
| 1322 | ).length, |
| 1323 | 1, |
| 1324 | ); |
| 1325 | assert.isTrue( |
| 1326 | events.some((event) => event.type === "turn.terminal" && event.status === "completed"), |
| 1327 | ); |
| 1328 | }).pipe( |
| 1329 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1330 | Effect.scoped, |
| 1331 | ), |
| 1332 | ); |
| 1333 | |
| 1334 | it.effect("ends a turn on the provider thread it started on", () => |
| 1335 | Effect.gen(function* () { |
| 1336 | const nativeEvents = asyncEventStream(); |
| 1337 | const harness = yield* makeOpenCodeRuntimeHarness( |
| 1338 | "forked-row", |
| 1339 | "native-opencode-forked-row", |
| 1340 | { |
| 1341 | event: { |
| 1342 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1343 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 1344 | return { stream: nativeEvents.stream }; |
| 1345 | }, |
| 1346 | }, |
| 1347 | session: { |
| 1348 | create: async () => ({ |
| 1349 | data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } }, |
| 1350 | }), |
| 1351 | summarize: async () => ({ data: true }), |
| 1352 | }, |
| 1353 | }, |
| 1354 | ); |
| 1355 | // A forked run starts on its own row for the same native session, while |
| 1356 | // the adapter tracks the session under the id it minted. |
| 1357 | const forkedRow = { |
| 1358 | ...harness.providerThread, |
| 1359 | id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"), |
| 1360 | }; |
| 1361 | yield* harness.startTurn("/compact", forkedRow); |
| 1362 | const terminal = yield* harness.runtime.events.pipe( |
| 1363 | Stream.filter( |
| 1364 | (event): event is Extract<typeof event, { type: "turn.terminal" }> => |
| 1365 | event.type === "turn.terminal", |
| 1366 | ), |
| 1367 | Stream.runHead, |
| 1368 | ); |
| 1369 | assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id); |
| 1370 | }).pipe( |
| 1371 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1372 | Effect.scoped, |
| 1373 | ), |
| 1374 | ); |
| 1375 | |
| 1376 | it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () => |
| 1377 | Effect.gen(function* () { |
| 1378 | const nativeEvents = asyncEventStream(); |
| 1379 | const nativeSessionId = "native-opencode-reverted-snapshot"; |
| 1380 | const messages = [ |
| 1381 | { |
| 1382 | info: { |
| 1383 | id: "user-kept", |
| 1384 | sessionID: nativeSessionId, |
| 1385 | role: "user", |
| 1386 | time: { created: 1 }, |
| 1387 | }, |
| 1388 | parts: [{ type: "text", text: "keep this prompt" }], |
| 1389 | }, |
| 1390 | { |
| 1391 | info: { |
| 1392 | id: "assistant-kept", |
| 1393 | sessionID: nativeSessionId, |
| 1394 | role: "assistant", |
| 1395 | time: { created: 2, completed: 3 }, |
| 1396 | }, |
| 1397 | parts: [{ type: "text", text: "keep this answer" }], |
| 1398 | }, |
| 1399 | { |
| 1400 | info: { |
| 1401 | id: "user-reverted", |
| 1402 | sessionID: nativeSessionId, |
| 1403 | role: "user", |
| 1404 | time: { created: 4 }, |
| 1405 | }, |
| 1406 | parts: [{ type: "text", text: "remove this prompt" }], |
| 1407 | }, |
| 1408 | { |
| 1409 | info: { |
| 1410 | id: "assistant-reverted", |
| 1411 | sessionID: nativeSessionId, |
| 1412 | role: "assistant", |
| 1413 | time: { created: 5, completed: 6 }, |
| 1414 | }, |
| 1415 | parts: [{ type: "text", text: "remove this answer" }], |
| 1416 | }, |
| 1417 | ]; |
| 1418 | const harness = yield* makeOpenCodeRuntimeHarness("reverted-snapshot", nativeSessionId, { |
| 1419 | event: { |
| 1420 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1421 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { |
| 1422 | once: true, |
| 1423 | }); |
| 1424 | return { stream: nativeEvents.stream }; |
| 1425 | }, |
| 1426 | }, |
| 1427 | session: { |
| 1428 | create: async () => ({ |
| 1429 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, |
| 1430 | }), |
| 1431 | get: async () => ({ |
| 1432 | data: { |
| 1433 | id: nativeSessionId, |
| 1434 | time: { created: 1, updated: 6 }, |
| 1435 | revert: { messageID: "user-reverted" }, |
| 1436 | }, |
| 1437 | }), |
| 1438 | messages: async () => ({ data: messages }), |
| 1439 | }, |
| 1440 | }); |
| 1441 | |
| 1442 | const snapshot = yield* harness.runtime.readThreadSnapshot({ |
| 1443 | providerThread: harness.providerThread, |
| 1444 | }); |
| 1445 | |
| 1446 | assert.deepEqual( |
| 1447 | snapshot.messages.map((message) => message.text), |
| 1448 | ["keep this prompt", "keep this answer"], |
| 1449 | ); |
| 1450 | const providerPayload = snapshot.providerPayload as ReadonlyArray<{ |
| 1451 | readonly info: { readonly id: string }; |
| 1452 | }>; |
| 1453 | assert.deepEqual( |
| 1454 | providerPayload.map((entry) => entry.info.id), |
| 1455 | ["user-kept", "assistant-kept"], |
| 1456 | ); |
| 1457 | assert.equal(snapshot.providerThread.nativeConversationHeadRef?.nativeId, "user-kept"); |
| 1458 | }).pipe( |
| 1459 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1460 | Effect.scoped, |
| 1461 | ), |
| 1462 | ); |
| 1463 | |
| 1464 | it.effect("keeps a newly admitted prompt alive across stale idle and delayed busy evidence", () => |
| 1465 | Effect.gen(function* () { |
| 1466 | const nativeEvents = asyncEventStream(); |
| 1467 | const prompt = promiseGate<void>(); |
| 1468 | const promptStarted = promiseGate<void>(); |
| 1469 | const steerPrompt = promiseGate<void>(); |
| 1470 | const steerPromptStarted = promiseGate<void>(); |
| 1471 | const stopPromptStarted = promiseGate<void>(); |
| 1472 | const statusCalled = promiseGate<void>(); |
| 1473 | const steerStatusCalled = promiseGate<void>(); |
| 1474 | const abortCalled = promiseGate<void>(); |
| 1475 | let status: "idle" | "busy" = "busy"; |
| 1476 | let promptCalls = 0; |
| 1477 | let statusCalls = 0; |
| 1478 | const promptInputs: Array<{ readonly messageID?: string }> = []; |
| 1479 | const client = { |
| 1480 | event: { |
| 1481 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1482 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 1483 | return { stream: nativeEvents.stream }; |
| 1484 | }, |
| 1485 | }, |
| 1486 | session: { |
| 1487 | create: async () => ({ |
| 1488 | data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, |
| 1489 | }), |
| 1490 | get: async () => ({ |
| 1491 | data: { id: "native-opencode-race", time: { created: 1, updated: 1 } }, |
| 1492 | }), |
| 1493 | promptAsync: async ( |
| 1494 | input: { readonly messageID?: string }, |
| 1495 | options?: { readonly signal?: AbortSignal }, |
| 1496 | ) => { |
| 1497 | promptInputs.push(input); |
| 1498 | promptCalls += 1; |
| 1499 | if (promptCalls === 1) { |
| 1500 | promptStarted.resolve(); |
| 1501 | await prompt.promise; |
| 1502 | } else if (promptCalls === 2) { |
| 1503 | steerPromptStarted.resolve(); |
| 1504 | await steerPrompt.promise; |
| 1505 | } else { |
| 1506 | stopPromptStarted.resolve(); |
| 1507 | await new Promise<void>((_resolve, reject) => |
| 1508 | options?.signal?.addEventListener("abort", () => reject(options.signal!.reason), { |
| 1509 | once: true, |
| 1510 | }), |
| 1511 | ); |
| 1512 | } |
| 1513 | return { data: true }; |
| 1514 | }, |
| 1515 | status: async () => ({ |
| 1516 | data: (() => { |
| 1517 | statusCalls += 1; |
| 1518 | if (statusCalls === 1) statusCalled.resolve(); |
| 1519 | else steerStatusCalled.resolve(); |
| 1520 | return { "native-opencode-race": { type: status } }; |
| 1521 | })(), |
| 1522 | }), |
| 1523 | messages: async () => ({ data: [] }), |
| 1524 | children: async () => ({ data: [] }), |
| 1525 | abort: async () => { |
| 1526 | abortCalled.resolve(); |
| 1527 | return { data: true }; |
| 1528 | }, |
| 1529 | }, |
| 1530 | mcp: { add: async () => ({ data: true }) }, |
| 1531 | }; |
| 1532 | const adapter = yield* makeOpenCodeAdapterV2({ |
| 1533 | instanceId: ProviderInstanceId.make("opencode-test"), |
| 1534 | settings: OPEN_CODE_TEST_SETTINGS, |
| 1535 | environment: {}, |
| 1536 | }).pipe( |
| 1537 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { |
| 1538 | connectToOpenCodeServer: () => |
| 1539 | Effect.succeed({ url: "http://test.invalid", external: true }), |
| 1540 | createOpenCodeSdkClient: () => client, |
| 1541 | } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]), |
| 1542 | Effect.provideService(ProviderHost.ProviderHost, { |
| 1543 | paths: { cwd: "/workspace" }, |
| 1544 | resolveAttachmentPath: () => null, |
| 1545 | } as unknown as ProviderHost.ProviderHost["Service"]), |
| 1546 | ); |
| 1547 | const threadId = ThreadId.make("thread-opencode-admission-race"); |
| 1548 | const providerSessionId = ProviderSessionId.make("session-opencode-admission-race"); |
| 1549 | const modelSelection = { |
| 1550 | instanceId: ProviderInstanceId.make("opencode-test"), |
| 1551 | model: "anthropic/claude-sonnet", |
| 1552 | options: [], |
| 1553 | }; |
| 1554 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); |
| 1555 | const runtime = yield* adapter.openSession({ |
| 1556 | threadId, |
| 1557 | providerSessionId, |
| 1558 | modelSelection, |
| 1559 | runtimePolicy: policy, |
| 1560 | }); |
| 1561 | const providerThread = yield* runtime.ensureThread({ |
| 1562 | threadId, |
| 1563 | modelSelection, |
| 1564 | runtimePolicy: policy, |
| 1565 | }); |
| 1566 | const now = yield* DateTime.now; |
| 1567 | const attemptId = RunAttemptId.make("attempt-opencode-admission-race"); |
| 1568 | const runId = RunId.make("run-opencode-admission-race"); |
| 1569 | const start = yield* runtime |
| 1570 | .startTurn({ |
| 1571 | appThread: { |
| 1572 | id: threadId, |
| 1573 | projectId: ProjectId.make("project-opencode-admission-race"), |
| 1574 | title: "race", |
| 1575 | providerInstanceId: modelSelection.instanceId, |
| 1576 | modelSelection, |
| 1577 | runtimeMode: "full-access", |
| 1578 | interactionMode: "default", |
| 1579 | branch: null, |
| 1580 | worktreePath: null, |
| 1581 | activeProviderThreadId: providerThread.id, |
| 1582 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, |
| 1583 | forkedFrom: null, |
| 1584 | createdBy: "user", |
| 1585 | creationSource: "web", |
| 1586 | createdAt: now, |
| 1587 | updatedAt: now, |
| 1588 | archivedAt: null, |
| 1589 | settledOverride: null, |
| 1590 | settledAt: null, |
| 1591 | lastVisitedAt: null, |
| 1592 | deletedAt: null, |
| 1593 | }, |
| 1594 | threadId, |
| 1595 | runId, |
| 1596 | runOrdinal: 1, |
| 1597 | providerTurnOrdinal: 1, |
| 1598 | attemptId, |
| 1599 | rootNodeId: NodeId.make("node-opencode-admission-race"), |
| 1600 | providerThread, |
| 1601 | message: { |
| 1602 | createdBy: "user", |
| 1603 | creationSource: "web", |
| 1604 | messageId: MessageId.make("message-opencode-admission-race"), |
| 1605 | text: "hello", |
| 1606 | attachments: [], |
| 1607 | }, |
| 1608 | modelSelection, |
| 1609 | runtimePolicy: policy, |
| 1610 | }) |
| 1611 | .pipe(Effect.forkScoped); |
| 1612 | |
| 1613 | yield* Effect.promise(() => promptStarted.promise); |
| 1614 | yield* Effect.promise(() => |
| 1615 | nativeEvents.push({ |
| 1616 | type: "session.status", |
| 1617 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, |
| 1618 | }), |
| 1619 | ); |
| 1620 | prompt.resolve(); |
| 1621 | yield* Fiber.join(start); |
| 1622 | assert.match(promptInputs[0]?.messageID ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{28}$/); |
| 1623 | yield* Effect.promise(() => |
| 1624 | nativeEvents.push({ |
| 1625 | type: "session.status", |
| 1626 | properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, |
| 1627 | }), |
| 1628 | ); |
| 1629 | status = "busy"; |
| 1630 | yield* Effect.promise(() => |
| 1631 | nativeEvents.push({ |
| 1632 | type: "message.updated", |
| 1633 | properties: { |
| 1634 | sessionID: "native-opencode-race", |
| 1635 | info: { |
| 1636 | id: "stale-user-message", |
| 1637 | sessionID: "native-opencode-race", |
| 1638 | role: "user", |
| 1639 | time: { created: DateTime.toEpochMillis(now) }, |
| 1640 | }, |
| 1641 | }, |
| 1642 | }), |
| 1643 | ); |
| 1644 | assert.equal(statusCalls, 0, "an unrelated user message must not release prompt admission"); |
| 1645 | yield* Effect.promise(() => |
| 1646 | nativeEvents.push({ |
| 1647 | type: "session.status", |
| 1648 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, |
| 1649 | }), |
| 1650 | ); |
| 1651 | yield* Effect.promise(() => |
| 1652 | nativeEvents.push({ |
| 1653 | type: "message.updated", |
| 1654 | properties: { |
| 1655 | sessionID: "native-opencode-race", |
| 1656 | info: { |
| 1657 | id: promptInputs[0]!.messageID!, |
| 1658 | sessionID: "native-opencode-race", |
| 1659 | role: "user", |
| 1660 | time: { created: DateTime.toEpochMillis(now) }, |
| 1661 | }, |
| 1662 | }, |
| 1663 | }), |
| 1664 | ); |
| 1665 | |
| 1666 | yield* Effect.promise(() => statusCalled.promise); |
| 1667 | |
| 1668 | const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); |
| 1669 | assert.equal(snapshot.providerTurns.at(-1)?.status, "running"); |
| 1670 | const activeTurn = snapshot.providerTurns.at(-1)!; |
| 1671 | const steer = yield* runtime |
| 1672 | .steerTurn({ |
| 1673 | threadId, |
| 1674 | runId, |
| 1675 | providerThread, |
| 1676 | providerTurnId: activeTurn.id, |
| 1677 | message: { |
| 1678 | messageId: MessageId.make("message-opencode-steer-race"), |
| 1679 | text: "follow up", |
| 1680 | attachments: [], |
| 1681 | createdBy: "user", |
| 1682 | creationSource: "web", |
| 1683 | }, |
| 1684 | }) |
| 1685 | .pipe(Effect.forkScoped); |
| 1686 | yield* Effect.promise(() => steerPromptStarted.promise); |
| 1687 | assert.notEqual(promptInputs[1]?.messageID, promptInputs[0]?.messageID); |
| 1688 | steerPrompt.resolve(); |
| 1689 | yield* Fiber.join(steer); |
| 1690 | yield* Effect.promise(() => |
| 1691 | nativeEvents.push({ |
| 1692 | type: "session.status", |
| 1693 | properties: { sessionID: "native-opencode-race", status: { type: "busy" } }, |
| 1694 | }), |
| 1695 | ); |
| 1696 | yield* Effect.promise(() => |
| 1697 | nativeEvents.push({ |
| 1698 | type: "message.updated", |
| 1699 | properties: { |
| 1700 | sessionID: "native-opencode-race", |
| 1701 | info: { |
| 1702 | id: promptInputs[0]!.messageID!, |
| 1703 | sessionID: "native-opencode-race", |
| 1704 | role: "user", |
| 1705 | time: { created: DateTime.toEpochMillis(now) }, |
| 1706 | }, |
| 1707 | }, |
| 1708 | }), |
| 1709 | ); |
| 1710 | yield* Effect.promise(() => |
| 1711 | nativeEvents.push({ |
| 1712 | type: "session.status", |
| 1713 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, |
| 1714 | }), |
| 1715 | ); |
| 1716 | yield* Effect.promise(() => |
| 1717 | nativeEvents.push({ |
| 1718 | type: "message.updated", |
| 1719 | properties: { |
| 1720 | sessionID: "native-opencode-race", |
| 1721 | info: { |
| 1722 | id: promptInputs[1]!.messageID!, |
| 1723 | sessionID: "native-opencode-race", |
| 1724 | role: "user", |
| 1725 | time: { created: DateTime.toEpochMillis(now) }, |
| 1726 | }, |
| 1727 | }, |
| 1728 | }), |
| 1729 | ); |
| 1730 | yield* Effect.promise(() => steerStatusCalled.promise); |
| 1731 | const pendingSteer = yield* runtime |
| 1732 | .steerTurn({ |
| 1733 | threadId, |
| 1734 | runId, |
| 1735 | providerThread, |
| 1736 | providerTurnId: activeTurn.id, |
| 1737 | message: { |
| 1738 | messageId: MessageId.make("message-opencode-stop-pending"), |
| 1739 | text: "pending when stopped", |
| 1740 | attachments: [], |
| 1741 | createdBy: "user", |
| 1742 | creationSource: "web", |
| 1743 | }, |
| 1744 | }) |
| 1745 | .pipe(Effect.exit, Effect.forkScoped); |
| 1746 | yield* Effect.promise(() => stopPromptStarted.promise); |
| 1747 | const interrupt = yield* runtime |
| 1748 | .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) |
| 1749 | .pipe(Effect.forkScoped); |
| 1750 | yield* Effect.promise(() => abortCalled.promise); |
| 1751 | yield* Fiber.join(interrupt); |
| 1752 | assert.isTrue(Exit.isFailure(yield* Fiber.join(pendingSteer))); |
| 1753 | const promptWhileStopping = yield* Effect.exit( |
| 1754 | runtime.steerTurn({ |
| 1755 | threadId, |
| 1756 | runId, |
| 1757 | providerThread, |
| 1758 | providerTurnId: activeTurn.id, |
| 1759 | message: { |
| 1760 | messageId: MessageId.make("message-opencode-after-stop"), |
| 1761 | text: "must not pass the pending stop", |
| 1762 | attachments: [], |
| 1763 | createdBy: "user", |
| 1764 | creationSource: "web", |
| 1765 | }, |
| 1766 | }), |
| 1767 | ); |
| 1768 | assert.isTrue(Exit.isFailure(promptWhileStopping)); |
| 1769 | assert.equal(promptCalls, 3); |
| 1770 | yield* Effect.promise(() => |
| 1771 | nativeEvents.push({ |
| 1772 | type: "session.status", |
| 1773 | properties: { sessionID: "native-opencode-race", status: { type: "idle" } }, |
| 1774 | }), |
| 1775 | ); |
| 1776 | const interrupted = yield* runtime.readThreadSnapshot({ providerThread }); |
| 1777 | assert.equal(interrupted.providerTurns.at(-1)?.status, "interrupted"); |
| 1778 | }).pipe( |
| 1779 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1780 | Effect.scoped, |
| 1781 | ), |
| 1782 | ); |
| 1783 | |
| 1784 | it.effect("interrupts an initial prompt while its SDK request is pending", () => |
| 1785 | Effect.gen(function* () { |
| 1786 | const nativeEvents = asyncEventStream(); |
| 1787 | const promptStarted = promiseGate<void>(); |
| 1788 | const abortCalled = promiseGate<void>(); |
| 1789 | let abortCalls = 0; |
| 1790 | const client = { |
| 1791 | event: { |
| 1792 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1793 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 1794 | return { stream: nativeEvents.stream }; |
| 1795 | }, |
| 1796 | }, |
| 1797 | session: { |
| 1798 | create: async () => ({ |
| 1799 | data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, |
| 1800 | }), |
| 1801 | get: async () => ({ |
| 1802 | data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } }, |
| 1803 | }), |
| 1804 | promptAsync: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { |
| 1805 | promptStarted.resolve(); |
| 1806 | await new Promise<void>((_resolve, reject) => { |
| 1807 | const signal = options?.signal; |
| 1808 | if (signal?.aborted) { |
| 1809 | reject(signal.reason); |
| 1810 | return; |
| 1811 | } |
| 1812 | signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); |
| 1813 | }); |
| 1814 | return { data: true }; |
| 1815 | }, |
| 1816 | status: async () => ({ |
| 1817 | data: { "native-opencode-initial-stop": { type: "idle" as const } }, |
| 1818 | }), |
| 1819 | messages: async () => ({ data: [] }), |
| 1820 | children: async () => ({ data: [] }), |
| 1821 | abort: async () => { |
| 1822 | abortCalls += 1; |
| 1823 | abortCalled.resolve(); |
| 1824 | return { data: true }; |
| 1825 | }, |
| 1826 | }, |
| 1827 | mcp: { add: async () => ({ data: true }) }, |
| 1828 | }; |
| 1829 | const adapter = yield* makeOpenCodeAdapterV2({ |
| 1830 | instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), |
| 1831 | settings: OPEN_CODE_TEST_SETTINGS, |
| 1832 | environment: {}, |
| 1833 | }).pipe( |
| 1834 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { |
| 1835 | connectToOpenCodeServer: () => |
| 1836 | Effect.succeed({ url: "http://test.invalid", external: true }), |
| 1837 | createOpenCodeSdkClient: () => client, |
| 1838 | } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]), |
| 1839 | Effect.provideService(ProviderHost.ProviderHost, { |
| 1840 | paths: { cwd: "/workspace" }, |
| 1841 | resolveAttachmentPath: () => null, |
| 1842 | } as unknown as ProviderHost.ProviderHost["Service"]), |
| 1843 | ); |
| 1844 | const threadId = ThreadId.make("thread-opencode-initial-stop"); |
| 1845 | const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop"); |
| 1846 | const modelSelection = { |
| 1847 | instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), |
| 1848 | model: "anthropic/claude-sonnet", |
| 1849 | options: [], |
| 1850 | }; |
| 1851 | const policy = runtimePolicy("full-access", { cwd: "/workspace" }); |
| 1852 | const runtime = yield* adapter.openSession({ |
| 1853 | threadId, |
| 1854 | providerSessionId, |
| 1855 | modelSelection, |
| 1856 | runtimePolicy: policy, |
| 1857 | }); |
| 1858 | const providerThread = yield* runtime.ensureThread({ |
| 1859 | threadId, |
| 1860 | modelSelection, |
| 1861 | runtimePolicy: policy, |
| 1862 | }); |
| 1863 | const terminalEvents = yield* runtime.events.pipe( |
| 1864 | Stream.takeUntil((event) => event.type === "turn.terminal"), |
| 1865 | Stream.runCollect, |
| 1866 | Effect.forkScoped, |
| 1867 | ); |
| 1868 | const now = yield* DateTime.now; |
| 1869 | const start = yield* runtime |
| 1870 | .startTurn({ |
| 1871 | appThread: { |
| 1872 | id: threadId, |
| 1873 | projectId: ProjectId.make("project-opencode-initial-stop"), |
| 1874 | title: "initial stop", |
| 1875 | providerInstanceId: modelSelection.instanceId, |
| 1876 | modelSelection, |
| 1877 | runtimeMode: "full-access", |
| 1878 | interactionMode: "default", |
| 1879 | branch: null, |
| 1880 | worktreePath: null, |
| 1881 | activeProviderThreadId: providerThread.id, |
| 1882 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, |
| 1883 | forkedFrom: null, |
| 1884 | createdBy: "user", |
| 1885 | creationSource: "web", |
| 1886 | createdAt: now, |
| 1887 | updatedAt: now, |
| 1888 | archivedAt: null, |
| 1889 | settledOverride: null, |
| 1890 | settledAt: null, |
| 1891 | lastVisitedAt: null, |
| 1892 | deletedAt: null, |
| 1893 | }, |
| 1894 | threadId, |
| 1895 | runId: RunId.make("run-opencode-initial-stop"), |
| 1896 | runOrdinal: 1, |
| 1897 | providerTurnOrdinal: 1, |
| 1898 | attemptId: RunAttemptId.make("attempt-opencode-initial-stop"), |
| 1899 | rootNodeId: NodeId.make("node-opencode-initial-stop"), |
| 1900 | providerThread, |
| 1901 | message: { |
| 1902 | createdBy: "user", |
| 1903 | creationSource: "web", |
| 1904 | messageId: MessageId.make("message-opencode-initial-stop"), |
| 1905 | text: "hello", |
| 1906 | attachments: [], |
| 1907 | }, |
| 1908 | modelSelection, |
| 1909 | runtimePolicy: policy, |
| 1910 | }) |
| 1911 | .pipe(Effect.forkScoped); |
| 1912 | |
| 1913 | yield* Effect.promise(() => promptStarted.promise); |
| 1914 | const running = yield* runtime.readThreadSnapshot({ providerThread }); |
| 1915 | const activeTurn = running.providerTurns.at(-1)!; |
| 1916 | const interrupt = yield* runtime |
| 1917 | .interruptTurn({ providerThread, providerTurnId: activeTurn.id }) |
| 1918 | .pipe(Effect.forkScoped); |
| 1919 | |
| 1920 | yield* Effect.promise(() => abortCalled.promise); |
| 1921 | yield* Fiber.join(start); |
| 1922 | yield* Fiber.join(interrupt); |
| 1923 | yield* Effect.promise(() => |
| 1924 | nativeEvents.push({ |
| 1925 | type: "session.status", |
| 1926 | properties: { |
| 1927 | sessionID: "native-opencode-initial-stop", |
| 1928 | status: { type: "idle" }, |
| 1929 | }, |
| 1930 | }), |
| 1931 | ); |
| 1932 | |
| 1933 | const events = Array.from(yield* Fiber.join(terminalEvents)); |
| 1934 | const terminals = events.filter((event) => event.type === "turn.terminal"); |
| 1935 | assert.equal(abortCalls, 1); |
| 1936 | assert.lengthOf(terminals, 1); |
| 1937 | assert.equal(terminals[0]?.status, "interrupted"); |
| 1938 | assert.isNull(terminals[0]?.failure ?? null); |
| 1939 | assert.isFalse( |
| 1940 | events.some( |
| 1941 | (event) => |
| 1942 | (event.type === "turn.terminal" && event.status === "failed") || |
| 1943 | (event.type === "provider_session.updated" && event.providerSession.status === "error"), |
| 1944 | ), |
| 1945 | ); |
| 1946 | }).pipe( |
| 1947 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1948 | Effect.scoped, |
| 1949 | ), |
| 1950 | ); |
| 1951 | |
| 1952 | it.effect("fails an active turn when the OpenCode event stream ends cleanly", () => |
| 1953 | Effect.gen(function* () { |
| 1954 | const nativeEvents = asyncEventStream(); |
| 1955 | const harness = yield* makeOpenCodeRuntimeHarness( |
| 1956 | "clean-event-eof", |
| 1957 | "native-opencode-clean-event-eof", |
| 1958 | { |
| 1959 | event: { |
| 1960 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 1961 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 1962 | return { stream: nativeEvents.stream }; |
| 1963 | }, |
| 1964 | }, |
| 1965 | session: { |
| 1966 | create: async () => ({ |
| 1967 | data: { id: "native-opencode-clean-event-eof", time: { created: 1, updated: 1 } }, |
| 1968 | }), |
| 1969 | promptAsync: async () => ({ data: true }), |
| 1970 | }, |
| 1971 | }, |
| 1972 | ); |
| 1973 | yield* harness.startTurn(); |
| 1974 | const terminalEvents = yield* harness.runtime.events.pipe( |
| 1975 | Stream.runCollect, |
| 1976 | Effect.forkScoped, |
| 1977 | ); |
| 1978 | |
| 1979 | nativeEvents.close(); |
| 1980 | const received = Array.from(yield* Fiber.join(terminalEvents)); |
| 1981 | assert.isTrue( |
| 1982 | received.some( |
| 1983 | (event) => |
| 1984 | event.type === "provider_session.updated" && event.providerSession.status === "error", |
| 1985 | ), |
| 1986 | ); |
| 1987 | const terminal = received.find((event) => event.type === "turn.terminal"); |
| 1988 | assert.equal(terminal?.status, "failed"); |
| 1989 | assert.equal(terminal?.failure?.class, "transport_error"); |
| 1990 | assert.equal(terminal?.threadDisposition, "broken"); |
| 1991 | assert.equal((yield* Effect.exit(harness.startTurn()))._tag, "Failure"); |
| 1992 | }).pipe( |
| 1993 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 1994 | Effect.scoped, |
| 1995 | ), |
| 1996 | ); |
| 1997 | |
| 1998 | it.effect("fails compaction when its response races stream termination", () => |
| 1999 | Effect.gen(function* () { |
| 2000 | const nativeEvents = asyncEventStream(); |
| 2001 | const summarizeStarted = promiseGate<void>(); |
| 2002 | const summarizeResult = promiseGate<{ data: boolean }>(); |
| 2003 | const baseClock = yield* Clock.Clock; |
| 2004 | const eofClockRead = yield* Deferred.make<void>(); |
| 2005 | const releaseEofClockRead = yield* Deferred.make<void>(); |
| 2006 | let blockNextClockRead = false; |
| 2007 | const blockingClock: Clock.Clock = { |
| 2008 | ...baseClock, |
| 2009 | currentTimeMillis: Effect.suspend(() => { |
| 2010 | if (!blockNextClockRead) return baseClock.currentTimeMillis; |
| 2011 | blockNextClockRead = false; |
| 2012 | return Deferred.succeed(eofClockRead, undefined).pipe( |
| 2013 | Effect.andThen(Deferred.await(releaseEofClockRead)), |
| 2014 | Effect.andThen(baseClock.currentTimeMillis), |
| 2015 | ); |
| 2016 | }), |
| 2017 | }; |
| 2018 | const harness = yield* makeOpenCodeRuntimeHarness( |
| 2019 | "compaction-eof-race", |
| 2020 | "native-opencode-compaction-eof-race", |
| 2021 | { |
| 2022 | event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, |
| 2023 | session: { |
| 2024 | create: async () => ({ |
| 2025 | data: { id: "native-opencode-compaction-eof-race", time: { created: 1, updated: 1 } }, |
| 2026 | }), |
| 2027 | summarize: () => { |
| 2028 | summarizeStarted.resolve(); |
| 2029 | return summarizeResult.promise; |
| 2030 | }, |
| 2031 | }, |
| 2032 | }, |
| 2033 | ).pipe(Effect.provideService(Clock.Clock, blockingClock)); |
| 2034 | const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); |
| 2035 | const start = yield* harness.startTurn("/compact").pipe(Effect.forkScoped); |
| 2036 | yield* Effect.promise(() => summarizeStarted.promise); |
| 2037 | blockNextClockRead = true; |
| 2038 | nativeEvents.close(); |
| 2039 | yield* Deferred.await(eofClockRead); |
| 2040 | summarizeResult.resolve({ data: true }); |
| 2041 | yield* Fiber.join(start); |
| 2042 | yield* Deferred.succeed(releaseEofClockRead, undefined); |
| 2043 | const received = Array.from(yield* Fiber.join(events)); |
| 2044 | const terminals = received.filter((event) => event.type === "turn.terminal"); |
| 2045 | assert.lengthOf(terminals, 1); |
| 2046 | assert.equal(terminals[0]?.status, "failed"); |
| 2047 | assert.equal(terminals[0]?.failure?.class, "transport_error"); |
| 2048 | assert.isFalse( |
| 2049 | received.some( |
| 2050 | (event) => |
| 2051 | event.type === "turn_item.updated" && |
| 2052 | event.turnItem.type === "compaction" && |
| 2053 | event.turnItem.status === "completed", |
| 2054 | ), |
| 2055 | ); |
| 2056 | }).pipe( |
| 2057 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 2058 | Effect.scoped, |
| 2059 | ), |
| 2060 | ); |
| 2061 | |
| 2062 | it.effect("does not register a turn after the OpenCode event stream ends", () => |
| 2063 | Effect.gen(function* () { |
| 2064 | const nativeEvents = asyncEventStream(); |
| 2065 | let promptCalls = 0; |
| 2066 | const harness = yield* makeOpenCodeRuntimeHarness( |
| 2067 | "event-eof-start-race", |
| 2068 | "native-opencode-event-eof-start-race", |
| 2069 | { |
| 2070 | event: { |
| 2071 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 2072 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 2073 | return { stream: nativeEvents.stream }; |
| 2074 | }, |
| 2075 | }, |
| 2076 | session: { |
| 2077 | create: async () => ({ |
| 2078 | data: { |
| 2079 | id: "native-opencode-event-eof-start-race", |
| 2080 | time: { created: 1, updated: 1 }, |
| 2081 | }, |
| 2082 | }), |
| 2083 | promptAsync: async () => { |
| 2084 | promptCalls += 1; |
| 2085 | return { data: true }; |
| 2086 | }, |
| 2087 | }, |
| 2088 | }, |
| 2089 | ); |
| 2090 | const baseClock = yield* Clock.Clock; |
| 2091 | const startClockRead = yield* Deferred.make<void>(); |
| 2092 | const releaseStartClockRead = yield* Deferred.make<void>(); |
| 2093 | let blockNextClockRead = true; |
| 2094 | const blockingClock: Clock.Clock = { |
| 2095 | ...baseClock, |
| 2096 | currentTimeMillis: Effect.suspend(() => { |
| 2097 | if (!blockNextClockRead) return baseClock.currentTimeMillis; |
| 2098 | blockNextClockRead = false; |
| 2099 | return Deferred.succeed(startClockRead, undefined).pipe( |
| 2100 | Effect.andThen(Deferred.await(releaseStartClockRead)), |
| 2101 | Effect.andThen(baseClock.currentTimeMillis), |
| 2102 | ); |
| 2103 | }), |
| 2104 | }; |
| 2105 | const start = yield* harness |
| 2106 | .startTurn() |
| 2107 | .pipe(Effect.provideService(Clock.Clock, blockingClock), Effect.exit, Effect.forkScoped); |
| 2108 | yield* Deferred.await(startClockRead); |
| 2109 | |
| 2110 | const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped); |
| 2111 | nativeEvents.close(); |
| 2112 | yield* Fiber.join(events); |
| 2113 | yield* Deferred.succeed(releaseStartClockRead, undefined); |
| 2114 | |
| 2115 | assert.isTrue(Exit.isFailure(yield* Fiber.join(start))); |
| 2116 | assert.equal(promptCalls, 0); |
| 2117 | }).pipe( |
| 2118 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 2119 | Effect.scoped, |
| 2120 | ), |
| 2121 | ); |
| 2122 | |
| 2123 | it("holds stale idle through prompt admission until the new user message is observed", () => { |
| 2124 | const admission = { |
| 2125 | admissionPending: true, |
| 2126 | admissionAccepted: false, |
| 2127 | admissionMessageObserved: false, |
| 2128 | idleDuringAdmission: false, |
| 2129 | }; |
| 2130 | |
| 2131 | assert.equal(advanceOpenCodePromptAdmission(admission, "idle"), "hold"); |
| 2132 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "hold"); |
| 2133 | assert.isTrue(admission.admissionPending); |
| 2134 | assert.equal(advanceOpenCodePromptAdmission(admission, "user-message"), "reconcile-idle"); |
| 2135 | assert.isTrue(admission.admissionPending); |
| 2136 | }); |
| 2137 | |
| 2138 | it("releases admission only after prompt acceptance and new-turn evidence", () => { |
| 2139 | const admission = { |
| 2140 | admissionPending: true, |
| 2141 | admissionAccepted: false, |
| 2142 | admissionMessageObserved: false, |
| 2143 | idleDuringAdmission: false, |
| 2144 | }; |
| 2145 | |
| 2146 | assert.equal(advanceOpenCodePromptAdmission(admission, "busy"), "hold"); |
| 2147 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); |
| 2148 | assert.isFalse(admission.admissionPending); |
| 2149 | }); |
| 2150 | |
| 2151 | it("releases admission when an assistant completes under a provider-assigned message id", () => { |
| 2152 | const admission = { |
| 2153 | admissionPending: true, |
| 2154 | admissionAccepted: false, |
| 2155 | admissionMessageObserved: false, |
| 2156 | idleDuringAdmission: true, |
| 2157 | }; |
| 2158 | |
| 2159 | assert.equal(advanceOpenCodePromptAdmission(admission, "assistant-completed"), "release"); |
| 2160 | assert.isTrue(admission.admissionAccepted); |
| 2161 | assert.isTrue(admission.admissionMessageObserved); |
| 2162 | assert.isFalse(admission.admissionPending); |
| 2163 | }); |
| 2164 | |
| 2165 | it("invalidates pending admission before aborting a turn", () => { |
| 2166 | const admission = { |
| 2167 | admissionGeneration: 4, |
| 2168 | admissionPending: true, |
| 2169 | admissionAccepted: false, |
| 2170 | admissionMessageObserved: false, |
| 2171 | idleDuringAdmission: false, |
| 2172 | }; |
| 2173 | |
| 2174 | cancelOpenCodePromptAdmission(admission, 5); |
| 2175 | |
| 2176 | assert.equal(admission.admissionGeneration, 5); |
| 2177 | assert.isFalse(admission.admissionPending); |
| 2178 | assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release"); |
| 2179 | }); |
| 2180 | |
| 2181 | it.effect("reconciles current idle and busy status replies after prompt admission", () => |
| 2182 | Effect.gen(function* () { |
| 2183 | for (const expected of ["idle", "busy"] as const) { |
| 2184 | const admission = { admissionGeneration: 4, admissionPending: true }; |
| 2185 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); |
| 2186 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( |
| 2187 | admission, |
| 2188 | 4, |
| 2189 | Deferred.await(status), |
| 2190 | ).pipe(Effect.forkChild({ startImmediately: true })); |
| 2191 | |
| 2192 | yield* Deferred.succeed(status, expected); |
| 2193 | |
| 2194 | assert.equal(yield* Fiber.join(fiber), expected); |
| 2195 | assert.isFalse(admission.admissionPending); |
| 2196 | } |
| 2197 | }), |
| 2198 | ); |
| 2199 | |
| 2200 | it.effect("keeps admission pending after a transient status failure", () => |
| 2201 | Effect.gen(function* () { |
| 2202 | const admission = { admissionGeneration: 4, admissionPending: true }; |
| 2203 | assert.equal( |
| 2204 | yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("unknown")), |
| 2205 | "unknown", |
| 2206 | ); |
| 2207 | assert.isTrue(admission.admissionPending); |
| 2208 | assert.equal( |
| 2209 | yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("idle")), |
| 2210 | "idle", |
| 2211 | ); |
| 2212 | assert.isFalse(admission.admissionPending); |
| 2213 | }), |
| 2214 | ); |
| 2215 | |
| 2216 | it.effect("retries a transient status failure without another idle event", () => |
| 2217 | Effect.gen(function* () { |
| 2218 | const nativeSessionId = "native-opencode-status-retry"; |
| 2219 | const nativeEvents = asyncEventStream(); |
| 2220 | const promptRelease = promiseGate<void>(); |
| 2221 | const promptCalls = yield* Queue.unbounded<string>(); |
| 2222 | const statusCalls = yield* Queue.unbounded<number>(); |
| 2223 | let statusCallCount = 0; |
| 2224 | const client = { |
| 2225 | event: { |
| 2226 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 2227 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 2228 | return { stream: nativeEvents.stream }; |
| 2229 | }, |
| 2230 | }, |
| 2231 | session: { |
| 2232 | create: async () => ({ |
| 2233 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, |
| 2234 | }), |
| 2235 | get: async () => ({ |
| 2236 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, |
| 2237 | }), |
| 2238 | promptAsync: async (input: { readonly messageID?: string }) => { |
| 2239 | Queue.offerUnsafe(promptCalls, input.messageID!); |
| 2240 | await promptRelease.promise; |
| 2241 | return { data: true }; |
| 2242 | }, |
| 2243 | status: async () => { |
| 2244 | statusCallCount += 1; |
| 2245 | Queue.offerUnsafe(statusCalls, statusCallCount); |
| 2246 | if (statusCallCount === 1) throw new Error("transient status failure"); |
| 2247 | return { data: { [nativeSessionId]: { type: "idle" as const } } }; |
| 2248 | }, |
| 2249 | messages: async () => ({ data: [] }), |
| 2250 | children: async () => ({ data: [] }), |
| 2251 | abort: async () => ({ data: true }), |
| 2252 | }, |
| 2253 | mcp: { add: async () => ({ data: true }) }, |
| 2254 | }; |
| 2255 | const harness = yield* makeOpenCodeRuntimeHarness("status-retry", nativeSessionId, client); |
| 2256 | const terminalEvents = yield* harness.runtime.events.pipe( |
| 2257 | Stream.takeUntil((event) => event.type === "turn.terminal"), |
| 2258 | Stream.runCollect, |
| 2259 | Effect.forkScoped, |
| 2260 | ); |
| 2261 | const start = yield* harness.startTurn().pipe(Effect.forkScoped); |
| 2262 | const admissionMessageId = yield* Queue.take(promptCalls); |
| 2263 | |
| 2264 | yield* Effect.promise(() => |
| 2265 | nativeEvents.push({ |
| 2266 | type: "session.status", |
| 2267 | properties: { sessionID: nativeSessionId, status: { type: "idle" } }, |
| 2268 | }), |
| 2269 | ); |
| 2270 | promptRelease.resolve(); |
| 2271 | yield* Fiber.join(start); |
| 2272 | const userMessage = { |
| 2273 | type: "message.updated", |
| 2274 | properties: { |
| 2275 | sessionID: nativeSessionId, |
| 2276 | info: { |
| 2277 | id: admissionMessageId, |
| 2278 | sessionID: nativeSessionId, |
| 2279 | role: "user", |
| 2280 | time: { created: DateTime.toEpochMillis(harness.now) }, |
| 2281 | }, |
| 2282 | }, |
| 2283 | }; |
| 2284 | yield* Effect.promise(() => nativeEvents.push(userMessage)); |
| 2285 | assert.equal(yield* Queue.take(statusCalls), 1); |
| 2286 | |
| 2287 | yield* Effect.promise(() => nativeEvents.push(userMessage)); |
| 2288 | assert.equal(statusCallCount, 1, "duplicate events must share the generation's retry worker"); |
| 2289 | yield* TestClock.adjust("250 millis"); |
| 2290 | assert.equal(yield* Queue.take(statusCalls), 2); |
| 2291 | |
| 2292 | const events = Array.from(yield* Fiber.join(terminalEvents)); |
| 2293 | const terminals = events.filter((event) => event.type === "turn.terminal"); |
| 2294 | assert.equal(statusCallCount, 2); |
| 2295 | assert.lengthOf(terminals, 1); |
| 2296 | assert.equal(terminals[0]?.status, "completed"); |
| 2297 | assert.isNull(terminals[0]?.failure ?? null); |
| 2298 | }).pipe( |
| 2299 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 2300 | Effect.scoped, |
| 2301 | ), |
| 2302 | ); |
| 2303 | |
| 2304 | it.effect("does not let a queued status retry adopt a newer steer generation", () => |
| 2305 | Effect.gen(function* () { |
| 2306 | const nativeSessionId = "native-opencode-stale-status-retry"; |
| 2307 | const nativeEvents = asyncEventStream(); |
| 2308 | const firstPromptRelease = promiseGate<void>(); |
| 2309 | const promptCalls = yield* Queue.unbounded<string>(); |
| 2310 | const statusCalls = yield* Queue.unbounded<number>(); |
| 2311 | let promptCallCount = 0; |
| 2312 | let statusCallCount = 0; |
| 2313 | const client = { |
| 2314 | event: { |
| 2315 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 2316 | options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); |
| 2317 | return { stream: nativeEvents.stream }; |
| 2318 | }, |
| 2319 | }, |
| 2320 | session: { |
| 2321 | create: async () => ({ |
| 2322 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, |
| 2323 | }), |
| 2324 | get: async () => ({ |
| 2325 | data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, |
| 2326 | }), |
| 2327 | promptAsync: async (input: { readonly messageID?: string }) => { |
| 2328 | promptCallCount += 1; |
| 2329 | Queue.offerUnsafe(promptCalls, input.messageID!); |
| 2330 | if (promptCallCount === 1) { |
| 2331 | await firstPromptRelease.promise; |
| 2332 | } |
| 2333 | return { data: true }; |
| 2334 | }, |
| 2335 | status: async () => { |
| 2336 | statusCallCount += 1; |
| 2337 | Queue.offerUnsafe(statusCalls, statusCallCount); |
| 2338 | if (statusCallCount === 1) throw new Error("transient status failure"); |
| 2339 | return { data: { [nativeSessionId]: { type: "idle" as const } } }; |
| 2340 | }, |
| 2341 | messages: async () => ({ data: [] }), |
| 2342 | children: async () => ({ data: [] }), |
| 2343 | abort: async () => ({ data: true }), |
| 2344 | }, |
| 2345 | mcp: { add: async () => ({ data: true }) }, |
| 2346 | }; |
| 2347 | const harness = yield* makeOpenCodeRuntimeHarness( |
| 2348 | "stale-status-retry", |
| 2349 | nativeSessionId, |
| 2350 | client, |
| 2351 | ); |
| 2352 | const start = yield* harness.startTurn().pipe(Effect.forkScoped); |
| 2353 | const firstAdmissionMessageId = yield* Queue.take(promptCalls); |
| 2354 | |
| 2355 | yield* Effect.promise(() => |
| 2356 | nativeEvents.push({ |
| 2357 | type: "session.status", |
| 2358 | properties: { sessionID: nativeSessionId, status: { type: "idle" } }, |
| 2359 | }), |
| 2360 | ); |
| 2361 | firstPromptRelease.resolve(); |
| 2362 | yield* Fiber.join(start); |
| 2363 | yield* Effect.promise(() => |
| 2364 | nativeEvents.push({ |
| 2365 | type: "message.updated", |
| 2366 | properties: { |
| 2367 | sessionID: nativeSessionId, |
| 2368 | info: { |
| 2369 | id: firstAdmissionMessageId, |
| 2370 | sessionID: nativeSessionId, |
| 2371 | role: "user", |
| 2372 | time: { created: DateTime.toEpochMillis(harness.now) }, |
| 2373 | }, |
| 2374 | }, |
| 2375 | }), |
| 2376 | ); |
| 2377 | assert.equal(yield* Queue.take(statusCalls), 1); |
| 2378 | |
| 2379 | const running = yield* harness.runtime.readThreadSnapshot({ |
| 2380 | providerThread: harness.providerThread, |
| 2381 | }); |
| 2382 | const activeTurn = running.providerTurns.at(-1)!; |
| 2383 | yield* harness.runtime.steerTurn({ |
| 2384 | threadId: harness.threadId, |
| 2385 | runId: harness.runId, |
| 2386 | providerThread: harness.providerThread, |
| 2387 | providerTurnId: activeTurn.id, |
| 2388 | message: { |
| 2389 | messageId: MessageId.make("message-opencode-stale-status-retry-steer"), |
| 2390 | text: "new generation", |
| 2391 | attachments: [], |
| 2392 | createdBy: "user", |
| 2393 | creationSource: "web", |
| 2394 | }, |
| 2395 | }); |
| 2396 | yield* Queue.take(promptCalls); |
| 2397 | yield* TestClock.adjust("250 millis"); |
| 2398 | |
| 2399 | const afterRetry = yield* harness.runtime.readThreadSnapshot({ |
| 2400 | providerThread: harness.providerThread, |
| 2401 | }); |
| 2402 | assert.equal(statusCallCount, 1); |
| 2403 | assert.equal(afterRetry.providerTurns.at(-1)?.status, "running"); |
| 2404 | }).pipe( |
| 2405 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 2406 | Effect.scoped, |
| 2407 | ), |
| 2408 | ); |
| 2409 | |
| 2410 | it.effect("ignores a delayed status reply after steering starts a newer admission", () => |
| 2411 | Effect.gen(function* () { |
| 2412 | const admission = { admissionGeneration: 4, admissionPending: true }; |
| 2413 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); |
| 2414 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( |
| 2415 | admission, |
| 2416 | 4, |
| 2417 | Deferred.await(status), |
| 2418 | ).pipe(Effect.forkChild({ startImmediately: true })); |
| 2419 | |
| 2420 | admission.admissionGeneration = 5; |
| 2421 | admission.admissionPending = true; |
| 2422 | yield* Deferred.succeed(status, "idle"); |
| 2423 | |
| 2424 | assert.equal(yield* Fiber.join(fiber), "stale"); |
| 2425 | assert.equal(admission.admissionGeneration, 5); |
| 2426 | assert.isTrue(admission.admissionPending); |
| 2427 | }), |
| 2428 | ); |
| 2429 | |
| 2430 | it.effect("does not revive admission when abort wins a pending status lookup", () => |
| 2431 | Effect.gen(function* () { |
| 2432 | const admission = { admissionGeneration: 4, admissionPending: true }; |
| 2433 | const status = yield* Deferred.make<"idle" | "busy" | "unknown">(); |
| 2434 | const fiber = yield* reconcileOpenCodePromptAdmissionStatus( |
| 2435 | admission, |
| 2436 | 4, |
| 2437 | Deferred.await(status), |
| 2438 | ).pipe(Effect.forkChild({ startImmediately: true })); |
| 2439 | |
| 2440 | cancelOpenCodePromptAdmission(admission, 5); |
| 2441 | yield* Deferred.succeed(status, "idle"); |
| 2442 | |
| 2443 | assert.equal(yield* Fiber.join(fiber), "stale"); |
| 2444 | assert.isFalse(admission.admissionPending); |
| 2445 | }), |
| 2446 | ); |
| 2447 | |
| 2448 | it.effect("logs bounded structural protocol diagnostics without native payload values", () => |
| 2449 | Effect.gen(function* () { |
| 2450 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2451 | const records: Array<unknown> = []; |
| 2452 | const nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger = { |
| 2453 | filePath: "/tmp/provider-native.ndjson", |
| 2454 | write: (event) => Effect.sync(() => void records.push(event)), |
| 2455 | close: () => Effect.void, |
| 2456 | }; |
| 2457 | const logProtocolEvent = makeOpenCodeProtocolLogger({ |
| 2458 | nativeEventLogger, |
| 2459 | idAllocator, |
| 2460 | providerInstanceId: ProviderInstanceId.make("opencode-test"), |
| 2461 | providerSessionId: ProviderSessionId.make("provider-session-opencode-test"), |
| 2462 | threadId: ThreadId.make("thread-opencode-test"), |
| 2463 | }); |
| 2464 | const secret = "secret-opencode-prompt"; |
| 2465 | |
| 2466 | yield* logProtocolEvent({ |
| 2467 | direction: "outgoing", |
| 2468 | messageKind: "request", |
| 2469 | method: "session.prompt", |
| 2470 | payload: { prompt: secret, nested: { token: secret } }, |
| 2471 | }); |
| 2472 | |
| 2473 | const serialized = encodeUnknownJson(records); |
| 2474 | assert.notInclude(serialized, secret); |
| 2475 | assert.include(serialized, '"protocol":"opencode-sdk.sse"'); |
| 2476 | assert.include(serialized, '"method":"session.prompt"'); |
| 2477 | assert.include(serialized, '"fieldCount":2'); |
| 2478 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer))), |
| 2479 | ); |
| 2480 | |
| 2481 | it.effect("adopts the handed-over provider thread identity on session create", () => |
| 2482 | Effect.gen(function* () { |
| 2483 | let createCount = 0; |
| 2484 | const createInputs: Array<unknown> = []; |
| 2485 | const fakeClient = { |
| 2486 | event: { |
| 2487 | subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({ |
| 2488 | // Emits nothing and ends when the pump's abort signal fires. |
| 2489 | stream: { |
| 2490 | [Symbol.asyncIterator]: () => ({ |
| 2491 | next: () => |
| 2492 | new Promise<IteratorResult<never>>((resolve) => { |
| 2493 | const done = () => resolve({ done: true, value: undefined }); |
| 2494 | if (options?.signal?.aborted) return done(); |
| 2495 | options?.signal?.addEventListener("abort", done, { once: true }); |
| 2496 | }), |
| 2497 | }), |
| 2498 | }, |
| 2499 | }), |
| 2500 | }, |
| 2501 | session: { |
| 2502 | create: async (input: unknown) => { |
| 2503 | createCount += 1; |
| 2504 | createInputs.push(input); |
| 2505 | return { data: { id: `ses_native_${createCount}`, time: { created: 1, updated: 1 } } }; |
| 2506 | }, |
| 2507 | }, |
| 2508 | } as unknown as OpencodeClient; |
| 2509 | const unused = (operation: string) => () => Effect.die(`${operation} is not used`); |
| 2510 | const runtime: OpenCodeRuntime.OpenCodeRuntime["Service"] = { |
| 2511 | startOpenCodeServerProcess: unused("startOpenCodeServerProcess"), |
| 2512 | connectToOpenCodeServer: () => |
| 2513 | Effect.succeed({ |
| 2514 | url: "test://opencode", |
| 2515 | version: "test", |
| 2516 | exitCode: null, |
| 2517 | external: true, |
| 2518 | }), |
| 2519 | runOpenCodeCommand: unused("runOpenCodeCommand"), |
| 2520 | createOpenCodeSdkClient: () => fakeClient, |
| 2521 | loadOpenCodeInventory: unused("loadOpenCodeInventory"), |
| 2522 | loadInventoryFromCli: unused("loadInventoryFromCli"), |
| 2523 | loadOpenCodeSkills: unused("loadOpenCodeSkills"), |
| 2524 | loadSkillsFromCli: unused("loadSkillsFromCli"), |
| 2525 | }; |
| 2526 | const instanceId = ProviderInstanceId.make("opencode"); |
| 2527 | const threadId = ThreadId.make("thread-opencode-adopt"); |
| 2528 | const modelSelection = { instanceId, model: "default" }; |
| 2529 | const adapter = yield* makeOpenCodeAdapterV2({ |
| 2530 | instanceId, |
| 2531 | settings: OPENCODE_TEST_SETTINGS, |
| 2532 | environment: {}, |
| 2533 | }).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); |
| 2534 | const session = yield* adapter.openSession({ |
| 2535 | threadId, |
| 2536 | providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"), |
| 2537 | modelSelection, |
| 2538 | runtimePolicy: runtimePolicy("full-access"), |
| 2539 | }); |
| 2540 | const now = yield* DateTime.now; |
| 2541 | // The placeholder row the orchestrator creates for a first run: no |
| 2542 | // native identity yet. The adapter must bind the created session to |
| 2543 | // this row instead of minting a second session-keyed row. |
| 2544 | const placeholder: OrchestrationV2ProviderThread = { |
| 2545 | id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"), |
| 2546 | driver: OPENCODE_PROVIDER, |
| 2547 | providerInstanceId: instanceId, |
| 2548 | providerSessionId: null, |
| 2549 | appThreadId: threadId, |
| 2550 | ownerNodeId: null, |
| 2551 | nativeThreadRef: null, |
| 2552 | nativeConversationHeadRef: null, |
| 2553 | status: "not_loaded", |
| 2554 | firstRunOrdinal: 1, |
| 2555 | lastRunOrdinal: 1, |
| 2556 | handoffIds: [], |
| 2557 | forkedFrom: null, |
| 2558 | createdAt: now, |
| 2559 | updatedAt: now, |
| 2560 | }; |
| 2561 | const adopted = yield* session.ensureThread({ |
| 2562 | threadId, |
| 2563 | modelSelection, |
| 2564 | runtimePolicy: runtimePolicy("full-access"), |
| 2565 | existingProviderThread: placeholder, |
| 2566 | }); |
| 2567 | assert.equal(adopted.id, placeholder.id); |
| 2568 | assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1"); |
| 2569 | // Without a handed-over row the adapter still derives its own id. |
| 2570 | const minted = yield* session.ensureThread({ |
| 2571 | threadId, |
| 2572 | modelSelection, |
| 2573 | runtimePolicy: runtimePolicy("full-access"), |
| 2574 | }); |
| 2575 | assert.notEqual(minted.id, placeholder.id); |
| 2576 | assert.equal(minted.nativeThreadRef?.nativeId, "ses_native_2"); |
| 2577 | // OpenCode names a session from its first prompt only when create |
| 2578 | // leaves the title unset, so the adapter never sends one. |
| 2579 | for (const input of createInputs) { |
| 2580 | assert.notProperty(input, "title"); |
| 2581 | } |
| 2582 | }).pipe( |
| 2583 | Effect.scoped, |
| 2584 | Effect.provide( |
| 2585 | Layer.mergeAll( |
| 2586 | IdAllocator.layer, |
| 2587 | McpProviderSessions.layer, |
| 2588 | TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), |
| 2589 | ), |
| 2590 | ), |
| 2591 | ), |
| 2592 | ); |
| 2593 | |
| 2594 | it("maps native permission families to orchestration request kinds", () => { |
| 2595 | assert.equal(openCodePermissionRequestKind("bash"), "command"); |
| 2596 | assert.equal(openCodePermissionRequestKind("read"), "file-read"); |
| 2597 | assert.equal(openCodePermissionRequestKind("grep"), "file-read"); |
| 2598 | assert.equal(openCodePermissionRequestKind("external_directory"), "file-read"); |
| 2599 | assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change"); |
| 2600 | assert.equal(openCodePermissionRequestKind("edit"), "file-change"); |
| 2601 | assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change"); |
| 2602 | assert.equal(openCodePermissionRequestKind("todowrite"), "command"); |
| 2603 | assert.equal(openCodePermissionRequestKind("custom", "todowrite"), "command"); |
| 2604 | }); |
| 2605 | |
| 2606 | it("maps OpenCode tools to semantic turn-item families", () => { |
| 2607 | assert.equal(openCodeToolProjectionKind("bash"), "command_execution"); |
| 2608 | assert.equal(openCodeToolProjectionKind("edit"), "file_change"); |
| 2609 | assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool"); |
| 2610 | assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); |
| 2611 | assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); |
| 2612 | assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); |
| 2613 | assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool"); |
| 2614 | assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool"); |
| 2615 | }); |
| 2616 | |
| 2617 | it("maps runtime modes to safe OpenCode permission rules", () => { |
| 2618 | const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required")); |
| 2619 | assert.equal(permissionAction(approvalRequired, "read"), "allow"); |
| 2620 | assert.equal(permissionAction(approvalRequired, "edit"), "ask"); |
| 2621 | assert.equal(permissionAction(approvalRequired, "bash"), "ask"); |
| 2622 | assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask"); |
| 2623 | assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask"); |
| 2624 | assert.equal(permissionAction(approvalRequired, "question"), "allow"); |
| 2625 | |
| 2626 | const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits")); |
| 2627 | assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow"); |
| 2628 | assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask"); |
| 2629 | |
| 2630 | const fullAccess = openCodePermissionRules(runtimePolicy("full-access")); |
| 2631 | assert.equal(permissionAction(fullAccess, "bash"), "allow"); |
| 2632 | assert.equal(permissionAction(fullAccess, "edit"), "allow"); |
| 2633 | |
| 2634 | const granularApproval = openCodePermissionRules( |
| 2635 | runtimePolicy("full-access", { |
| 2636 | approvalPolicy: { granular: { request_permissions: true } }, |
| 2637 | }), |
| 2638 | ); |
| 2639 | assert.equal(permissionAction(granularApproval, "bash"), "ask"); |
| 2640 | assert.equal(permissionAction(granularApproval, "read"), "allow"); |
| 2641 | |
| 2642 | const approvalRequiredWorkspaceWrite = openCodePermissionRules( |
| 2643 | runtimePolicy("approval-required", { |
| 2644 | sandboxPolicy: { |
| 2645 | type: "workspaceWrite", |
| 2646 | writableRoots: ["/tmp/opencode-workspace"], |
| 2647 | networkAccess: false, |
| 2648 | }, |
| 2649 | }), |
| 2650 | ); |
| 2651 | assert.equal(permissionAction(approvalRequiredWorkspaceWrite, "edit"), "ask"); |
| 2652 | }); |
| 2653 | |
| 2654 | it("enforces non-interactive sandbox policy through OpenCode permissions", () => { |
| 2655 | const readOnly = openCodePermissionRules( |
| 2656 | runtimePolicy("full-access", { |
| 2657 | approvalPolicy: "never", |
| 2658 | sandboxPolicy: { |
| 2659 | type: "readOnly", |
| 2660 | access: { type: "fullAccess" }, |
| 2661 | networkAccess: false, |
| 2662 | }, |
| 2663 | }), |
| 2664 | ); |
| 2665 | assert.equal(permissionAction(readOnly, "read"), "allow"); |
| 2666 | assert.equal(permissionAction(readOnly, "edit"), "deny"); |
| 2667 | assert.equal(permissionAction(readOnly, "bash"), "deny"); |
| 2668 | assert.equal(permissionAction(readOnly, "webfetch"), "deny"); |
| 2669 | assert.equal(permissionAction(readOnly, "doom_loop"), "deny"); |
| 2670 | assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny"); |
| 2671 | assert.equal(permissionAction(readOnly, "external_directory"), "allow"); |
| 2672 | |
| 2673 | const workspaceWrite = openCodePermissionRules( |
| 2674 | runtimePolicy("auto-accept-edits", { |
| 2675 | approvalPolicy: "never", |
| 2676 | sandboxPolicy: { |
| 2677 | type: "workspaceWrite", |
| 2678 | writableRoots: ["/tmp/opencode-workspace"], |
| 2679 | networkAccess: true, |
| 2680 | }, |
| 2681 | }), |
| 2682 | ); |
| 2683 | assert.equal(permissionAction(workspaceWrite, "edit"), "allow"); |
| 2684 | assert.equal(permissionAction(workspaceWrite, "bash"), "deny"); |
| 2685 | assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow"); |
| 2686 | assert.deepInclude(workspaceWrite, { |
| 2687 | permission: "external_directory", |
| 2688 | pattern: "/tmp/opencode-workspace/*", |
| 2689 | action: "allow", |
| 2690 | }); |
| 2691 | }); |
| 2692 | |
| 2693 | it("preserves OpenCode's recursion guard on task-created child sessions", () => { |
| 2694 | const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [ |
| 2695 | { permission: "task", pattern: "*", action: "deny" }, |
| 2696 | ]); |
| 2697 | |
| 2698 | assert.equal(permissionAction(childRules, "read"), "allow"); |
| 2699 | assert.equal(permissionAction(childRules, "bash"), "allow"); |
| 2700 | assert.equal(permissionAction(childRules, "task"), "deny"); |
| 2701 | |
| 2702 | const approvalRequiredPolicy = runtimePolicy("approval-required"); |
| 2703 | const parentRules = openCodePermissionRules(approvalRequiredPolicy); |
| 2704 | const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [ |
| 2705 | ...parentRules.filter((rule) => rule.action === "deny"), |
| 2706 | { permission: "task", pattern: "*", action: "deny" }, |
| 2707 | ]); |
| 2708 | assert.equal(permissionAction(childApprovalRules, "bash"), "ask"); |
| 2709 | assert.equal(permissionAction(childApprovalRules, "task"), "deny"); |
| 2710 | }); |
| 2711 | |
| 2712 | it("uses the next native user message as the exclusive fork and revert boundary", () => { |
| 2713 | const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" }); |
| 2714 | const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null }); |
| 2715 | const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" }); |
| 2716 | |
| 2717 | assert.equal( |
| 2718 | openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id), |
| 2719 | "msg-user-3", |
| 2720 | ); |
| 2721 | assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id)); |
| 2722 | }); |
| 2723 | }); |
| 2724 | |
| 2725 | it.effect.each([false, true])( |
| 2726 | "OpenCode rewind forks history and validates the retained boundary, invalid=%s", |
| 2727 | (invalid) => |
| 2728 | Effect.gen(function* () { |
| 2729 | const events = asyncEventStream(); |
| 2730 | const nativeSessionId = "rewind-source"; |
| 2731 | const forkId = "rewind-fork"; |
| 2732 | const calls: string[] = []; |
| 2733 | const removed = { |
| 2734 | info: { id: "first-user", sessionID: nativeSessionId, role: "user", time: { created: 1 } }, |
| 2735 | parts: [], |
| 2736 | }; |
| 2737 | const harness = yield* makeOpenCodeRuntimeHarness("rewind-fork", nativeSessionId, { |
| 2738 | event: { |
| 2739 | subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { |
| 2740 | options.signal?.addEventListener("abort", () => events.close(), { once: true }); |
| 2741 | return { stream: events.stream }; |
| 2742 | }, |
| 2743 | }, |
| 2744 | session: { |
| 2745 | create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), |
| 2746 | get: async ({ sessionID }: { sessionID: string }) => ({ |
| 2747 | data: { id: sessionID, time: { created: 1, updated: 2 } }, |
| 2748 | }), |
| 2749 | messages: async ({ sessionID }: { sessionID: string }) => ({ |
| 2750 | data: sessionID === nativeSessionId || invalid ? [removed] : [], |
| 2751 | }), |
| 2752 | fork: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { |
| 2753 | assert.equal(sessionID, nativeSessionId); |
| 2754 | assert.equal(messageID, "first-user"); |
| 2755 | calls.push("fork"); |
| 2756 | return { data: { id: forkId, time: { created: 1, updated: 2 } } }; |
| 2757 | }, |
| 2758 | update: async () => { |
| 2759 | calls.push("permissions"); |
| 2760 | return { data: {} }; |
| 2761 | }, |
| 2762 | revert: async () => { |
| 2763 | throw new Error("Native revert would change workspace files"); |
| 2764 | }, |
| 2765 | }, |
| 2766 | }); |
| 2767 | const effect = harness.runtime.rollbackThread({ |
| 2768 | providerThread: harness.providerThread, |
| 2769 | target: { |
| 2770 | type: "thread_start", |
| 2771 | checkpointId: CheckpointId.make("rewind-checkpoint"), |
| 2772 | appRunOrdinal: 0, |
| 2773 | }, |
| 2774 | providerThreadTurns: [], |
| 2775 | }); |
| 2776 | if (invalid) { |
| 2777 | const error = yield* effect.pipe(Effect.flip); |
| 2778 | assert.equal(error._tag, "ProviderAdapterRollbackThreadError"); |
| 2779 | assert.deepEqual(calls, ["fork"]); |
| 2780 | } else { |
| 2781 | const result = yield* effect; |
| 2782 | assert.equal(result.providerThread.nativeThreadRef?.nativeId, forkId); |
| 2783 | assert.equal(result.messages.length, 0); |
| 2784 | assert.deepEqual(calls, ["fork", "permissions"]); |
| 2785 | } |
| 2786 | }).pipe( |
| 2787 | Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), |
| 2788 | Effect.scoped, |
| 2789 | ), |
| 2790 | ); |