1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2";
4import {
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";
19import { OpenCodeSettings } from "../settings.ts";
20import * as Effect from "effect/Effect";
21import * as Clock from "effect/Clock";
22import * as DateTime from "effect/DateTime";
23import * as Deferred from "effect/Deferred";
24import * as Fiber from "effect/Fiber";
25import * as Exit from "effect/Exit";
26import * as Queue from "effect/Queue";
27import * as Layer from "effect/Layer";
28import * as Option from "effect/Option";
29import * as Schema from "effect/Schema";
30import * as Scope from "effect/Scope";
31import * as Stream from "effect/Stream";
32import * as TestClock from "effect/testing/TestClock";
33
34import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
35import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
36import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
37import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
38import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
39import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
40
41import {
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";
54import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
55
56const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
57const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
58 serverUrl: "http://test.invalid",
59});
60
61function 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
69function 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
107const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({});
108
109function 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
121function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) {
122 return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission)
123 ?.action;
124}
125
126function 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
147const 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
243describe("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
2725it.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);