1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2/**
3 * Failures and settings a live OpenCode 2 server cannot produce on demand,
4 * driven through the real adapter and `@opencode/client` against a replayed
5 * HTTP server. Frames reuse the shapes recorded against 2.0.18.
6 */
7import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
8import { assert, it } from "@effect/vitest";
9import {
10 CheckpointId,
11 EnvironmentId,
12 MessageId,
13 NodeId,
14 ProviderInstanceId,
15 ProviderSessionId,
16 ProviderThreadId,
17 ProviderTurnId,
18 RunAttemptId,
19 RunId,
20 ThreadId,
21 type ModelSelection,
22 type OrchestrationV2AppThread,
23 type OrchestrationV2ProviderThread,
24 type OrchestrationV2ProviderTurn,
25 type ProviderReplayEntry,
26} from "@t3tools/contracts";
27import * as Cause from "effect/Cause";
28import * as DateTime from "effect/DateTime";
29import * as Deferred from "effect/Deferred";
30import * as Effect from "effect/Effect";
31import * as Fiber from "effect/Fiber";
32import * as Layer from "effect/Layer";
33import * as Logger from "effect/Logger";
34import * as Option from "effect/Option";
35import * as References from "effect/References";
36import * as Scope from "effect/Scope";
37import * as Stream from "effect/Stream";
38import * as Exit from "effect/Exit";
39import { TestClock } from "effect/testing";
40import { describe } from "vite-plus/test";
41
42import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
43import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
44import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
45import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
46import {
47 OPENCODE_2_STILL_STOPPING,
48 OPENCODE_PROVIDER,
49 t3McpServerName,
50} from "@t3tools/provider-opencode/testing";
51import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts";
52import {
53 makeProviderReplayGate,
54 type ProviderReplayGate,
55} from "@t3tools/provider-testing/replayGate";
56
57const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX";
58const WORK = "/work/opencode2";
59const instanceId = ProviderInstanceId.make("opencode");
60const threadId = ThreadId.make("thread:opencode2-adapter");
61
62const out = (type: string, input?: unknown): ProviderReplayEntry => ({
63 type: "expect_outbound",
64 frame: input === undefined ? { type } : { type, input },
65});
66/** A recorded response body; `{ data }` is the server's envelope, `null` an empty 204. */
67const reply = (operation: string, data: unknown): ProviderReplayEntry => ({
68 type: "emit_inbound",
69 frame: { type: "sdk.response", operation, data },
70});
71const replyData = (operation: string, data: unknown) => reply(operation, { data });
72const event = (
73 type: string,
74 data: Record<string, unknown>,
75 /** Holds the event until a test's replay gate releases this label. */
76 label?: string,
77): ProviderReplayEntry => ({
78 type: "emit_inbound",
79 frame: {
80 type: "sdk.event",
81 event: { id: `evt_${type.replaceAll(".", "")}0000`, created: 1, type, data, ...durable },
82 },
83 ...(label === undefined ? {} : { label }),
84});
85const durable = { durable: { aggregateID: SESSION, seq: 1, version: 1 } };
86
87/** The rules T3 gives every session it runs, with only this thread's own T3 MCP server allowed. */
88const mcpRules = [
89 { action: "t3-code-*", resource: "*", effect: "deny" },
90 { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" },
91];
92const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules];
93/** The thread's title, which the recorded session already carries. */
94const TITLE = "Prime check";
95const sessionInfo = (overrides: Record<string, unknown> = {}) => ({
96 id: SESSION,
97 title: TITLE,
98 permissions: t3Rules,
99 projectID: "global",
100 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
101 cost: 0,
102 tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
103 time: { created: 1790656601394, updated: 1790656601394 },
104 location: { directory: WORK },
105 ...overrides,
106});
107// `/api/model` as 2.0.18 lists big-pickle: its 160k input limit is the usable window.
108const modelCatalog = {
109 location: { directory: WORK },
110 data: [
111 {
112 id: "big-pickle",
113 modelID: "big-pickle",
114 providerID: "opencode",
115 family: "big-pickle",
116 name: "Big Pickle",
117 compatibility: { reasoningField: "reasoning_content" },
118 package: "@opencode/ai/providers/openai-compatible",
119 settings: { apiKey: "public", baseURL: "https://opencode.ai/zen/v1", provider: "opencode" },
120 capabilities: { tools: true, input: ["text"], output: ["text"] },
121 variants: [],
122 time: { released: 1760659200000 },
123 cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
124 status: "active",
125 enabled: true,
126 limit: { context: 200000, input: 160000, output: 32000 },
127 },
128 ],
129};
130
131/** The prompt id the recorded answer carries; a replay maps it to the one T3 chose. */
132const PROMPT_ID = "msg_0eb735d41001NJee1EvVePJAK5";
133const promptAccepted = replyData("session.prompt", {
134 id: PROMPT_ID,
135 sessionID: SESSION,
136 time: { created: 1790656601410 },
137 type: "user",
138 payload: { text: "hi" },
139 delivery: "steer",
140});
141
142/** A resumed session that waits on nothing. */
143const noOpenRequests: ReadonlyArray<ProviderReplayEntry> = [
144 out("permission.list", { sessionID: SESSION }),
145 replyData("permission.list", []),
146 out("session.form.list", { sessionID: SESSION }),
147 replyData("session.form.list", []),
148];
149
150/**
151 * A thread's first turn writes T3's instructions entry before it starts; the
152 * adapter only rewrites it when it changes, so later turns do not.
153 */
154const withInstructions = (
155 entries: ReadonlyArray<ProviderReplayEntry>,
156): ReadonlyArray<ProviderReplayEntry> => {
157 const first = entries.findIndex(
158 (entry) =>
159 entry.type === "expect_outbound" &&
160 typeof entry.frame === "object" &&
161 entry.frame !== null &&
162 "type" in entry.frame &&
163 [
164 "session.prompt",
165 "session.command",
166 "session.compact",
167 "command.list",
168 "skill.list",
169 ].includes(String(entry.frame.type)),
170 );
171 // T3's MCP server is added before the entry that describes it.
172 const after = entries.findIndex(
173 (entry, index) =>
174 index < first &&
175 entry.type === "emit_inbound" &&
176 typeof entry.frame === "object" &&
177 entry.frame !== null &&
178 "operation" in entry.frame &&
179 entry.frame.operation === "mcp.add",
180 );
181 const at = after < 0 ? first : after + 1;
182 return first < 0
183 ? entries
184 : [
185 ...entries.slice(0, at),
186 out("session.instructions.entry.put", {
187 sessionID: SESSION,
188 key: "t3-code",
189 value: "<any>",
190 }),
191 reply("session.instructions.entry.put", null),
192 ...entries.slice(at),
193 ];
194};
195
196const openCode2ReplayRuntimeWithInstructions = (
197 entries: ReadonlyArray<ProviderReplayEntry>,
198 options?: { readonly external?: boolean },
199) => openCode2ReplayRuntime(withInstructions(entries), options);
200
201/** What every session sends when it opens: the event stream, then the model list. */
202const opening: ReadonlyArray<ProviderReplayEntry> = [
203 out("event.subscribe"),
204 out("model.list", "<any>"),
205 reply("model.list", modelCatalog),
206];
207/** The model list read the first time a thread runs in `directory`. */
208const directoryModels = (directory: string): ReadonlyArray<ProviderReplayEntry> => [
209 out("model.list", { "location[directory]": directory }),
210 reply("model.list", { ...modelCatalog, location: { directory } }),
211];
212
213const bigPickle: ModelSelection = { instanceId, model: "opencode/big-pickle" };
214const policy = (runtimeMode: "full-access" | "approval-required" = "full-access") => ({
215 runtimeMode,
216 interactionMode: "default" as const,
217 cwd: WORK,
218});
219
220const providerThread = (now: DateTime.Utc): OrchestrationV2ProviderThread => ({
221 id: ProviderThreadId.make("provider-thread:opencode2-adapter"),
222 driver: OPENCODE_PROVIDER,
223 providerInstanceId: instanceId,
224 providerSessionId: ProviderSessionId.make("provider-session:opencode2-adapter"),
225 appThreadId: threadId,
226 ownerNodeId: null,
227 nativeThreadRef: { driver: OPENCODE_PROVIDER, nativeId: SESSION, strength: "strong" },
228 nativeConversationHeadRef: null,
229 status: "idle",
230 firstRunOrdinal: null,
231 lastRunOrdinal: null,
232 handoffIds: [],
233 forkedFrom: null,
234 createdAt: now,
235 updatedAt: now,
236});
237
238const turnInput = (
239 thread: OrchestrationV2ProviderThread,
240 modelSelection: ModelSelection = bigPickle,
241 runtimeMode: "full-access" | "approval-required" = "full-access",
242) => ({
243 appThread: { title: TITLE } as OrchestrationV2AppThread,
244 threadId,
245 runId: RunId.make("run:opencode2-adapter"),
246 runOrdinal: 1,
247 providerTurnOrdinal: 1,
248 attemptId: RunAttemptId.make("attempt:opencode2-adapter"),
249 rootNodeId: NodeId.make("node:opencode2-adapter"),
250 providerThread: thread,
251 message: {
252 messageId: MessageId.make("message:opencode2-adapter"),
253 text: "hi",
254 attachments: [],
255 createdBy: "user" as const,
256 creationSource: "web" as const,
257 scheduledTaskId: undefined,
258 senderThreadId: undefined,
259 },
260 modelSelection,
261 runtimePolicy: policy(runtimeMode),
262});
263
264/** The agents' own path rules, as `/api/agent` lists them for build. */
265const buildPaths = [
266 {
267 action: "external_directory",
268 resource: "/home/.local/share/opencode/tool-output/*",
269 effect: "allow",
270 },
271];
272const agentList = {
273 location: { directory: WORK },
274 data: [
275 {
276 id: "build",
277 name: "Build",
278 request: { settings: {}, headers: {}, body: {} },
279 description: "The default agent.",
280 mode: "primary",
281 hidden: false,
282 permissions: [{ action: "*", resource: "*", effect: "allow" }, ...buildPaths],
283 },
284 ],
285};
286/** Supervised: shell, edits and other directories ask. */
287const supervisedRules = [
288 { action: "shell", resource: "*", effect: "ask" },
289 { action: "edit", resource: "*", effect: "ask" },
290 { action: "external_directory", resource: "*", effect: "ask" },
291 ...buildPaths,
292 ...mcpRules,
293];
294
295// The first ask and question form the spike recorded (recordings/permission, question).
296const shellAsk = {
297 data: {
298 id: "per_0eb7c4d7e001Pyt8o50Vi4KrOO",
299 sessionID: SESSION,
300 action: "shell",
301 resources: ["echo FIRST"],
302 save: ["echo *"],
303 source: { type: "tool", messageID: "msg_0eb7c4330001dYFQKuTpfD780v", id: "call_1" },
304 },
305};
306const shellAskEvent = event("permission.asked", shellAsk.data);
307const colorForm = {
308 id: "frm_0eb79ab35001fkvFECSh3wYNVD",
309 sessionID: SESSION,
310 title: "Questions",
311 metadata: { kind: "question" },
312 fields: [
313 {
314 key: "q0",
315 title: "Color preference",
316 description: "Which color do you prefer?",
317 type: "string",
318 options: [{ value: "Red", label: "Red" }],
319 custom: true,
320 },
321 ],
322};
323
324/**
325 * Resumes the recorded session and returns the runtime and the thread. A
326 * supervised resume gives the session Supervised rules first.
327 */
328const resumed = (
329 entries: ReadonlyArray<ProviderReplayEntry>,
330 options?: {
331 readonly external?: boolean;
332 readonly supervised?: boolean;
333 readonly replayGate?: ProviderReplayGate;
334 },
335) =>
336 Effect.gen(function* () {
337 const runtime = yield* openCode2ReplayRuntime(
338 withInstructions([
339 ...opening,
340 out("session.get", { sessionID: SESSION }),
341 replyData("session.get", sessionInfo()),
342 ...noOpenRequests,
343 ...(options?.supervised === true
344 ? [
345 out("agent.list", "<any>"),
346 reply("agent.list", agentList),
347 out("session.update", { sessionID: SESSION, permissions: supervisedRules }),
348 reply("session.update", null),
349 ]
350 : []),
351 ...entries,
352 ]),
353 {
354 ...(options?.external === undefined ? {} : { external: options.external }),
355 ...(options?.replayGate === undefined ? {} : { replayGate: options.replayGate }),
356 },
357 );
358 const thread = yield* runtime.resumeThread({
359 providerThread: providerThread(yield* DateTime.now),
360 threadId,
361 modelSelection: bigPickle,
362 runtimePolicy: policy(options?.supervised === true ? "approval-required" : "full-access"),
363 });
364 return { runtime, thread };
365 });
366
367const requestOf = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime) =>
368 runtime.events.pipe(
369 Stream.filter(
370 (
371 event,
372 ): event is Extract<
373 ProviderAdapter.ProviderAdapterV2Event,
374 { type: "runtime_request.updated" }
375 > => event.type === "runtime_request.updated",
376 ),
377 Stream.map((event) => event.runtimeRequest),
378 Stream.runHead,
379 Effect.map(Option.getOrUndefined),
380 );
381
382const terminalOf = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime) =>
383 runtime.events.pipe(
384 Stream.filter(
385 (
386 event,
387 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
388 event.type === "turn.terminal",
389 ),
390 Stream.runHead,
391 Effect.map(Option.getOrUndefined),
392 );
393
394// The history the spike read back after its `simple` turn (recordings/simple.ndjson).
395const history = {
396 data: [
397 {
398 id: "msg_0eb732081001RntUJfRtTXOAjd",
399 time: { created: 1790656585885 },
400 text: "Think carefully step by step about whether 391 is prime, showing your reasoning, then answer in one short sentence.",
401 type: "user",
402 },
403 {
404 id: "msg_0eb7320a9001vve3OV5uNi2HRT",
405 time: { created: 1790656585925, streamed: 1790656590719, completed: 1790656590736 },
406 type: "assistant",
407 agent: "build",
408 model: { id: "space-bunny-free", providerID: "opencode", variant: "high" },
409 content: [
410 { type: "reasoning", text: "Check divisibility up to sqrt(391)." },
411 { type: "text", text: "391 is not prime: it's the product 17 × 23." },
412 ],
413 finish: "stop",
414 cost: 0,
415 tokens: { input: 8701, output: 113, reasoning: 147, cache: { read: 489, write: 0 } },
416 },
417 {
418 id: "msg_0eb733399001NhwTrB32UU6d6H",
419 time: { created: 1790656590745 },
420 type: "idle",
421 outcome: "succeeded",
422 },
423 ],
424 cursor: {},
425};
426
427const layerThreadServices = Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable);
428
429it.layer(layerThreadServices)("OpenCode2 adapter", (it) => {
430 it.effect("switches the session's model and variant before a turn that changed them", () =>
431 Effect.gen(function* () {
432 const { runtime, thread } = yield* resumed([
433 out("session.switchModel", {
434 sessionID: SESSION,
435 model: { providerID: "openrouter", id: "deepseek/deepseek-v4-flash", variant: "high" },
436 }),
437 reply("session.switchModel", null),
438 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
439 promptAccepted,
440 event("session.execution.succeeded", { sessionID: SESSION }),
441 ]);
442 assert.deepEqual(thread.nativeMetadata?.modelSelection, {
443 ...bigPickle,
444 options: [{ id: "variant", value: "default" }],
445 });
446 assert.equal(bigPickle.options, undefined);
447 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
448 yield* runtime.startTurn(
449 turnInput(thread, {
450 instanceId,
451 model: "openrouter/deepseek/deepseek-v4-flash",
452 options: [{ id: "variant", value: "high" }],
453 }),
454 );
455 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
456 }).pipe(Effect.scoped),
457 );
458
459 it.effect("creates the session with the thread's title, so OpenCode generates none", () =>
460 Effect.gen(function* () {
461 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
462 ...opening,
463 out("session.create", {
464 location: { directory: WORK },
465 model: { providerID: "opencode", id: "big-pickle" },
466 permissions: t3Rules,
467 title: "Check whether 391 is prime",
468 }),
469 replyData("session.create", sessionInfo({ title: "Check whether 391 is prime" })),
470 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
471 promptAccepted,
472 event("session.execution.succeeded", { sessionID: SESSION }),
473 ]);
474 const thread = yield* runtime.ensureThread({
475 threadId,
476 title: " Check whether 391 is prime ",
477 modelSelection: bigPickle,
478 runtimePolicy: policy(),
479 });
480 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
481 yield* runtime.startTurn({
482 ...turnInput(thread),
483 appThread: { title: "Check whether 391 is prime" } as OrchestrationV2AppThread,
484 });
485 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
486 }).pipe(Effect.scoped),
487 );
488
489 it.effect("renames the session before the first turn after the thread was renamed", () =>
490 Effect.gen(function* () {
491 const { runtime, thread } = yield* resumed([
492 out("session.update", { sessionID: SESSION, title: "Prime factors of 391" }),
493 reply("session.update", null),
494 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
495 promptAccepted,
496 event("session.execution.succeeded", { sessionID: SESSION }),
497 // Renamed once: the next turn under the same title sends no update.
498 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
499 promptAccepted,
500 event("session.execution.succeeded", { sessionID: SESSION }),
501 ]);
502 const renamed = {
503 ...turnInput(thread),
504 appThread: { title: "Prime factors of 391" } as OrchestrationV2AppThread,
505 };
506 const first = yield* terminalOf(runtime).pipe(Effect.forkScoped);
507 yield* runtime.startTurn(renamed);
508 assert.equal((yield* Fiber.join(first))?.status, "completed");
509 const second = yield* terminalOf(runtime).pipe(Effect.forkScoped);
510 yield* runtime.startTurn({
511 ...renamed,
512 runId: RunId.make("run:opencode2-adapter:2"),
513 attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"),
514 });
515 assert.equal((yield* Fiber.join(second))?.status, "completed");
516 }).pipe(Effect.scoped),
517 );
518
519 it.effect("ends a turn on the provider thread it started on", () =>
520 Effect.gen(function* () {
521 const { runtime, thread } = yield* resumed([
522 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
523 promptAccepted,
524 event("session.execution.succeeded", { sessionID: SESSION }),
525 ]);
526 // A forked run starts on its own row for the same native session, while
527 // the adapter tracks the session under the id it minted for the fork.
528 const forkedRow = {
529 ...thread,
530 id: ProviderThreadId.make("provider-thread:opencode2-adapter:forked-run-row"),
531 };
532 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
533 yield* runtime.startTurn(turnInput(forkedRow));
534 assert.equal((yield* Fiber.join(terminal))?.providerThreadId, forkedRow.id);
535 }).pipe(Effect.scoped),
536 );
537
538 it.effect("ends the turn when its terminal event is one this build cannot decode", () =>
539 Effect.gen(function* () {
540 const { runtime, thread } = yield* resumed([
541 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
542 promptAccepted,
543 // A reason added after 2.0.18: the full schema rejects the frame.
544 event("session.execution.interrupted", { sessionID: SESSION, reason: "budget" }),
545 ]);
546 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
547 yield* runtime.startTurn(turnInput(thread));
548 assert.equal((yield* Fiber.join(terminal))?.status, "interrupted");
549 }).pipe(Effect.scoped),
550 );
551
552 it.effect("keeps a turn running through a start event this build cannot decode", () =>
553 Effect.gen(function* () {
554 const { runtime, thread } = yield* resumed([
555 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
556 promptAccepted,
557 {
558 type: "emit_inbound",
559 frame: {
560 type: "sdk.event",
561 event: {
562 id: "evt_executionstartedx",
563 created: 1,
564 type: "session.execution.started",
565 data: { sessionID: SESSION },
566 durable: "not-an-envelope",
567 },
568 },
569 },
570 event("session.text.started", {
571 sessionID: SESSION,
572 assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z",
573 ordinal: 0,
574 }),
575 event("session.text.ended", {
576 sessionID: SESSION,
577 assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z",
578 ordinal: 0,
579 text: "DONE",
580 }),
581 event("session.execution.succeeded", { sessionID: SESSION }),
582 ]);
583 const collected = yield* runtime.events.pipe(
584 Stream.takeUntil((event) => event.type === "turn.terminal"),
585 Stream.runCollect,
586 Effect.forkScoped,
587 );
588 yield* runtime.startTurn(turnInput(thread));
589 const seen = yield* Fiber.join(collected);
590 const terminals = seen.filter((event) => event.type === "turn.terminal");
591 assert.deepEqual(
592 terminals.map((event) => event.type === "turn.terminal" && event.status),
593 ["completed"],
594 );
595 // The reply after the malformed start still reached the turn.
596 assert.isTrue(
597 seen.some(
598 (event) =>
599 event.type === "turn_item.updated" &&
600 event.turnItem.type === "assistant_message" &&
601 event.turnItem.text === "DONE",
602 ),
603 );
604 }).pipe(Effect.scoped),
605 );
606
607 it.effect("stops running turns on an external server when the session closes", () =>
608 Effect.gen(function* () {
609 const scope = yield* Scope.make();
610 const { runtime, thread } = yield* resumed(
611 [
612 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
613 promptAccepted,
614 out("session.interrupt", { sessionID: SESSION }),
615 reply("session.interrupt", { interrupted: true }),
616 ],
617 { external: true },
618 ).pipe(Scope.provide(scope));
619 yield* runtime.startTurn(turnInput(thread));
620 yield* Scope.close(scope, Exit.void);
621 }),
622 );
623
624 it.effect("sends nothing for a turn stopped while it reads the workspace's commands", () =>
625 Effect.gen(function* () {
626 // The command list never answers; the Stop lands while the turn waits
627 // on it, before anything was sent, so OpenCode has nothing running. The
628 // replay fails on any prompt or command sent after the Stop.
629 const { runtime, thread } = yield* resumed([
630 out("command.list", "<any>"),
631 reply("command.list", "<hang>"),
632 out("session.interrupt", { sessionID: SESSION }),
633 reply("session.interrupt", { interrupted: false }),
634 ]);
635 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
636 const started = yield* runtime
637 .startTurn({
638 ...turnInput(thread),
639 message: { ...turnInput(thread).message, text: "/hello WORLD" },
640 })
641 .pipe(Effect.forkScoped);
642 yield* TestClock.adjust("1 second");
643 yield* runtime.interruptTurn({
644 providerThread: thread,
645 providerTurnId: yield* providerTurnId,
646 requestRuntimeRestart: true,
647 });
648 // The command list times out; the stopped turn must not go on to prompt.
649 yield* TestClock.adjust("6 seconds");
650 yield* Fiber.join(started);
651 assert.equal((yield* Fiber.join(terminal))?.status, "interrupted");
652 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
653 );
654
655 it.effect("ends a turn locally when a stuck server never answers Stop", () =>
656 Effect.gen(function* () {
657 const { runtime, thread } = yield* resumed([
658 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
659 promptAccepted,
660 out("session.interrupt", { sessionID: SESSION }),
661 reply("session.interrupt", "<hang>"),
662 ]);
663 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
664 yield* runtime.startTurn(turnInput(thread));
665 const interrupt = yield* runtime
666 .interruptTurn({
667 providerThread: thread,
668 providerTurnId: yield* providerTurnId,
669 })
670 .pipe(Effect.forkScoped);
671 yield* TestClock.adjust("11 seconds");
672 yield* Fiber.join(interrupt);
673 assert.equal((yield* Fiber.join(terminal))?.status, "interrupted");
674 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
675 );
676
677 /** A subagent's session as 2.0.18 announced it in the spike's `background` run. */
678 const childCreated = (child: string) => ({
679 sessionID: child,
680 slug: "proud-canyon",
681 version: "2.0.18",
682 projectID: "global",
683 parentID: SESSION,
684 location: { directory: WORK },
685 subpath: "",
686 title: "Sleep",
687 agent: "general",
688 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
689 });
690 /** A turn whose model launched a background subagent that still runs. */
691 const backgroundLaunch = (child: string): ReadonlyArray<ProviderReplayEntry> => {
692 const call = "call-background";
693 const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call };
694 return [
695 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
696 promptAccepted,
697 event("session.execution.started", { sessionID: SESSION }),
698 event("session.tool.input.started", { ...tool, name: "subagent" }),
699 event("session.tool.called", {
700 ...tool,
701 name: "subagent",
702 input: { description: "Sleep", prompt: "sleep", background: true },
703 executed: false,
704 }),
705 event("session.created", childCreated(child)),
706 event("session.tool.progress", {
707 ...tool,
708 metadata: { sessionID: child, status: "running" },
709 }),
710 ];
711 };
712 // The subagent's child thread hangs off the app thread's lineage.
713 const withLineage = (thread: OrchestrationV2ProviderThread) => ({
714 ...turnInput(thread),
715 appThread: {
716 id: threadId,
717 title: TITLE,
718 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
719 } as OrchestrationV2AppThread,
720 });
721 /**
722 * The continuation turn the orchestrator starts for an offered wake: its
723 * message is the one the offer named, as `ProviderContinuationService` dispatches it.
724 */
725 const continuationTurn = (
726 thread: OrchestrationV2ProviderThread,
727 offer: ProviderContinuationRequests.ProviderContinuationRequest,
728 suffix = "wake",
729 ) => ({
730 ...withLineage(thread),
731 runId: RunId.make(`run:opencode2-adapter:${suffix}`),
732 runOrdinal: 2,
733 providerTurnOrdinal: 2,
734 attemptId: RunAttemptId.make(`attempt:opencode2-adapter:${suffix}`),
735 message: {
736 ...turnInput(thread).message,
737 messageId: offer.messageId!,
738 text: offer.detail ?? "",
739 createdBy: "agent" as const,
740 creationSource: "provider" as const,
741 },
742 });
743 const CHILD = "ses_f1485c529ffea4URrYruwEg0Ja";
744 /**
745 * The single reader of the runtime's events: resolves `attached` once the
746 * running background child has its thread, then returns the turn's terminal.
747 */
748 const watchBackgroundTurn = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime) =>
749 Effect.gen(function* () {
750 const attached = yield* Deferred.make<void>();
751 const terminal = yield* runtime.events.pipe(
752 Stream.tap((event) =>
753 event.type === "subagent.updated" && event.subagent.childThreadId !== null
754 ? Deferred.succeed(attached, undefined)
755 : Effect.void,
756 ),
757 Stream.filter(
758 (
759 event,
760 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
761 event.type === "turn.terminal",
762 ),
763 Stream.runHead,
764 Effect.map(Option.getOrUndefined),
765 Effect.forkScoped,
766 );
767 return { attached: Deferred.await(attached), terminal: Fiber.join(terminal) };
768 });
769 it.effect("keeps background subagents running when a turn is interrupted to restart it", () =>
770 Effect.gen(function* () {
771 const { runtime, thread } = yield* resumed([
772 ...backgroundLaunch(CHILD),
773 // Only the parent's execution is stopped.
774 out("session.interrupt", { sessionID: SESSION }),
775 reply("session.interrupt", { interrupted: true }),
776 event("session.execution.interrupted", { sessionID: SESSION }),
777 ]);
778 const watch = yield* watchBackgroundTurn(runtime);
779 yield* runtime.startTurn(withLineage(thread));
780 yield* watch.attached;
781 // The orchestrator's restart interrupt: no `requestRuntimeRestart`.
782 yield* runtime.interruptTurn({
783 providerThread: thread,
784 providerTurnId: yield* providerTurnId,
785 });
786 assert.equal((yield* watch.terminal)?.status, "interrupted");
787 assert.isTrue(yield* runtime.hasPendingBackgroundWork!);
788 }).pipe(Effect.scoped),
789 );
790
791 it.effect("stops background subagents on a user Stop", () =>
792 Effect.gen(function* () {
793 const { runtime, thread } = yield* resumed([
794 ...backgroundLaunch(CHILD),
795 out("session.interrupt", { sessionID: CHILD }),
796 reply("session.interrupt", { interrupted: true }),
797 out("session.interrupt", { sessionID: SESSION }),
798 reply("session.interrupt", { interrupted: true }),
799 event("session.execution.interrupted", { sessionID: SESSION }),
800 ]);
801 const watch = yield* watchBackgroundTurn(runtime);
802 yield* runtime.startTurn(withLineage(thread));
803 yield* watch.attached;
804 yield* runtime.interruptTurn({
805 providerThread: thread,
806 providerTurnId: yield* providerTurnId,
807 requestRuntimeRestart: true,
808 });
809 assert.equal((yield* watch.terminal)?.status, "interrupted");
810 assert.isFalse(yield* runtime.hasPendingBackgroundWork!);
811 }).pipe(Effect.scoped),
812 );
813
814 it.effect("keeps tracking a background subagent whose Stop did not reach it", () =>
815 Effect.gen(function* () {
816 const { runtime, thread } = yield* resumed([
817 ...backgroundLaunch(CHILD),
818 out("session.interrupt", { sessionID: CHILD }),
819 reply("session.interrupt", {
820 status: 500,
821 body: { _tag: "UnknownError", message: "interrupt failed" },
822 }),
823 out("session.interrupt", { sessionID: SESSION }),
824 reply("session.interrupt", { interrupted: true }),
825 event("session.execution.interrupted", { sessionID: SESSION }),
826 ]);
827 const watch = yield* watchBackgroundTurn(runtime);
828 yield* runtime.startTurn(withLineage(thread));
829 yield* watch.attached;
830 yield* runtime.interruptTurn({
831 providerThread: thread,
832 providerTurnId: yield* providerTurnId,
833 requestRuntimeRestart: true,
834 });
835 assert.equal((yield* watch.terminal)?.status, "interrupted");
836 // The subagent still runs, so the session is not idle and the next Stop reaches it.
837 assert.isTrue(yield* runtime.hasPendingBackgroundWork!);
838 assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
839 }).pipe(Effect.scoped),
840 );
841
842 it.effect("stops a background subagent announced before its call named it", () =>
843 Effect.gen(function* () {
844 const call = "call-background";
845 const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call };
846 const { runtime, thread } = yield* resumed([
847 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
848 promptAccepted,
849 event("session.execution.started", { sessionID: SESSION }),
850 event("session.tool.input.started", { ...tool, name: "subagent" }),
851 event("session.tool.called", {
852 ...tool,
853 name: "subagent",
854 input: { description: "Sleep", prompt: "sleep", background: true },
855 executed: false,
856 }),
857 // OpenCode announces the child before the call's progress names it.
858 event("session.created", childCreated(CHILD)),
859 // Emitted after the announcement, so the Stop comes after it too.
860 event("session.text.ended", {
861 sessionID: SESSION,
862 assistantMessageID: "msg_assistant",
863 ordinal: 0,
864 text: "Launched.",
865 }),
866 out("session.interrupt", { sessionID: CHILD }),
867 reply("session.interrupt", { interrupted: true }),
868 out("session.interrupt", { sessionID: SESSION }),
869 reply("session.interrupt", { interrupted: true }),
870 event("session.execution.interrupted", { sessionID: SESSION }),
871 ]);
872 const announced = yield* Deferred.make<void>();
873 const terminal = yield* runtime.events.pipe(
874 Stream.tap((event) =>
875 event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
876 ? Deferred.succeed(announced, undefined)
877 : Effect.void,
878 ),
879 Stream.filter(
880 (
881 event,
882 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
883 event.type === "turn.terminal",
884 ),
885 Stream.runHead,
886 Effect.map(Option.getOrUndefined),
887 Effect.forkScoped,
888 );
889 yield* runtime.startTurn(withLineage(thread));
890 yield* Deferred.await(announced);
891 yield* runtime.interruptTurn({
892 providerThread: thread,
893 providerTurnId: yield* providerTurnId,
894 requestRuntimeRestart: true,
895 });
896 assert.equal((yield* Fiber.join(terminal))?.status, "interrupted");
897 assert.isFalse(yield* runtime.hasPendingBackgroundWork!);
898 }).pipe(Effect.scoped),
899 );
900
901 it.effect("gives a background subagent the rules of a mode changed while it runs", () =>
902 Effect.gen(function* () {
903 const { runtime, thread } = yield* resumed([
904 ...backgroundLaunch(CHILD),
905 event("session.execution.succeeded", { sessionID: SESSION }),
906 // The next turn runs Supervised while the subagent from Full access still runs.
907 out("agent.list", "<any>"),
908 reply("agent.list", agentList),
909 out("session.update", { sessionID: SESSION, permissions: supervisedRules }),
910 reply("session.update", null),
911 // A subagent's session may use its thread's T3 MCP server.
912 out("session.update", {
913 sessionID: CHILD,
914 permissions: [...supervisedRules.slice(0, 3), ...mcpRules],
915 }),
916 reply("session.update", null),
917 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
918 promptAccepted,
919 event("session.execution.succeeded", { sessionID: SESSION }),
920 ]);
921 const firstEnded = yield* Deferred.make<void>();
922 const ended = yield* runtime.events.pipe(
923 Stream.filter(
924 (
925 event,
926 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
927 event.type === "turn.terminal",
928 ),
929 Stream.tap(() => Deferred.succeed(firstEnded, undefined)),
930 Stream.take(2),
931 Stream.runCollect,
932 Effect.forkScoped,
933 );
934 yield* runtime.startTurn(withLineage(thread));
935 yield* Deferred.await(firstEnded);
936 yield* runtime.startTurn({
937 ...withLineage(thread),
938 runId: RunId.make("run:opencode2-adapter:2"),
939 runOrdinal: 2,
940 providerTurnOrdinal: 2,
941 attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"),
942 runtimePolicy: policy("approval-required"),
943 });
944 assert.deepEqual(
945 [...(yield* Fiber.join(ended))].map((terminal) => terminal.status),
946 ["completed", "completed"],
947 );
948 }).pipe(Effect.scoped),
949 );
950
951 it.effect("keeps a finished background subagent's queued report as pending work", () =>
952 Effect.gen(function* () {
953 const { runtime, thread } = yield* resumed([
954 ...backgroundLaunch(CHILD),
955 event("session.execution.succeeded", { sessionID: SESSION }),
956 // The child ends; OpenCode queues its report for the parent, and only
957 // then starts the parent's follow-up execution.
958 event("session.execution.succeeded", { sessionID: CHILD }),
959 event("session.inbox.enqueued", {
960 inboxID: "msg_report",
961 sessionID: SESSION,
962 item: {
963 type: "synthetic",
964 payload: {
965 text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`,
966 description: "Sleep",
967 metadata: {
968 source: "subagent",
969 childID: CHILD,
970 agent: "General",
971 state: "completed",
972 },
973 },
974 delivery: "steer",
975 },
976 }),
977 // Emitted once the report is in, so the check below runs in the gap.
978 event("session.usage.updated", { sessionID: SESSION }),
979 ]);
980 const reported = yield* Deferred.make<void>();
981 yield* runtime.events.pipe(
982 Stream.tap((event) =>
983 event.type === "subagent.updated" && event.subagent.status === "completed"
984 ? Deferred.succeed(reported, undefined)
985 : Effect.void,
986 ),
987 Stream.runDrain,
988 Effect.forkScoped,
989 );
990 yield* runtime.startTurn(withLineage(thread));
991 yield* Deferred.await(reported);
992 // The follow-up execution OpenCode will start for the report is still to come.
993 assert.isTrue(yield* runtime.hasPendingBackgroundWork!);
994 assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
995 }).pipe(Effect.scoped),
996 );
997
998 /** A `subagent` call's result while its subagent runs on (`session.tool.success` is at version 2). */
999 const returnedRunning = (id: string): ProviderReplayEntry => ({
1000 type: "emit_inbound",
1001 frame: {
1002 type: "sdk.event",
1003 event: {
1004 id: `evt_toolsuccess_${id}`,
1005 created: 1,
1006 type: "session.tool.success",
1007 data: {
1008 sessionID: SESSION,
1009 assistantMessageID: "msg_assistant",
1010 id,
1011 content: [{ type: "text", text: "The subagent is working in the background." }],
1012 metadata: { sessionID: CHILD, status: "running" },
1013 executed: true,
1014 },
1015 durable: { aggregateID: SESSION, seq: 1, version: 2 },
1016 },
1017 },
1018 });
1019 /** The model calls the subagent again by its session; the tool returns while it runs on. */
1020 const callAgain = (input: Record<string, unknown>): ReadonlyArray<ProviderReplayEntry> => {
1021 const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-again" };
1022 return [
1023 event("session.tool.input.started", { ...tool, name: "subagent" }),
1024 event("session.tool.called", {
1025 ...tool,
1026 name: "subagent",
1027 input: { description: "Again", prompt: "also this", sessionID: CHILD, ...input },
1028 executed: false,
1029 }),
1030 event("session.tool.progress", {
1031 ...tool,
1032 metadata: { sessionID: CHILD, status: "running" },
1033 }),
1034 // A session called again gets the thread's rules.
1035 out("session.update", { sessionID: CHILD, permissions: "<any>" }),
1036 reply("session.update", null),
1037 returnedRunning(tool.id),
1038 ];
1039 };
1040 const childReport = (inboxID: string, text: string) =>
1041 event("session.inbox.enqueued", {
1042 inboxID,
1043 sessionID: SESSION,
1044 item: {
1045 type: "synthetic",
1046 payload: {
1047 text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\n${text}\n</subagent>`,
1048 description: "Sleep",
1049 metadata: { source: "subagent", childID: CHILD, agent: "General", state: "completed" },
1050 },
1051 delivery: "steer",
1052 },
1053 });
1054 /** The subagent rows by title, the thread's background roster and the child's turns, as emitted. */
1055 const watchCalls = (
1056 runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime,
1057 thread: OrchestrationV2ProviderThread,
1058 ) =>
1059 Effect.gen(function* () {
1060 const calls = new Map<string, { status: string; result: string | null }>();
1061 const childTurns = new Map<string, string>();
1062 const callsAtChildTurnStart = new Map<
1063 string,
1064 Record<string, { status: string; result: string | null }>
1065 >();
1066 const state: { roster: ReadonlyArray<unknown> | undefined } = { roster: undefined };
1067 let wake = yield* Deferred.make<void>();
1068 yield* runtime.events.pipe(
1069 Stream.tap((event) =>
1070 Effect.gen(function* () {
1071 if (event.type === "subagent.updated" && event.subagent.title !== null) {
1072 calls.set(event.subagent.title, {
1073 status: event.subagent.status,
1074 result: event.subagent.result,
1075 });
1076 }
1077 if (event.type === "provider_thread.updated" && event.providerThread.id === thread.id) {
1078 state.roster = event.providerThread.pendingBackgroundTasks;
1079 }
1080 const id =
1081 event.type === "provider_turn.updated"
1082 ? event.providerTurn.nativeTurnRef?.nativeId
1083 : undefined;
1084 if (
1085 event.type === "provider_turn.updated" &&
1086 id?.startsWith(`${CHILD}:turn:`) === true
1087 ) {
1088 if (!childTurns.has(id)) {
1089 callsAtChildTurnStart.set(id, Object.fromEntries(calls));
1090 }
1091 childTurns.set(id, event.providerTurn.status);
1092 }
1093 yield* Deferred.succeed(wake, undefined);
1094 }),
1095 ),
1096 Stream.runDrain,
1097 Effect.forkScoped,
1098 );
1099 const until = (check: () => boolean): Effect.Effect<void> =>
1100 Effect.suspend(() => {
1101 if (check()) return Effect.void;
1102 return Effect.gen(function* () {
1103 wake = yield* Deferred.make<void>();
1104 if (check()) return;
1105 yield* Deferred.await(wake);
1106 yield* until(check);
1107 });
1108 });
1109 return { calls, callsAtChildTurnStart, childTurns, state, until };
1110 });
1111 const launchedAndRunning = [
1112 ...backgroundLaunch(CHILD),
1113 returnedRunning("call-background"),
1114 event("session.execution.started", { sessionID: CHILD }),
1115 ];
1116
1117 it.effect("settles every call that joined a running subagent with its one report", () =>
1118 Effect.gen(function* () {
1119 const { runtime, thread } = yield* resumed([
1120 ...launchedAndRunning,
1121 // OpenCode steers the running subagent and joins its run: one report answers both calls.
1122 ...callAgain({ background: true }),
1123 event("session.execution.succeeded", { sessionID: SESSION }),
1124 event("session.execution.succeeded", { sessionID: CHILD }),
1125 childReport("msg_report", "CHILD_OK"),
1126 ]);
1127 const watch = yield* watchCalls(runtime, thread);
1128 yield* runtime.startTurn(withLineage(thread));
1129 yield* watch.until(
1130 () =>
1131 watch.calls.get("Again")?.status === "completed" &&
1132 watch.calls.get("Sleep")?.status === "completed" &&
1133 watch.state.roster?.length === 0,
1134 );
1135 assert.deepEqual(Object.fromEntries(watch.calls), {
1136 Sleep: { status: "completed", result: "CHILD_OK" },
1137 Again: { status: "completed", result: "CHILD_OK" },
1138 });
1139 }).pipe(Effect.scoped),
1140 );
1141
1142 it.effect("keeps a foreground call that joined a background run for that run's report", () =>
1143 Effect.gen(function* () {
1144 const { runtime, thread } = yield* resumed([
1145 ...launchedAndRunning,
1146 // Without `background`, the call joins the run in the background all the same.
1147 ...callAgain({}),
1148 event("session.execution.succeeded", { sessionID: SESSION }),
1149 event("session.execution.succeeded", { sessionID: CHILD }),
1150 childReport("msg_report", "CHILD_OK"),
1151 ]);
1152 const watch = yield* watchCalls(runtime, thread);
1153 yield* runtime.startTurn(withLineage(thread));
1154 yield* watch.until(
1155 () =>
1156 watch.calls.get("Again")?.status === "completed" &&
1157 watch.calls.get("Sleep")?.status === "completed" &&
1158 watch.state.roster?.length === 0,
1159 );
1160 // Not settled empty when the turn that made it ended.
1161 assert.deepEqual(watch.calls.get("Again"), { status: "completed", result: "CHILD_OK" });
1162 }).pipe(Effect.scoped),
1163 );
1164
1165 it.effect("keeps a call to a finished subagent for its own run's report", () =>
1166 Effect.gen(function* () {
1167 const { runtime, thread } = yield* resumed([
1168 ...launchedAndRunning,
1169 event("session.execution.succeeded", { sessionID: CHILD }),
1170 // Called again after it ended but before its report: the call starts a new run.
1171 ...callAgain({ background: true }),
1172 childReport("msg_report_1", "FIRST_OK"),
1173 event("session.execution.started", { sessionID: CHILD }),
1174 event("session.execution.succeeded", { sessionID: CHILD }),
1175 event("session.execution.succeeded", { sessionID: SESSION }),
1176 childReport("msg_report_2", "SECOND_OK"),
1177 ]);
1178 const watch = yield* watchCalls(runtime, thread);
1179 yield* runtime.startTurn(withLineage(thread));
1180 // The new run's turn starts after the first report was handled.
1181 yield* watch.until(() => watch.childTurns.has(`${CHILD}:turn:2`));
1182 assert.deepEqual(watch.callsAtChildTurnStart.get(`${CHILD}:turn:2`), {
1183 Sleep: { status: "completed", result: "FIRST_OK" },
1184 Again: { status: "running", result: null },
1185 });
1186 yield* watch.until(() => watch.calls.get("Again")?.status === "completed");
1187 assert.deepEqual(watch.calls.get("Again"), { status: "completed", result: "SECOND_OK" });
1188 }).pipe(Effect.scoped),
1189 );
1190
1191 it.effect("leaves no background work pending once a reconnect finds its subagent gone", () =>
1192 Effect.gen(function* () {
1193 // The subagent ends while the stream is down: its end, its report and
1194 // the follow-up it starts are never seen. `session.active` lists nothing.
1195 const { runtime, thread } = yield* resumed([
1196 ...backgroundLaunch(CHILD),
1197 event("session.execution.started", { sessionID: CHILD }),
1198 event("session.execution.succeeded", { sessionID: SESSION }),
1199 { type: "runtime_exit", status: "success" } as const,
1200 out("event.subscribe"),
1201 out("session.active"),
1202 replyData("session.active", {}),
1203 ]);
1204 const settled = yield* Deferred.make<string | null>();
1205 yield* runtime.events.pipe(
1206 Stream.tap((event) =>
1207 event.type === "subagent.updated" && event.subagent.status === "interrupted"
1208 ? Deferred.succeed(settled, event.subagent.result)
1209 : Effect.void,
1210 ),
1211 Stream.runDrain,
1212 Effect.forkScoped,
1213 );
1214 yield* runtime.startTurn(withLineage(thread));
1215 assert.include((yield* Deferred.await(settled)) ?? "", "lost its connection to OpenCode");
1216 assert.isFalse(yield* runtime.hasPendingBackgroundWork!);
1217 assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
1218 }).pipe(Effect.scoped),
1219 );
1220
1221 it.effect("stops a nested background subagent's report on its own parent's session", () =>
1222 Effect.gen(function* () {
1223 const MIDDLE = "ses_middle0000000000000000000";
1224 const GRANDCHILD = "ses_grandchild00000000000000";
1225 const tool = (session: string, id: string) => ({
1226 sessionID: session,
1227 assistantMessageID: `msg_assistant_${id}`,
1228 id,
1229 });
1230 const report = {
1231 inboxID: "msg_nested_report",
1232 sessionID: MIDDLE,
1233 item: {
1234 type: "synthetic",
1235 payload: {
1236 text: `<subagent sessionID="${GRANDCHILD}" state="cancelled" description="Deep">\n</subagent>`,
1237 description: "Deep",
1238 metadata: {
1239 source: "subagent",
1240 childID: GRANDCHILD,
1241 agent: "General",
1242 state: "cancelled",
1243 },
1244 },
1245 delivery: "steer",
1246 },
1247 };
1248 const offered = yield* Deferred.make<void>();
1249 const { runtime, thread } = yield* resumed([
1250 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1251 promptAccepted,
1252 event("session.execution.started", { sessionID: SESSION }),
1253 // The thread's model starts a background subagent...
1254 event("session.tool.input.started", { ...tool(SESSION, "call-middle"), name: "subagent" }),
1255 event("session.tool.called", {
1256 ...tool(SESSION, "call-middle"),
1257 name: "subagent",
1258 input: { description: "Middle", prompt: "delegate", background: true },
1259 executed: false,
1260 }),
1261 event("session.created", { ...childCreated(MIDDLE), title: "Middle" }),
1262 event("session.tool.progress", {
1263 ...tool(SESSION, "call-middle"),
1264 metadata: { sessionID: MIDDLE, status: "running" },
1265 }),
1266 event("session.execution.started", { sessionID: MIDDLE }),
1267 // ...which starts one of its own in the background.
1268 event("session.tool.input.started", { ...tool(MIDDLE, "call-deep"), name: "subagent" }),
1269 event("session.tool.called", {
1270 ...tool(MIDDLE, "call-deep"),
1271 name: "subagent",
1272 input: { description: "Deep", prompt: "sleep", background: true },
1273 executed: false,
1274 }),
1275 event("session.created", {
1276 ...childCreated(GRANDCHILD),
1277 parentID: MIDDLE,
1278 title: "Deep",
1279 }),
1280 event("session.tool.progress", {
1281 ...tool(MIDDLE, "call-deep"),
1282 metadata: { sessionID: GRANDCHILD, status: "running" },
1283 }),
1284 event("session.text.ended", {
1285 sessionID: SESSION,
1286 assistantMessageID: "msg_assistant_root",
1287 ordinal: 0,
1288 text: "Launched.",
1289 }),
1290 // A Stop on the thread interrupts both background subagents.
1291 out("session.interrupt", { sessionID: MIDDLE }),
1292 reply("session.interrupt", { interrupted: true }),
1293 out("session.interrupt", { sessionID: GRANDCHILD }),
1294 reply("session.interrupt", { interrupted: true }),
1295 out("session.interrupt", { sessionID: SESSION }),
1296 reply("session.interrupt", { interrupted: true }),
1297 event("session.execution.interrupted", { sessionID: SESSION }),
1298 event("session.execution.interrupted", { sessionID: MIDDLE }),
1299 // OpenCode still reports the stopped grandchild to the middle session
1300 // and wakes it; that execution is stopped, not run as a subagent turn.
1301 event("session.inbox.enqueued", report),
1302 event("session.execution.started", { sessionID: MIDDLE }),
1303 out("session.interrupt", { sessionID: MIDDLE }),
1304 reply("session.interrupt", { interrupted: true }),
1305 event("session.execution.interrupted", { sessionID: MIDDLE }),
1306 // Later OpenCode runs the thread's own session by itself: a follow-up
1307 // T3 offers a turn for, which marks that everything above was handled.
1308 event("session.execution.started", { sessionID: SESSION }),
1309 ]).pipe(
1310 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
1311 offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
1312 take: Effect.never,
1313 }),
1314 );
1315 const launched = yield* Deferred.make<void>();
1316 const middleTurns: Array<string> = [];
1317 yield* runtime.events.pipe(
1318 Stream.tap((event) =>
1319 Effect.gen(function* () {
1320 if (
1321 event.type === "provider_turn.updated" &&
1322 event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${MIDDLE}:turn:`) === true
1323 ) {
1324 middleTurns.push(event.providerTurn.nativeTurnRef.nativeId);
1325 }
1326 if (event.type === "turn_item.updated" && event.turnItem.type === "assistant_message") {
1327 yield* Deferred.succeed(launched, undefined);
1328 }
1329 }),
1330 ),
1331 Stream.runDrain,
1332 Effect.forkScoped,
1333 );
1334 yield* runtime.startTurn(withLineage(thread));
1335 yield* Deferred.await(launched);
1336 yield* runtime.interruptTurn({
1337 providerThread: thread,
1338 providerTurnId: yield* providerTurnId,
1339 requestRuntimeRestart: true,
1340 });
1341 // The report wakes the middle session; the adapter stops that execution
1342 // (the replay fails on any other request) and opens no turn for it.
1343 yield* Deferred.await(offered);
1344 assert.deepEqual([...new Set(middleTurns)], [`${MIDDLE}:turn:1`]);
1345 }).pipe(Effect.scoped),
1346 );
1347
1348 /**
1349 * A foreground subagent starts one in the background and returns, so the
1350 * thread's turn ends while that one runs. Frames as 2.0.18 sent them in a
1351 * live run with `subagent_depth` 2, ids shortened.
1352 */
1353 const MIDDLE = "ses_middle0000000000000000000";
1354 const DEEP = "ses_grandchild00000000000000";
1355 const toolOf = (session: string, id: string) => ({
1356 sessionID: session,
1357 assistantMessageID: `msg_assistant_${id}`,
1358 id,
1359 });
1360 const deepReport = (state: "completed" | "cancelled") =>
1361 event("session.inbox.enqueued", {
1362 inboxID: "msg_deep_report",
1363 sessionID: MIDDLE,
1364 item: {
1365 type: "synthetic",
1366 payload: {
1367 text: `<subagent sessionID="${DEEP}" state="${state}" description="Deep">\nDEEP_OK\n</subagent>`,
1368 description: "Deep",
1369 metadata: { source: "subagent", childID: DEEP, agent: "General", state },
1370 },
1371 delivery: "steer",
1372 },
1373 });
1374 const nestedLaunch: ReadonlyArray<ProviderReplayEntry> = [
1375 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1376 promptAccepted,
1377 event("session.execution.started", { sessionID: SESSION }),
1378 event("session.tool.input.started", { ...toolOf(SESSION, "call-middle"), name: "subagent" }),
1379 event("session.tool.called", {
1380 ...toolOf(SESSION, "call-middle"),
1381 name: "subagent",
1382 input: { description: "Middle", prompt: "delegate" },
1383 executed: false,
1384 }),
1385 event("session.created", { ...childCreated(MIDDLE), title: "Middle" }),
1386 event("session.tool.progress", {
1387 ...toolOf(SESSION, "call-middle"),
1388 metadata: { sessionID: MIDDLE, status: "running" },
1389 }),
1390 event("session.execution.started", { sessionID: MIDDLE }),
1391 event("session.tool.input.started", { ...toolOf(MIDDLE, "call-deep"), name: "subagent" }),
1392 event("session.tool.called", {
1393 ...toolOf(MIDDLE, "call-deep"),
1394 name: "subagent",
1395 input: { description: "Deep", prompt: "sleep", background: true },
1396 executed: false,
1397 }),
1398 event("session.created", { ...childCreated(DEEP), parentID: MIDDLE, title: "Deep" }),
1399 event("session.tool.progress", {
1400 ...toolOf(MIDDLE, "call-deep"),
1401 metadata: { sessionID: DEEP, status: "running" },
1402 }),
1403 event("session.tool.success", {
1404 ...toolOf(MIDDLE, "call-deep"),
1405 content: [{ type: "text", text: "The subagent is working in the background." }],
1406 metadata: { sessionID: DEEP, status: "running", truncated: false },
1407 executed: false,
1408 }),
1409 event("session.execution.started", { sessionID: DEEP }),
1410 event("session.execution.succeeded", { sessionID: MIDDLE }),
1411 event("session.tool.success", {
1412 ...toolOf(SESSION, "call-middle"),
1413 content: [{ type: "text", text: `<subagent sessionID="${MIDDLE}" state="completed">` }],
1414 metadata: { sessionID: MIDDLE, status: "completed", truncated: false },
1415 executed: false,
1416 }),
1417 event("session.execution.succeeded", { sessionID: SESSION }),
1418 ];
1419 /** The runtime's events, with the background subagent's statuses and the middle session's turns. */
1420 const watchNested = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime) =>
1421 Effect.gen(function* () {
1422 const deep: Array<string> = [];
1423 const middleTurns = new Map<string, string>();
1424 const terminals: Array<string> = [];
1425 const changed = yield* Deferred.make<void>();
1426 let wake = changed;
1427 yield* runtime.events.pipe(
1428 Stream.tap((event) =>
1429 Effect.gen(function* () {
1430 if (event.type === "turn.terminal") terminals.push(event.status);
1431 if (event.type === "subagent.updated" && event.subagent.title === "Deep") {
1432 deep.push(event.subagent.status);
1433 }
1434 const native = event.type === "provider_turn.updated" ? event.providerTurn : undefined;
1435 const id = native?.nativeTurnRef?.nativeId;
1436 if (native !== undefined && id?.startsWith(`${MIDDLE}:turn:`) === true) {
1437 middleTurns.set(id, native.status);
1438 }
1439 yield* Deferred.succeed(wake, undefined);
1440 }),
1441 ),
1442 Stream.runDrain,
1443 Effect.forkScoped,
1444 );
1445 /** Waits until `check` holds for what the runtime emitted. */
1446 const until = (check: () => boolean): Effect.Effect<void> =>
1447 Effect.suspend(() => {
1448 if (check()) return Effect.void;
1449 return Effect.gen(function* () {
1450 wake = yield* Deferred.make<void>();
1451 if (check()) return;
1452 yield* Deferred.await(wake);
1453 yield* until(check);
1454 });
1455 });
1456 return { deep, middleTurns, terminals, until };
1457 });
1458
1459 it.effect("stops a foreground subagent's background subagent after the turn ended", () =>
1460 Effect.gen(function* () {
1461 const offered = yield* Deferred.make<void>();
1462 const { runtime, thread } = yield* resumed([
1463 ...nestedLaunch,
1464 // The Stop reaches the background subagent. OpenCode reports it to the
1465 // middle session and wakes that session, which is stopped as well.
1466 out("session.interrupt", { sessionID: DEEP }),
1467 reply("session.interrupt", { interrupted: true }),
1468 event("session.execution.interrupted", { sessionID: DEEP }),
1469 deepReport("cancelled"),
1470 event("session.execution.started", { sessionID: MIDDLE }),
1471 out("session.interrupt", { sessionID: MIDDLE }),
1472 reply("session.interrupt", { interrupted: true }),
1473 event("session.execution.interrupted", { sessionID: MIDDLE }),
1474 // Later OpenCode runs the thread's own session by itself: a follow-up
1475 // T3 offers a turn for, which marks that everything above was handled.
1476 event("session.execution.started", { sessionID: SESSION }),
1477 ]).pipe(
1478 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
1479 offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
1480 take: Effect.never,
1481 }),
1482 );
1483 const watch = yield* watchNested(runtime);
1484 yield* runtime.startTurn(withLineage(thread));
1485 yield* watch.until(() => watch.terminals.length > 0);
1486 assert.deepEqual(watch.terminals, ["completed"]);
1487 // The middle call ended; the subagent it started runs on, and keeps the thread busy.
1488 assert.equal(watch.deep.at(-1), "running");
1489 assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
1490 yield* runtime.interruptTurn({
1491 providerThread: thread,
1492 providerTurnId: yield* providerTurnId,
1493 requestRuntimeRestart: true,
1494 });
1495 yield* Deferred.await(offered);
1496 assert.equal(watch.deep.at(-1), "interrupted");
1497 // The replay fails on any request but the stops above, and no turn
1498 // opened for the middle session's stopped wake.
1499 assert.deepEqual([...watch.middleTurns.keys()], [`${MIDDLE}:turn:1`]);
1500 }).pipe(Effect.scoped),
1501 );
1502
1503 it.effect("keeps a reused subagent's background subagent tracked and stops it", () =>
1504 Effect.gen(function* () {
1505 const again = "call-middle-again";
1506 const { runtime, thread } = yield* resumed([
1507 ...nestedLaunch,
1508 // The next turn gives the running subagent the thread's rules, and its
1509 // model calls the middle subagent again, by its session.
1510 out("session.update", { sessionID: DEEP, permissions: "<any>" }),
1511 reply("session.update", null),
1512 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1513 promptAccepted,
1514 event("session.execution.started", { sessionID: SESSION }),
1515 event("session.tool.input.started", { ...toolOf(SESSION, again), name: "subagent" }),
1516 event("session.tool.called", {
1517 ...toolOf(SESSION, again),
1518 name: "subagent",
1519 input: { description: "Middle", prompt: "again", sessionID: MIDDLE },
1520 executed: false,
1521 }),
1522 event("session.tool.progress", {
1523 ...toolOf(SESSION, again),
1524 metadata: { sessionID: MIDDLE, status: "running" },
1525 }),
1526 out("session.update", { sessionID: MIDDLE, permissions: "<any>" }),
1527 reply("session.update", null),
1528 event("session.execution.started", { sessionID: MIDDLE }),
1529 event("session.execution.succeeded", { sessionID: MIDDLE }),
1530 event("session.tool.success", {
1531 ...toolOf(SESSION, again),
1532 content: [{ type: "text", text: `<subagent sessionID="${MIDDLE}" state="completed">` }],
1533 metadata: { sessionID: MIDDLE, status: "completed", truncated: false },
1534 executed: false,
1535 }),
1536 event("session.execution.succeeded", { sessionID: SESSION }),
1537 // A Stop still reaches the subagent the first call started.
1538 out("session.interrupt", { sessionID: DEEP }),
1539 reply("session.interrupt", { interrupted: true }),
1540 ]);
1541 const watch = yield* watchNested(runtime);
1542 yield* runtime.startTurn(withLineage(thread));
1543 yield* watch.until(() => watch.terminals.length === 1);
1544 yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread });
1545 yield* watch.until(() => watch.terminals.length === 2);
1546 assert.deepEqual(watch.terminals, ["completed", "completed"]);
1547 // The first call's background subagent still runs after the second call.
1548 assert.equal(watch.deep.at(-1), "running");
1549 assert.isTrue(yield* runtime.hasPendingBackgroundWork!);
1550 yield* runtime.interruptTurn({
1551 providerThread: thread,
1552 providerTurnId: yield* providerTurnId,
1553 requestRuntimeRestart: true,
1554 });
1555 // The replay fails on a Stop that does not interrupt it.
1556 yield* watch.until(() => watch.deep.at(-1) === "interrupted");
1557 }).pipe(Effect.scoped),
1558 );
1559
1560 it.effect("stops the turn a foreground subagent runs to answer its background subagent", () =>
1561 Effect.gen(function* () {
1562 const { runtime, thread } = yield* resumed([
1563 ...nestedLaunch,
1564 // The background subagent ends; OpenCode wakes the middle session to answer it.
1565 event("session.execution.succeeded", { sessionID: DEEP }),
1566 deepReport("completed"),
1567 event("session.execution.started", { sessionID: MIDDLE }),
1568 event("session.text.ended", {
1569 sessionID: MIDDLE,
1570 assistantMessageID: "msg_assistant_answer",
1571 ordinal: 0,
1572 text: "Noted.",
1573 }),
1574 out("session.interrupt", { sessionID: MIDDLE }),
1575 reply("session.interrupt", { interrupted: true }),
1576 event("session.execution.interrupted", { sessionID: MIDDLE }),
1577 ]);
1578 const watch = yield* watchNested(runtime);
1579 yield* runtime.startTurn(withLineage(thread));
1580 yield* watch.until(() => watch.middleTurns.get(`${MIDDLE}:turn:2`) === "running");
1581 assert.equal(watch.deep.at(-1), "completed");
1582 // Its answer still runs after the thread's turn ended.
1583 assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
1584 yield* runtime.interruptTurn({
1585 providerThread: thread,
1586 providerTurnId: yield* providerTurnId,
1587 requestRuntimeRestart: true,
1588 });
1589 yield* watch.until(() => watch.middleTurns.get(`${MIDDLE}:turn:2`) === "interrupted");
1590 assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
1591 }).pipe(Effect.scoped),
1592 );
1593
1594 it.effect("leaves nothing pending once a reconnect drops a subagent's queued report", () =>
1595 Effect.gen(function* () {
1596 const { runtime, thread } = yield* resumed([
1597 ...nestedLaunch,
1598 // The background subagent ends and its report is queued for the middle
1599 // session; the stream drops before OpenCode wakes that session.
1600 event("session.execution.succeeded", { sessionID: DEEP }),
1601 deepReport("completed"),
1602 { type: "runtime_exit", status: "success" } as const,
1603 out("event.subscribe"),
1604 out("session.active"),
1605 replyData("session.active", {}),
1606 ]);
1607 const watch = yield* watchNested(runtime);
1608 yield* runtime.startTurn(withLineage(thread));
1609 yield* watch.until(() => watch.deep.at(-1) === "completed");
1610 assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
1611 // The reconnect does not read back what the stream carried outside a
1612 // turn, so the report's follow-up is not waited on.
1613 yield* Effect.repeat(runtime.hasPendingBackgroundWorkForThread!(thread), {
1614 while: (pending) => pending,
1615 });
1616 assert.isFalse(yield* runtime.hasPendingBackgroundWork!);
1617 }).pipe(Effect.scoped),
1618 );
1619
1620 /** A prompt accepted, then a Stop the server never answers, advanced past its timeout. */
1621 const stopTimedOut: ReadonlyArray<ProviderReplayEntry> = [
1622 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1623 promptAccepted,
1624 out("session.interrupt", { sessionID: SESSION }),
1625 reply("session.interrupt", "<hang>"),
1626 ];
1627 const secondTurn = (thread: OrchestrationV2ProviderThread) => ({
1628 ...turnInput(thread),
1629 runId: RunId.make("run:opencode2-adapter:2"),
1630 runOrdinal: 2,
1631 providerTurnOrdinal: 2,
1632 attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"),
1633 });
1634 const stopFirstTurn = (
1635 runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime,
1636 thread: OrchestrationV2ProviderThread,
1637 ) =>
1638 Effect.gen(function* () {
1639 yield* runtime.startTurn(turnInput(thread));
1640 const interrupt = yield* runtime
1641 .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
1642 .pipe(Effect.forkScoped);
1643 yield* TestClock.adjust("11 seconds");
1644 yield* Fiber.join(interrupt);
1645 });
1646 const terminals = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, count: number) =>
1647 runtime.events.pipe(
1648 Stream.filter(
1649 (
1650 event,
1651 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
1652 event.type === "turn.terminal",
1653 ),
1654 Stream.take(count),
1655 Stream.runCollect,
1656 Effect.forkScoped,
1657 );
1658
1659 it.effect("never lets a timed-out Stop's late end finish the next turn", () =>
1660 Effect.gen(function* () {
1661 const { runtime, thread } = yield* resumed([
1662 ...stopTimedOut,
1663 // The server no longer runs the stopped execution, so the next turn goes ahead.
1664 out("session.active"),
1665 reply("session.active", { data: {} }),
1666 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1667 promptAccepted,
1668 // The stopped execution's end arrives late, then the new turn's own.
1669 event("session.execution.succeeded", { sessionID: SESSION }),
1670 event("session.execution.started", { sessionID: SESSION }),
1671 event("session.execution.failed", {
1672 sessionID: SESSION,
1673 error: { type: "provider", message: "second turn failed" },
1674 }),
1675 ]);
1676 const ended = yield* terminals(runtime, 2);
1677 yield* stopFirstTurn(runtime, thread);
1678 yield* runtime.startTurn(secondTurn(thread));
1679 const [first, second] = yield* Fiber.join(ended);
1680 assert.equal(first?.status, "interrupted");
1681 // Only the second turn's own end finishes it.
1682 assert.equal(second?.status, "failed");
1683 assert.equal(second?.failure?.message, "second turn failed");
1684 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
1685 );
1686
1687 it.effect(
1688 "starts the next turn once the server no longer runs a timed-out Stop's execution",
1689 () =>
1690 Effect.gen(function* () {
1691 const { runtime, thread } = yield* resumed([
1692 ...stopTimedOut,
1693 out("session.active"),
1694 reply("session.active", { data: {} }),
1695 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1696 promptAccepted,
1697 event("session.execution.started", { sessionID: SESSION }),
1698 event("session.execution.succeeded", { sessionID: SESSION }),
1699 ]);
1700 const ended = yield* terminals(runtime, 2);
1701 yield* stopFirstTurn(runtime, thread);
1702 yield* runtime.startTurn(secondTurn(thread));
1703 const [, second] = yield* Fiber.join(ended);
1704 assert.equal(second?.status, "completed");
1705 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
1706 );
1707
1708 it.effect("reads a timed-out Stop's next turn from an execution start it cannot decode", () =>
1709 Effect.gen(function* () {
1710 const { runtime, thread } = yield* resumed([
1711 ...stopTimedOut,
1712 out("session.active"),
1713 reply("session.active", { data: {} }),
1714 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1715 promptAccepted,
1716 // A newer server's start: the full schema rejects it, but it still opens the turn.
1717 {
1718 type: "emit_inbound",
1719 frame: {
1720 type: "sdk.event",
1721 event: {
1722 id: "evt_executionstartednewer",
1723 created: 1,
1724 type: "session.execution.started",
1725 data: { sessionID: SESSION },
1726 durable: "not-an-envelope",
1727 },
1728 },
1729 },
1730 event("session.text.ended", {
1731 sessionID: SESSION,
1732 assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z",
1733 ordinal: 0,
1734 text: "DONE",
1735 }),
1736 event("session.execution.succeeded", { sessionID: SESSION }),
1737 ]);
1738 const ended = yield* terminals(runtime, 2);
1739 yield* stopFirstTurn(runtime, thread);
1740 yield* runtime.startTurn(secondTurn(thread));
1741 const [, second] = yield* Fiber.join(ended);
1742 assert.equal(second?.status, "completed");
1743 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
1744 );
1745
1746 it.effect("checks the server before prompting again after a prompt request failed", () =>
1747 Effect.gen(function* () {
1748 const { runtime, thread } = yield* resumed([
1749 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1750 // The request failed, but the server may have taken the prompt.
1751 reply("session.prompt", {
1752 status: 502,
1753 body: { _tag: "UnknownError", message: "bad gateway" },
1754 }),
1755 out("session.active"),
1756 reply("session.active", { data: { [SESSION]: { type: "running" } } }),
1757 out("session.interrupt", { sessionID: SESSION }),
1758 reply("session.interrupt", { interrupted: true }),
1759 ]);
1760 const ended = yield* terminals(runtime, 2);
1761 yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore);
1762 yield* runtime.startTurn(secondTurn(thread));
1763 const [first, second] = yield* Fiber.join(ended);
1764 assert.equal(first?.status, "failed");
1765 assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING);
1766 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
1767 );
1768
1769 it.effect("still checks the server when a failed prompt's execution starts late", () =>
1770 Effect.gen(function* () {
1771 const offered = yield* Deferred.make<void>();
1772 const { runtime, thread } = yield* resumed([
1773 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1774 // The request failed, but the server took the prompt: its execution
1775 // starts only after the turn already failed.
1776 reply("session.prompt", {
1777 status: 502,
1778 body: { _tag: "UnknownError", message: "bad gateway" },
1779 }),
1780 event("session.execution.started", { sessionID: SESSION }),
1781 // That run is still going, so the next turn stops it and fails.
1782 out("session.active"),
1783 reply("session.active", { data: { [SESSION]: { type: "running" } } }),
1784 out("session.interrupt", { sessionID: SESSION }),
1785 reply("session.interrupt", { interrupted: true }),
1786 ]).pipe(
1787 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
1788 offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
1789 take: Effect.never,
1790 }),
1791 );
1792 const ended = yield* terminals(runtime, 2);
1793 yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore);
1794 // The late start has been read.
1795 yield* Deferred.await(offered);
1796 yield* runtime.startTurn(secondTurn(thread));
1797 const [first, second] = yield* Fiber.join(ended);
1798 assert.equal(first?.status, "failed");
1799 assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING);
1800 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
1801 );
1802
1803 it.effect("still checks the server when a failed prompt starts after a reconnect", () =>
1804 Effect.gen(function* () {
1805 const offered = yield* Deferred.make<void>();
1806 const gate = makeProviderReplayGate(["drop"]);
1807 const { runtime, thread } = yield* resumed(
1808 [
1809 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1810 reply("session.prompt", {
1811 status: 502,
1812 body: { _tag: "UnknownError", message: "bad gateway" },
1813 }),
1814 // The stream drops before the prompt's execution starts, and the
1815 // reconnect finds the session active: the prompt's run is still to come.
1816 event("session.usage.updated", { sessionID: SESSION }, "drop"),
1817 { type: "runtime_exit", status: "success" } as const,
1818 out("event.subscribe"),
1819 out("session.active"),
1820 replyData("session.active", { [SESSION]: { type: "running" } }),
1821 out("permission.list", { sessionID: SESSION }),
1822 replyData("permission.list", []),
1823 out("session.form.list", { sessionID: SESSION }),
1824 replyData("session.form.list", []),
1825 // Its own start: that run is still going, so the next turn stops it and fails.
1826 event("session.execution.started", { sessionID: SESSION }),
1827 out("session.active"),
1828 reply("session.active", { data: { [SESSION]: { type: "running" } } }),
1829 out("session.interrupt", { sessionID: SESSION }),
1830 reply("session.interrupt", { interrupted: true }),
1831 ],
1832 { replayGate: gate },
1833 ).pipe(
1834 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
1835 offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
1836 take: Effect.never,
1837 }),
1838 );
1839 const ended = yield* terminals(runtime, 2);
1840 yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore);
1841 gate.release("drop");
1842 // The late start has been read.
1843 yield* Deferred.await(offered);
1844 yield* runtime.startTurn(secondTurn(thread));
1845 const [first, second] = yield* Fiber.join(ended);
1846 assert.equal(first?.status, "failed");
1847 assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING);
1848 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
1849 );
1850
1851 it.effect("prompts again without a check after the server refused a prompt", () =>
1852 Effect.gen(function* () {
1853 const { runtime, thread } = yield* resumed([
1854 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1855 reply("session.prompt", {
1856 status: 400,
1857 body: { _tag: "InvalidRequestError", message: "bad prompt" },
1858 }),
1859 // A clear refusal: nothing runs, so the next turn prompts directly.
1860 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
1861 promptAccepted,
1862 event("session.execution.succeeded", { sessionID: SESSION }),
1863 ]);
1864 const ended = yield* terminals(runtime, 2);
1865 yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore);
1866 yield* runtime.startTurn(secondTurn(thread));
1867 const [first, second] = yield* Fiber.join(ended);
1868 assert.deepEqual([first?.status, second?.status], ["failed", "completed"]);
1869 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
1870 );
1871
1872 it.effect("stops a timed-out Stop's execution again and fails the turn while it runs", () =>
1873 Effect.gen(function* () {
1874 const { runtime, thread } = yield* resumed([
1875 ...stopTimedOut,
1876 out("session.active"),
1877 reply("session.active", { data: { [SESSION]: { type: "running" } } }),
1878 // Stopped again, and the turn fails without a prompt.
1879 out("session.interrupt", { sessionID: SESSION }),
1880 reply("session.interrupt", { interrupted: true }),
1881 ]);
1882 const ended = yield* terminals(runtime, 2);
1883 yield* stopFirstTurn(runtime, thread);
1884 yield* runtime.startTurn(secondTurn(thread));
1885 const [, second] = yield* Fiber.join(ended);
1886 assert.equal(second?.status, "failed");
1887 assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING);
1888 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
1889 );
1890
1891 it.effect("ends a background reply's continuation after a lost stream took a Stop's end", () =>
1892 Effect.gen(function* () {
1893 const offered =
1894 yield* Deferred.make<ProviderContinuationRequests.ProviderContinuationRequest>();
1895 const gate = makeProviderReplayGate(["drop", "reply"]);
1896 const { runtime, thread } = yield* resumed(
1897 [
1898 ...backgroundLaunch(CHILD),
1899 // Restarting the turn stops only the parent, and the server never answers.
1900 out("session.interrupt", { sessionID: SESSION }),
1901 reply("session.interrupt", "<hang>"),
1902 // The restarted turn finds the parent still stopping and fails.
1903 out("session.active"),
1904 replyData("session.active", { [SESSION]: { type: "running" } }),
1905 out("session.interrupt", { sessionID: SESSION }),
1906 reply("session.interrupt", { interrupted: true }),
1907 // The stream drops before the parent's end; the reconnect finds it idle.
1908 event("session.usage.updated", { sessionID: SESSION }, "drop"),
1909 { type: "runtime_exit", status: "success" } as const,
1910 out("event.subscribe"),
1911 out("session.active"),
1912 replyData("session.active", { [CHILD]: { type: "running" } }),
1913 out("permission.list", { sessionID: CHILD }),
1914 replyData("permission.list", []),
1915 out("session.form.list", { sessionID: CHILD }),
1916 replyData("session.form.list", []),
1917 // The subagent ends and OpenCode answers its report on its own.
1918 event("session.execution.succeeded", { sessionID: CHILD }),
1919 event("session.inbox.enqueued", {
1920 inboxID: "msg_report",
1921 sessionID: SESSION,
1922 item: {
1923 type: "synthetic",
1924 payload: {
1925 text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`,
1926 description: "Sleep",
1927 metadata: {
1928 source: "subagent",
1929 childID: CHILD,
1930 agent: "General",
1931 state: "completed",
1932 },
1933 },
1934 delivery: "steer",
1935 },
1936 }),
1937 event("session.execution.started", { sessionID: SESSION }),
1938 event(
1939 "session.text.ended",
1940 {
1941 sessionID: SESSION,
1942 assistantMessageID: "msg_followup",
1943 ordinal: 0,
1944 text: "CHILD_OK",
1945 },
1946 "reply",
1947 ),
1948 event("session.execution.succeeded", { sessionID: SESSION }),
1949 // The subagent's session runs again: its turn marks that the reply's end was handled.
1950 event("session.execution.started", { sessionID: CHILD }),
1951 ],
1952 { replayGate: gate },
1953 ).pipe(
1954 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
1955 offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid),
1956 take: Effect.never,
1957 }),
1958 );
1959 // The single reader of the runtime's events.
1960 const attached = yield* Deferred.make<void>();
1961 const collected = yield* runtime.events.pipe(
1962 Stream.tap((event) =>
1963 event.type === "subagent.updated" && event.subagent.childThreadId !== null
1964 ? Deferred.succeed(attached, undefined)
1965 : Effect.void,
1966 ),
1967 Stream.takeUntil(
1968 (event) =>
1969 event.type === "provider_turn.updated" &&
1970 event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${CHILD}:turn:`) === true,
1971 ),
1972 Stream.runCollect,
1973 Effect.forkScoped,
1974 );
1975 yield* runtime.startTurn(withLineage(thread));
1976 yield* Deferred.await(attached);
1977 const interrupt = yield* runtime
1978 .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
1979 .pipe(Effect.forkScoped);
1980 yield* TestClock.adjust("11 seconds");
1981 yield* Fiber.join(interrupt);
1982 yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread });
1983 gate.release("drop");
1984 yield* runtime.startTurn({
1985 ...continuationTurn(thread, yield* Deferred.await(offered)),
1986 runOrdinal: 3,
1987 providerTurnOrdinal: 3,
1988 });
1989 gate.release("reply");
1990 // The reply's own end finishes its continuation: the lost Stop's end does not take it.
1991 const events = [...(yield* Fiber.join(collected))];
1992 assert.deepEqual(
1993 events.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])),
1994 ["interrupted", "failed", "completed"],
1995 );
1996 // The continuation replayed the reply.
1997 assert.include(
1998 events.flatMap((event) =>
1999 event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
2000 ? [event.turnItem.text]
2001 : [],
2002 ),
2003 "CHILD_OK",
2004 );
2005 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
2006 );
2007
2008 it.effect("lets a prompt join a background reply after a lost stream took a Stop's end", () =>
2009 Effect.gen(function* () {
2010 const offered = yield* Deferred.make<void>();
2011 const gate = makeProviderReplayGate(["drop"]);
2012 const { runtime, thread } = yield* resumed(
2013 [
2014 ...backgroundLaunch(CHILD),
2015 // Restarting the turn stops only the parent, and the server never answers.
2016 out("session.interrupt", { sessionID: SESSION }),
2017 reply("session.interrupt", "<hang>"),
2018 // The restarted turn finds the parent still stopping and fails.
2019 out("session.active"),
2020 replyData("session.active", { [SESSION]: { type: "running" } }),
2021 out("session.interrupt", { sessionID: SESSION }),
2022 reply("session.interrupt", { interrupted: true }),
2023 // The stream drops before the parent's end; the reconnect finds it idle.
2024 event("session.usage.updated", { sessionID: SESSION }, "drop"),
2025 { type: "runtime_exit", status: "success" } as const,
2026 out("event.subscribe"),
2027 out("session.active"),
2028 replyData("session.active", { [CHILD]: { type: "running" } }),
2029 out("permission.list", { sessionID: CHILD }),
2030 replyData("permission.list", []),
2031 out("session.form.list", { sessionID: CHILD }),
2032 replyData("session.form.list", []),
2033 // The subagent ends and OpenCode answers its report on its own.
2034 event("session.execution.succeeded", { sessionID: CHILD }),
2035 event("session.inbox.enqueued", {
2036 inboxID: "msg_report",
2037 sessionID: SESSION,
2038 item: {
2039 type: "synthetic",
2040 payload: {
2041 text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`,
2042 description: "Sleep",
2043 metadata: {
2044 source: "subagent",
2045 childID: CHILD,
2046 agent: "General",
2047 state: "completed",
2048 },
2049 },
2050 delivery: "steer",
2051 },
2052 }),
2053 event("session.execution.started", { sessionID: SESSION }),
2054 // A prompt sent while that reply runs joins it, with no check first.
2055 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2056 promptAccepted,
2057 event("session.text.ended", {
2058 sessionID: SESSION,
2059 assistantMessageID: "msg_followup",
2060 ordinal: 0,
2061 text: "CHILD_OK",
2062 }),
2063 event("session.execution.succeeded", { sessionID: SESSION }),
2064 ],
2065 { replayGate: gate },
2066 ).pipe(
2067 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
2068 offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
2069 take: Effect.never,
2070 }),
2071 );
2072 // The single reader of the runtime's events.
2073 const attached = yield* Deferred.make<void>();
2074 const ended = yield* runtime.events.pipe(
2075 Stream.tap((event) =>
2076 event.type === "subagent.updated" && event.subagent.childThreadId !== null
2077 ? Deferred.succeed(attached, undefined)
2078 : Effect.void,
2079 ),
2080 Stream.filter(
2081 (
2082 event,
2083 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
2084 event.type === "turn.terminal",
2085 ),
2086 Stream.take(3),
2087 Stream.runCollect,
2088 Effect.forkScoped,
2089 );
2090 yield* runtime.startTurn(withLineage(thread));
2091 yield* Deferred.await(attached);
2092 const interrupt = yield* runtime
2093 .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
2094 .pipe(Effect.forkScoped);
2095 yield* TestClock.adjust("11 seconds");
2096 yield* Fiber.join(interrupt);
2097 yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread });
2098 gate.release("drop");
2099 // The reply has started.
2100 yield* Deferred.await(offered);
2101 yield* runtime.startTurn({
2102 ...withLineage(thread),
2103 runId: RunId.make("run:opencode2-adapter:3"),
2104 runOrdinal: 3,
2105 providerTurnOrdinal: 3,
2106 attemptId: RunAttemptId.make("attempt:opencode2-adapter:3"),
2107 });
2108 // The reply's start cleared the Stop's mark, so its end finishes the joined turn.
2109 assert.deepEqual(
2110 [...(yield* Fiber.join(ended))].map((terminal) => terminal.status),
2111 ["interrupted", "failed", "completed"],
2112 );
2113 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
2114 );
2115
2116 /**
2117 * A prompt fails without a clear rejection, OpenCode never took it, and a
2118 * background subagent's report then wakes the parent into a reply. With
2119 * `endedFirst` that reply ends before its continuation takes it.
2120 */
2121 const replyAfterUnclearFailure = (endedFirst: boolean) =>
2122 Effect.gen(function* () {
2123 const offered =
2124 yield* Deferred.make<ProviderContinuationRequests.ProviderContinuationRequest>();
2125 const gate = makeProviderReplayGate(endedFirst ? ["wake"] : ["wake", "reply"]);
2126 const { runtime, thread } = yield* resumed(
2127 [
2128 ...backgroundLaunch(CHILD),
2129 event("session.execution.succeeded", { sessionID: SESSION }),
2130 out("session.update", { sessionID: CHILD, permissions: t3Rules }),
2131 reply("session.update", null),
2132 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2133 reply("session.prompt", {
2134 status: 502,
2135 body: { _tag: "UnknownError", message: "bad gateway" },
2136 }),
2137 // The subagent ends and OpenCode answers its report on its own.
2138 event("session.execution.succeeded", { sessionID: CHILD }, "wake"),
2139 event("session.inbox.enqueued", {
2140 inboxID: "msg_report",
2141 sessionID: SESSION,
2142 item: {
2143 type: "synthetic",
2144 payload: {
2145 text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`,
2146 description: "Sleep",
2147 metadata: {
2148 source: "subagent",
2149 childID: CHILD,
2150 agent: "General",
2151 state: "completed",
2152 },
2153 },
2154 delivery: "steer",
2155 },
2156 }),
2157 event("session.execution.started", { sessionID: SESSION }),
2158 event(
2159 "session.text.ended",
2160 {
2161 sessionID: SESSION,
2162 assistantMessageID: "msg_followup",
2163 ordinal: 0,
2164 text: "CHILD_OK",
2165 },
2166 endedFirst ? undefined : "reply",
2167 ),
2168 event("session.execution.succeeded", { sessionID: SESSION }),
2169 // The subagent's session runs again: a turn of its own, after the reply's end.
2170 event("session.execution.started", { sessionID: CHILD }),
2171 ],
2172 { replayGate: gate },
2173 ).pipe(
2174 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
2175 offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid),
2176 take: Effect.never,
2177 }),
2178 );
2179 // The single reader of the runtime's events.
2180 const launched = yield* Deferred.make<void>();
2181 const childRan = yield* Deferred.make<void>();
2182 const ended: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
2183 const allEnded = yield* Deferred.make<void>();
2184 yield* runtime.events.pipe(
2185 Stream.tap((event) =>
2186 Effect.gen(function* () {
2187 if (
2188 event.type === "provider_turn.updated" &&
2189 event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${CHILD}:turn:`) === true
2190 ) {
2191 yield* Deferred.succeed(childRan, undefined);
2192 }
2193 if (event.type !== "turn_item.updated" && event.type !== "turn.terminal") return;
2194 ended.push(event);
2195 if (event.type === "turn.terminal") yield* Deferred.succeed(launched, undefined);
2196 if (ended.filter((entry) => entry.type === "turn.terminal").length === 3) {
2197 yield* Deferred.succeed(allEnded, undefined);
2198 }
2199 }),
2200 ),
2201 Stream.runDrain,
2202 Effect.forkScoped,
2203 );
2204 yield* runtime.startTurn(withLineage(thread));
2205 yield* Deferred.await(launched);
2206 yield* runtime
2207 .startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread })
2208 .pipe(Effect.ignore);
2209 gate.release("wake");
2210 // Everything up to the subagent's next run has been read: the reply ended.
2211 if (endedFirst) yield* Deferred.await(childRan);
2212 yield* runtime.startTurn({
2213 ...continuationTurn(thread, yield* Deferred.await(offered)),
2214 runOrdinal: 3,
2215 providerTurnOrdinal: 3,
2216 });
2217 if (!endedFirst) gate.release("reply");
2218 // The reply's own end finishes its continuation: the failed prompt's mark does not take it.
2219 yield* Deferred.await(allEnded);
2220 assert.deepEqual(
2221 ended.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])),
2222 ["completed", "failed", "completed"],
2223 );
2224 assert.include(
2225 ended.flatMap((event) =>
2226 event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
2227 ? [event.turnItem.text]
2228 : [],
2229 ),
2230 "CHILD_OK",
2231 );
2232 }).pipe(Effect.scoped, Effect.provide(TestClock.layer()));
2233
2234 it.effect("ends a background reply's continuation after a prompt failed unclearly", () =>
2235 replyAfterUnclearFailure(false),
2236 );
2237
2238 it.effect("ends a finished background reply's continuation after a prompt failed unclearly", () =>
2239 replyAfterUnclearFailure(true),
2240 );
2241
2242 it.effect("does not report a Stop the server says did nothing", () =>
2243 Effect.gen(function* () {
2244 const { runtime, thread } = yield* resumed([
2245 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2246 promptAccepted,
2247 out("session.interrupt", { sessionID: SESSION }),
2248 reply("session.interrupt", { interrupted: false }),
2249 ]);
2250 yield* runtime.startTurn(turnInput(thread));
2251 const failed = yield* runtime
2252 .interruptTurn({
2253 providerThread: thread,
2254 providerTurnId: yield* providerTurnId,
2255 })
2256 .pipe(Effect.flip);
2257 assert.equal(failed._tag, "ProviderAdapterProtocolError");
2258 }).pipe(Effect.scoped),
2259 );
2260
2261 it.effect("reports a turn as completed when its Stop request failed", () =>
2262 Effect.gen(function* () {
2263 const { runtime, thread } = yield* resumed([
2264 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2265 promptAccepted,
2266 out("session.interrupt", { sessionID: SESSION }),
2267 reply("session.interrupt", {
2268 status: 500,
2269 body: { _tag: "UnknownError", message: "interrupt failed" },
2270 }),
2271 event("session.execution.succeeded", { sessionID: SESSION }),
2272 ]);
2273 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2274 yield* runtime.startTurn(turnInput(thread));
2275 const failed = yield* runtime
2276 .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
2277 .pipe(Effect.flip);
2278 assert.equal(failed._tag, "ProviderAdapterInterruptError");
2279 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2280 }).pipe(Effect.scoped),
2281 );
2282
2283 it.effect("refuses to resume a thread without an OpenCode session as a protocol error", () =>
2284 Effect.gen(function* () {
2285 const runtime = yield* openCode2ReplayRuntime(opening);
2286 const failed = yield* runtime
2287 .resumeThread({
2288 providerThread: { ...providerThread(yield* DateTime.now), nativeThreadRef: null },
2289 })
2290 .pipe(Effect.flip);
2291 assert.equal(failed._tag, "ProviderAdapterProtocolError");
2292 }).pipe(Effect.scoped),
2293 );
2294
2295 it.effect("gives a resumed session T3's rules when it was made with others", () =>
2296 Effect.gen(function* () {
2297 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
2298 ...opening,
2299 out("session.get", { sessionID: SESSION }),
2300 // Made by an earlier build that denied subagents; resuming drops the deny.
2301 replyData(
2302 "session.get",
2303 sessionInfo({
2304 permissions: [
2305 { action: "*", resource: "*", effect: "allow" },
2306 { action: "subagent", resource: "*", effect: "deny" },
2307 ],
2308 }),
2309 ),
2310 ...noOpenRequests,
2311 out("session.update", { sessionID: SESSION, permissions: t3Rules }),
2312 reply("session.update", null),
2313 ]);
2314 yield* runtime.resumeThread({
2315 providerThread: providerThread(yield* DateTime.now),
2316 threadId,
2317 modelSelection: bigPickle,
2318 runtimePolicy: policy(),
2319 });
2320 }).pipe(Effect.scoped),
2321 );
2322
2323 it.effect("moves the session when the thread's worktree changed", () =>
2324 Effect.gen(function* () {
2325 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
2326 ...opening,
2327 ...directoryModels("/work/opencode2-feature"),
2328 out("session.get", { sessionID: SESSION }),
2329 replyData("session.get", sessionInfo()),
2330 ...noOpenRequests,
2331 out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }),
2332 reply("session.move", null),
2333 ]);
2334 yield* runtime.resumeThread({
2335 providerThread: providerThread(yield* DateTime.now),
2336 threadId,
2337 modelSelection: bigPickle,
2338 runtimePolicy: { ...policy(), cwd: "/work/opencode2-feature" },
2339 });
2340 }).pipe(Effect.scoped),
2341 );
2342
2343 it.effect(
2344 "moves the session when a thread it resumes through ensureThread changed worktree",
2345 () =>
2346 Effect.gen(function* () {
2347 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
2348 ...opening,
2349 ...directoryModels("/work/opencode2-feature"),
2350 out("session.get", { sessionID: SESSION }),
2351 replyData("session.get", sessionInfo()),
2352 ...noOpenRequests,
2353 out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }),
2354 reply("session.move", null),
2355 ]);
2356 yield* runtime.ensureThread({
2357 threadId,
2358 modelSelection: bigPickle,
2359 runtimePolicy: { ...policy(), cwd: "/work/opencode2-feature" },
2360 existingProviderThread: providerThread(yield* DateTime.now),
2361 });
2362 }).pipe(Effect.scoped),
2363 );
2364
2365 it.effect("breaks the thread and forgets it when the session was deleted outside T3", () =>
2366 Effect.gen(function* () {
2367 const { runtime, thread } = yield* resumed([
2368 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2369 reply("session.prompt", {
2370 status: 404,
2371 body: {
2372 _tag: "SessionNotFoundError",
2373 sessionID: SESSION,
2374 message: `Session not found: ${SESSION}`,
2375 },
2376 }),
2377 ]);
2378 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2379 yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore);
2380 const ended = yield* Fiber.join(terminal);
2381 assert.equal(ended?.status, "failed");
2382 assert.equal(ended?.threadDisposition, "broken");
2383 // The next turn must resume (and fail into a handoff), not reuse the dead session.
2384 const again = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.flip);
2385 assert.equal(again._tag, "ProviderAdapterProtocolError");
2386 assert.include(again.message, "not registered");
2387 }).pipe(Effect.scoped),
2388 );
2389
2390 it.effect("refuses a model slug that is not provider/model before creating a session", () =>
2391 Effect.gen(function* () {
2392 // Nothing but the session's opening is expected: no create, no prompt.
2393 const runtime = yield* openCode2ReplayRuntime([...opening]);
2394 const created = yield* runtime
2395 .ensureThread({
2396 threadId,
2397 modelSelection: { instanceId, model: "big-pickle" },
2398 runtimePolicy: policy(),
2399 })
2400 .pipe(Effect.flip);
2401 assert.equal(created._tag, "ProviderAdapterProtocolError");
2402 assert.include(created.message, "OpenCode model 'big-pickle' must use provider/model format");
2403 }).pipe(Effect.scoped),
2404 );
2405
2406 it.effect("refuses a turn whose model slug is not provider/model before prompting", () =>
2407 Effect.gen(function* () {
2408 const { runtime, thread } = yield* resumed([]);
2409 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2410 yield* runtime.startTurn(turnInput(thread, { instanceId, model: "big-pickle" }));
2411 const refused = yield* Fiber.join(terminal);
2412 assert.equal(refused?.failure?.class, "validation_error");
2413 assert.include(refused?.failure?.message, "must use provider/model format");
2414 }).pipe(Effect.scoped),
2415 );
2416
2417 it.effect("stops the requests a session still waits on when a restarted T3 loads it", () =>
2418 Effect.gen(function* () {
2419 // T3 restarted while the server kept waiting on an ask T3 no longer shows.
2420 const runtime = yield* openCode2ReplayRuntimeWithInstructions(
2421 [
2422 ...opening,
2423 out("session.get", { sessionID: SESSION }),
2424 replyData("session.get", sessionInfo()),
2425 out("permission.list", { sessionID: SESSION }),
2426 replyData("permission.list", [shellAsk.data]),
2427 out("session.form.list", { sessionID: SESSION }),
2428 replyData("session.form.list", [colorForm]),
2429 out("permission.reply", {
2430 sessionID: SESSION,
2431 requestID: shellAsk.data.id,
2432 decision: "reject",
2433 }),
2434 reply("permission.reply", null),
2435 out("session.form.cancel", { sessionID: SESSION, formID: colorForm.id }),
2436 reply("session.form.cancel", null),
2437 // The next turn checks that the stopped run is gone. Its end arrives
2438 // late and is its own; the turn ends on its own execution's end.
2439 out("session.active"),
2440 reply("session.active", { data: {} }),
2441 event("session.execution.interrupted", { sessionID: SESSION, reason: "user" }),
2442 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2443 promptAccepted,
2444 event("session.execution.started", { sessionID: SESSION }),
2445 event("session.execution.succeeded", { sessionID: SESSION }),
2446 ],
2447 { external: true },
2448 );
2449 const thread = yield* runtime.resumeThread({
2450 providerThread: providerThread(yield* DateTime.now),
2451 threadId,
2452 modelSelection: bigPickle,
2453 runtimePolicy: policy(),
2454 });
2455 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2456 yield* runtime.startTurn(turnInput(thread));
2457 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2458 }).pipe(Effect.scoped),
2459 );
2460
2461 it.effect("ends the turn when an answer to its request cannot be delivered", () =>
2462 Effect.gen(function* () {
2463 const failedReply = reply("permission.reply", {
2464 status: 500,
2465 body: { _tag: "UnknownError", message: "reply failed" },
2466 });
2467 const replyOut = out("permission.reply", {
2468 sessionID: SESSION,
2469 requestID: shellAsk.data.id,
2470 decision: "once",
2471 });
2472 const { runtime, thread } = yield* resumed(
2473 [
2474 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2475 promptAccepted,
2476 shellAskEvent,
2477 // One try and one retry, then the turn ends and the session is stopped.
2478 replyOut,
2479 failedReply,
2480 replyOut,
2481 failedReply,
2482 out("session.interrupt", { sessionID: SESSION }),
2483 reply("session.interrupt", { interrupted: true }),
2484 ],
2485 { supervised: true },
2486 );
2487 const requested = yield* requestOf(runtime).pipe(Effect.forkScoped);
2488 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2489 yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required"));
2490 const request = yield* Fiber.join(requested);
2491 yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" });
2492 const ended = yield* Fiber.join(terminal);
2493 assert.equal(ended?.status, "failed");
2494 assert.equal(
2495 ended?.status === "failed" ? ended.failure.message : undefined,
2496 "OpenCode is waiting on a request T3 Code couldn't answer.",
2497 );
2498 }).pipe(Effect.scoped),
2499 );
2500
2501 it.effect("counts an answer to a request OpenCode already dropped as delivered", () =>
2502 Effect.gen(function* () {
2503 const { runtime, thread } = yield* resumed(
2504 [
2505 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2506 promptAccepted,
2507 shellAskEvent,
2508 // The execution ended first, so OpenCode no longer knows the request.
2509 out("permission.reply", {
2510 sessionID: SESSION,
2511 requestID: shellAsk.data.id,
2512 decision: "once",
2513 }),
2514 reply("permission.reply", {
2515 status: 404,
2516 body: {
2517 _tag: "PermissionNotFoundError",
2518 requestID: shellAsk.data.id,
2519 message: "Permission request not found",
2520 },
2521 }),
2522 event("session.execution.succeeded", { sessionID: SESSION }),
2523 ],
2524 { supervised: true },
2525 );
2526 const requested = yield* requestOf(runtime).pipe(Effect.forkScoped);
2527 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2528 yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required"));
2529 const request = yield* Fiber.join(requested);
2530 yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" });
2531 // Not "waiting on a request T3 Code couldn't answer": nothing waits on it.
2532 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2533 }).pipe(Effect.scoped),
2534 );
2535
2536 it.effect("declines a form T3 cannot show with the reason, instead of leaving it open", () =>
2537 Effect.gen(function* () {
2538 const { runtime, thread } = yield* resumed([
2539 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2540 promptAccepted,
2541 event("form.created", {
2542 form: {
2543 id: "frm_0eb79ab35001fkvFECSh3wYNVD",
2544 sessionID: SESSION,
2545 title: "MCP authorization",
2546 metadata: { kind: "mcp" },
2547 fields: [
2548 {
2549 key: "authorization",
2550 type: "external",
2551 url: "https://example.com/authorize",
2552 title: "Authorize",
2553 },
2554 ],
2555 },
2556 }),
2557 out("session.form.cancel", {
2558 sessionID: SESSION,
2559 formID: "frm_0eb79ab35001fkvFECSh3wYNVD",
2560 }),
2561 reply("session.form.cancel", null),
2562 ]);
2563 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2564 yield* runtime.startTurn(turnInput(thread));
2565 const ended = yield* Fiber.join(terminal);
2566 assert.equal(ended?.status, "failed");
2567 assert.include(
2568 ended?.status === "failed" ? ended.failure.message : "",
2569 "asked for a link to open",
2570 );
2571 }).pipe(Effect.scoped),
2572 );
2573
2574 it.effect("asks a subagent's permission request on the parent thread's turn", () =>
2575 Effect.gen(function* () {
2576 const child = "ses_f0e5aa64cffelFnoQRL0DAA9BH";
2577 const { runtime, thread } = yield* resumed(
2578 [
2579 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2580 promptAccepted,
2581 // As 2.0.18 announced the child the live probe's parent started.
2582 event("session.created", {
2583 sessionID: child,
2584 slug: "stellar-garden",
2585 version: "2.0.18",
2586 projectID: "global",
2587 parentID: SESSION,
2588 location: { directory: WORK },
2589 subpath: "",
2590 title: "Echo test command",
2591 agent: "general",
2592 permissions: supervisedRules,
2593 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
2594 }),
2595 event("permission.asked", { ...shellAsk.data, sessionID: child }),
2596 out("permission.reply", {
2597 sessionID: child,
2598 requestID: shellAsk.data.id,
2599 decision: "once",
2600 }),
2601 reply("permission.reply", null),
2602 event("permission.replied", {
2603 sessionID: child,
2604 requestID: shellAsk.data.id,
2605 reply: "once",
2606 }),
2607 event("session.execution.succeeded", { sessionID: SESSION }),
2608 ],
2609 { supervised: true },
2610 );
2611 const requested = yield* requestOf(runtime).pipe(Effect.forkScoped);
2612 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2613 yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required"));
2614 const request = yield* Fiber.join(requested);
2615 assert.equal(request?.nativeRequestRef?.nativeId, shellAsk.data.id);
2616 yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" });
2617 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2618 }).pipe(Effect.scoped),
2619 );
2620
2621 /**
2622 * A supervised foreground subagent that asks to run `echo FIRST`: the
2623 * child is announced and named by the parent's call, then asks.
2624 */
2625 const subagentAsks = (child: string): ReadonlyArray<ProviderReplayEntry> => {
2626 const call = "call-subagent";
2627 const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call };
2628 return [
2629 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2630 promptAccepted,
2631 event("session.execution.started", { sessionID: SESSION }),
2632 event("session.tool.input.started", { ...tool, name: "subagent" }),
2633 event("session.tool.called", {
2634 ...tool,
2635 name: "subagent",
2636 input: { description: "Echo", prompt: "echo" },
2637 executed: false,
2638 }),
2639 event("session.created", { ...childCreated(child), title: "Echo" }),
2640 event("session.tool.progress", {
2641 ...tool,
2642 metadata: { sessionID: child, status: "running" },
2643 }),
2644 event("session.execution.started", { sessionID: child }),
2645 event("permission.asked", { ...shellAsk.data, sessionID: child }),
2646 ];
2647 };
2648
2649 it.effect("keeps a subagent's 'allow this session' in the subagent's own rules", () =>
2650 Effect.gen(function* () {
2651 const { runtime, thread } = yield* resumed(
2652 [
2653 ...subagentAsks(CHILD),
2654 // The subagent's session asked, so the grant is its rule, not the parent's.
2655 // Supervised rules for the subagent's own agent (`general`, which lists
2656 // no path rules here) with the grant.
2657 out("session.update", {
2658 sessionID: CHILD,
2659 permissions: [
2660 ...supervisedRules.slice(0, 3),
2661 { action: "shell", resource: "echo *", effect: "allow" },
2662 // A subagent's session may use its thread's T3 MCP server.
2663 ...mcpRules,
2664 ],
2665 }),
2666 reply("session.update", null),
2667 out("permission.reply", {
2668 sessionID: CHILD,
2669 requestID: shellAsk.data.id,
2670 decision: "once",
2671 }),
2672 reply("permission.reply", null),
2673 event("session.execution.succeeded", { sessionID: CHILD }),
2674 event("session.execution.succeeded", { sessionID: SESSION }),
2675 ],
2676 { supervised: true },
2677 );
2678 const requested = yield* requestOf(runtime).pipe(Effect.forkScoped);
2679 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2680 yield* runtime.startTurn({
2681 ...withLineage(thread),
2682 runtimePolicy: policy("approval-required"),
2683 });
2684 const request = yield* Fiber.join(requested);
2685 yield* runtime.respondToRuntimeRequest({
2686 requestId: request!.id,
2687 decision: "acceptForSession",
2688 });
2689 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2690 }).pipe(Effect.scoped),
2691 );
2692
2693 it.effect("stops the subagent whose request's answer cannot be delivered", () =>
2694 Effect.gen(function* () {
2695 const replyOut = out("permission.reply", {
2696 sessionID: CHILD,
2697 requestID: shellAsk.data.id,
2698 decision: "once",
2699 });
2700 const failedReply = reply("permission.reply", {
2701 status: 500,
2702 body: { _tag: "UnknownError", message: "reply failed" },
2703 });
2704 const { runtime, thread } = yield* resumed(
2705 [
2706 ...subagentAsks(CHILD),
2707 replyOut,
2708 failedReply,
2709 replyOut,
2710 failedReply,
2711 // The subagent's session is the one waiting on the answer.
2712 out("session.interrupt", { sessionID: CHILD }),
2713 reply("session.interrupt", { interrupted: true }),
2714 ],
2715 { supervised: true },
2716 );
2717 const requested = yield* requestOf(runtime).pipe(Effect.forkScoped);
2718 yield* runtime.startTurn({
2719 ...withLineage(thread),
2720 runtimePolicy: policy("approval-required"),
2721 });
2722 const request = yield* Fiber.join(requested);
2723 yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" });
2724 }).pipe(Effect.scoped),
2725 );
2726
2727 it.effect("stops the subagent whose form T3 cannot show or decline", () =>
2728 Effect.gen(function* () {
2729 const linkForm = {
2730 id: "frm_0eb79ab35001fkvFECSh3wYNVD",
2731 sessionID: CHILD,
2732 title: "MCP authorization",
2733 metadata: { kind: "mcp" },
2734 fields: [{ key: "authorization", type: "external", url: "https://example.com/authorize" }],
2735 };
2736 const cancelOut = out("session.form.cancel", { sessionID: CHILD, formID: linkForm.id });
2737 const failedCancel = reply("session.form.cancel", {
2738 status: 500,
2739 body: { _tag: "UnknownError", message: "cancel failed" },
2740 });
2741 const { runtime, thread } = yield* resumed([
2742 ...subagentAsks(CHILD).slice(0, -1),
2743 event("form.created", { form: linkForm }),
2744 cancelOut,
2745 failedCancel,
2746 cancelOut,
2747 failedCancel,
2748 // The subagent's session is the one blocked on the form; the parent
2749 // goes on once its subagent is stopped.
2750 out("session.interrupt", { sessionID: CHILD }),
2751 reply("session.interrupt", { interrupted: true }),
2752 event("session.execution.interrupted", { sessionID: CHILD }),
2753 event("session.execution.succeeded", { sessionID: SESSION }),
2754 ]);
2755 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2756 yield* runtime.startTurn(withLineage(thread));
2757 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2758 }).pipe(Effect.scoped),
2759 );
2760
2761 it.effect("ends only the subagent's turn when it declines the subagent's form", () =>
2762 Effect.gen(function* () {
2763 const linkForm = {
2764 id: "frm_0eb79ab35001fkvFECSh3wYNVD",
2765 sessionID: CHILD,
2766 title: "MCP authorization",
2767 metadata: { kind: "mcp" },
2768 fields: [{ key: "authorization", type: "external", url: "https://example.com/authorize" }],
2769 };
2770 const { runtime, thread } = yield* resumed([
2771 ...subagentAsks(CHILD).slice(0, -1),
2772 event("form.created", { form: linkForm }),
2773 out("session.form.cancel", { sessionID: CHILD, formID: linkForm.id }),
2774 reply("session.form.cancel", null),
2775 // The cancel stops the subagent; its parent reads the failed call and goes on.
2776 event("session.execution.interrupted", { sessionID: CHILD }),
2777 event("session.tool.failed", {
2778 sessionID: SESSION,
2779 assistantMessageID: "msg_assistant",
2780 id: "call-subagent",
2781 error: { type: "unknown", message: `Subagent cancelled (sessionID: ${CHILD})` },
2782 executed: true,
2783 }),
2784 event("session.text.ended", {
2785 sessionID: SESSION,
2786 assistantMessageID: "msg_assistant_after",
2787 ordinal: 0,
2788 text: "The subagent could not finish.",
2789 }),
2790 event("session.execution.succeeded", { sessionID: SESSION }),
2791 ]);
2792 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2793 yield* runtime.startTurn(withLineage(thread));
2794 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2795 }).pipe(Effect.scoped),
2796 );
2797
2798 it.effect("gives a resumed subagent the rules of the thread's current mode", () =>
2799 Effect.gen(function* () {
2800 const call = "call-resume";
2801 const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant_2", id: call };
2802 const { runtime, thread } = yield* resumed([
2803 // A first turn on Full access runs the subagent once.
2804 ...subagentAsks(CHILD).slice(0, -1),
2805 event("session.execution.succeeded", { sessionID: CHILD }),
2806 event("session.tool.success", {
2807 sessionID: SESSION,
2808 assistantMessageID: "msg_assistant",
2809 id: "call-subagent",
2810 content: [{ type: "text", text: "done" }],
2811 metadata: { sessionID: CHILD, status: "completed" },
2812 }),
2813 event("session.execution.succeeded", { sessionID: SESSION }),
2814 // The thread is now Supervised: the parent gets the narrower rules...
2815 out("agent.list", "<any>"),
2816 reply("agent.list", agentList),
2817 out("session.update", { sessionID: SESSION, permissions: supervisedRules }),
2818 reply("session.update", null),
2819 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2820 promptAccepted,
2821 event("session.execution.started", { sessionID: SESSION }),
2822 // ...and its model resumes the same subagent, whose session OpenCode
2823 // made with the Full access rules it inherited back then.
2824 event("session.tool.input.started", { ...tool, name: "subagent" }),
2825 event("session.tool.called", {
2826 ...tool,
2827 name: "subagent",
2828 input: { description: "Echo", prompt: "again", sessionID: CHILD },
2829 executed: false,
2830 }),
2831 event("session.tool.progress", {
2832 ...tool,
2833 metadata: { sessionID: CHILD, status: "running" },
2834 }),
2835 // The subagent gets them too before its execution runs anything.
2836 out("session.update", {
2837 sessionID: CHILD,
2838 permissions: [...supervisedRules.slice(0, 3), ...mcpRules],
2839 }),
2840 reply("session.update", null),
2841 event("session.execution.started", { sessionID: CHILD }),
2842 event("session.execution.succeeded", { sessionID: CHILD }),
2843 event("session.execution.succeeded", { sessionID: SESSION }),
2844 ]);
2845 const firstEnded = yield* Deferred.make<void>();
2846 const childTurns = new Set<string>();
2847 const ended = yield* runtime.events.pipe(
2848 Stream.tap((event) =>
2849 Effect.sync(() => {
2850 const nativeId =
2851 event.type === "provider_turn.updated"
2852 ? event.providerTurn.nativeTurnRef?.nativeId
2853 : undefined;
2854 if (nativeId?.startsWith(`${CHILD}:turn:`) === true) childTurns.add(nativeId);
2855 }),
2856 ),
2857 Stream.filter(
2858 (
2859 event,
2860 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
2861 event.type === "turn.terminal",
2862 ),
2863 Stream.tap(() => Deferred.succeed(firstEnded, undefined)),
2864 Stream.take(2),
2865 Stream.runCollect,
2866 Effect.forkScoped,
2867 );
2868 yield* runtime.startTurn(withLineage(thread));
2869 yield* Deferred.await(firstEnded);
2870 yield* runtime.startTurn({
2871 ...withLineage(thread),
2872 runId: RunId.make("run:opencode2-adapter:2"),
2873 runOrdinal: 2,
2874 providerTurnOrdinal: 2,
2875 attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"),
2876 runtimePolicy: policy("approval-required"),
2877 });
2878 assert.deepEqual(
2879 [...(yield* Fiber.join(ended))].map((terminal) => terminal.status),
2880 ["completed", "completed"],
2881 );
2882 // The resumed subagent's execution is its next turn, not its first again.
2883 assert.deepEqual([...childTurns], [`${CHILD}:turn:1`, `${CHILD}:turn:2`]);
2884 }).pipe(Effect.scoped),
2885 );
2886
2887 it.effect("keeps 'allow this session' in the session's rules, not OpenCode's saved grants", () =>
2888 Effect.gen(function* () {
2889 const { runtime, thread } = yield* resumed(
2890 [
2891 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2892 promptAccepted,
2893 shellAskEvent,
2894 // The grant goes into this session's rules; the reply is only `once`.
2895 out("session.update", {
2896 sessionID: SESSION,
2897 permissions: [
2898 ...supervisedRules.slice(0, 3),
2899 { action: "shell", resource: "echo *", effect: "allow" },
2900 ...supervisedRules.slice(3),
2901 ],
2902 }),
2903 reply("session.update", null),
2904 out("permission.reply", {
2905 sessionID: SESSION,
2906 requestID: shellAsk.data.id,
2907 decision: "once",
2908 }),
2909 reply("permission.reply", null),
2910 event("session.execution.succeeded", { sessionID: SESSION }),
2911 ],
2912 { supervised: true },
2913 );
2914 const requested = yield* requestOf(runtime).pipe(Effect.forkScoped);
2915 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2916 yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required"));
2917 const request = yield* Fiber.join(requested);
2918 yield* runtime.respondToRuntimeRequest({
2919 requestId: request!.id,
2920 decision: "acceptForSession",
2921 });
2922 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2923 }).pipe(Effect.scoped),
2924 );
2925
2926 it.effect("reads the agents again while a fresh server still lists none", () =>
2927 Effect.gen(function* () {
2928 // 2.0.18 answers `/api/agent` with `[]` for a moment after it starts;
2929 // rules written from that would drop the agents' own paths.
2930 const runtime = yield* openCode2ReplayRuntime([
2931 ...opening,
2932 out("agent.list", "<any>"),
2933 reply("agent.list", { location: { directory: WORK }, data: [] }),
2934 out("agent.list", "<any>"),
2935 reply("agent.list", agentList),
2936 out("session.create", {
2937 location: { directory: WORK },
2938 model: { providerID: "opencode", id: "big-pickle" },
2939 permissions: supervisedRules,
2940 }),
2941 replyData("session.create", sessionInfo({ permissions: supervisedRules })),
2942 ]);
2943 const created = yield* runtime
2944 .ensureThread({
2945 threadId,
2946 modelSelection: bigPickle,
2947 runtimePolicy: policy("approval-required"),
2948 })
2949 .pipe(Effect.forkScoped);
2950 yield* TestClock.adjust("250 millis");
2951 assert.equal((yield* Fiber.join(created)).nativeThreadRef?.nativeId, SESSION);
2952 }).pipe(Effect.scoped),
2953 );
2954
2955 it.effect("refuses a turn once reconnecting to a lost event stream has given up", () =>
2956 Effect.gen(function* () {
2957 // The server goes away with no turn running, and never comes back.
2958 const { runtime, thread } = yield* resumed([{ type: "runtime_exit", status: "success" }]);
2959 // Reconnecting retries a few times, seconds apart, then ends the runtime's events.
2960 const drained = yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped);
2961 yield* TestClock.adjust("1 minute");
2962 yield* Fiber.join(drained);
2963 const refused = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.flip);
2964 assert.equal(refused._tag, "ProviderAdapterEventStreamError");
2965 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
2966 );
2967
2968 it.effect("logs why reconnecting to a lost event stream gave up", () => {
2969 const logs: Array<{
2970 readonly message: unknown;
2971 readonly cause: Cause.Cause<unknown>;
2972 readonly annotations: Readonly<Record<string, unknown>>;
2973 }> = [];
2974 const logger = Logger.make(({ fiber, message, cause }) => {
2975 logs.push({ message, cause, annotations: fiber.getRef(References.CurrentLogAnnotations) });
2976 });
2977 return Effect.gen(function* () {
2978 const { runtime } = yield* resumed([{ type: "runtime_exit", status: "success" }]);
2979 const drained = yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped);
2980 yield* TestClock.adjust("1 minute");
2981 yield* Fiber.join(drained);
2982 const gaveUp = logs.find((entry) =>
2983 JSON.stringify(entry.message).includes("Could not reconnect to the OpenCode event stream"),
2984 );
2985 assert.isDefined(gaveUp);
2986 // The failure travels with the log entry as its cause, annotated by its tag only.
2987 assert.isTrue(gaveUp !== undefined && gaveUp.cause.reasons.length > 0);
2988 assert.isString(gaveUp?.annotations.errorTag);
2989 }).pipe(
2990 Effect.scoped,
2991 Effect.provide(
2992 Layer.merge(TestClock.layer(), Logger.layer([logger], { mergeWithExisting: false })),
2993 ),
2994 );
2995 });
2996
2997 it.effect("fails the session once reconnecting to a lost event stream has given up", () =>
2998 Effect.gen(function* () {
2999 const { runtime, thread } = yield* resumed([
3000 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
3001 promptAccepted,
3002 { type: "runtime_exit", status: "success" },
3003 ]);
3004 // The runtime's event stream has one consumer.
3005 const events = yield* runtime.events.pipe(Stream.runCollect, Effect.forkScoped);
3006 yield* runtime.startTurn(turnInput(thread));
3007 yield* TestClock.adjust("1 minute");
3008 const collected = yield* Fiber.join(events);
3009 const terminal = collected.find((event) => event.type === "turn.terminal");
3010 assert.deepInclude(terminal, { status: "failed", threadDisposition: "broken" });
3011 const last = collected.findLast((event) => event.type === "provider_session.updated");
3012 assert.equal(
3013 last?.type === "provider_session.updated" ? last.providerSession.status : undefined,
3014 "error",
3015 );
3016 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
3017 );
3018 // A stream that drops and comes back: what the gap lost is read from the
3019 // session's history, and the turn ends from the server's own state. Real
3020 // servers only do this on a network blip, so these are driven here.
3021 const reconnected = (active: Record<string, unknown>, ended?: "succeeded") => [
3022 { type: "runtime_exit", status: "success" } as const,
3023 out("event.subscribe"),
3024 out("session.active"),
3025 replyData("session.active", active),
3026 out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }),
3027 reply("message.list", {
3028 data: [
3029 // OpenCode appends an `idle` item after each execution it ends.
3030 ...(ended === undefined
3031 ? []
3032 : [{ id: "msg_idle_gap", time: { created: 3 }, type: "idle", outcome: ended }]),
3033 {
3034 id: "msg_assistant_gap",
3035 time: { created: 2 },
3036 type: "assistant",
3037 agent: "build",
3038 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
3039 content: [
3040 { type: "text", text: "Sent while the stream was down." },
3041 {
3042 type: "tool",
3043 id: "call_gap",
3044 name: "shell",
3045 executed: true,
3046 state: {
3047 status: "completed",
3048 input: { command: "echo GAP" },
3049 content: [{ type: "text", text: "GAP" }],
3050 metadata: {},
3051 },
3052 time: { created: 2, ran: 2, completed: 2 },
3053 },
3054 ],
3055 finish: "stop",
3056 },
3057 {
3058 id: "msg_0eb735d41001NJee1EvVePJAK5",
3059 time: { created: 1 },
3060 text: "hi",
3061 type: "user",
3062 },
3063 ],
3064 cursor: {},
3065 }),
3066 ];
3067 /** What a reconnect reads back of the requests a still-running session waits on. */
3068 const openRequests = (
3069 permissions: ReadonlyArray<unknown>,
3070 forms: ReadonlyArray<unknown> = [],
3071 ): ReadonlyArray<ProviderReplayEntry> => [
3072 out("permission.list", { sessionID: SESSION }),
3073 replyData("permission.list", permissions),
3074 out("session.form.list", { sessionID: SESSION }),
3075 replyData("session.form.list", forms),
3076 ];
3077
3078 const turnItems = (collected: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>) =>
3079 collected.flatMap((event) =>
3080 event.type === "turn_item.updated" ? [`${event.turnItem.type}:${event.turnItem.status}`] : [],
3081 );
3082
3083 it.effect("does not end a turn from an earlier turn's history when its prompt is not there", () =>
3084 Effect.gen(function* () {
3085 // The stream drops before the turn's prompt reached the history: what the
3086 // history holds is the previous turn's, and its `idle` is not this turn's end.
3087 const { runtime, thread } = yield* resumed([
3088 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
3089 promptAccepted,
3090 { type: "runtime_exit", status: "success" } as const,
3091 out("event.subscribe"),
3092 out("session.active"),
3093 replyData("session.active", {}),
3094 out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }),
3095 reply("message.list", {
3096 data: [
3097 { id: "msg_idle_earlier", time: { created: 3 }, type: "idle", outcome: "succeeded" },
3098 {
3099 id: "msg_assistant_earlier",
3100 time: { created: 2 },
3101 type: "assistant",
3102 agent: "build",
3103 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
3104 content: [{ type: "text", text: "An earlier turn's answer." }],
3105 finish: "stop",
3106 },
3107 { id: "msg_user_earlier", time: { created: 1 }, text: "earlier", type: "user" },
3108 ],
3109 cursor: {},
3110 }),
3111 ]);
3112 const events = yield* runtime.events.pipe(
3113 Stream.takeUntil((event) => event.type === "turn.terminal"),
3114 Stream.runCollect,
3115 Effect.forkScoped,
3116 );
3117 yield* runtime.startTurn(turnInput(thread));
3118 const collected = yield* Fiber.join(events);
3119 assert.notInclude(
3120 collected.flatMap((event) =>
3121 event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
3122 ? [event.turnItem.text]
3123 : [],
3124 ),
3125 "An earlier turn's answer.",
3126 );
3127 assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "interrupted" });
3128 }).pipe(Effect.scoped),
3129 );
3130
3131 it.effect("ends a turn that finished while the stream was down with the server's outcome", () =>
3132 Effect.gen(function* () {
3133 const { runtime, thread } = yield* resumed([
3134 out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "<any>" }),
3135 promptAccepted,
3136 ...reconnected({}, "succeeded"),
3137 ]);
3138 const events = yield* runtime.events.pipe(
3139 Stream.takeUntil((event) => event.type === "turn.terminal"),
3140 Stream.runCollect,
3141 Effect.forkScoped,
3142 );
3143 yield* runtime.startTurn(turnInput(thread));
3144 const collected = yield* Fiber.join(events);
3145 // The reply and the tool call it ran are shown before the turn ends.
3146 assert.includeMembers(turnItems(collected), [
3147 "assistant_message:completed",
3148 "command_execution:completed",
3149 ]);
3150 assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" });
3151 }).pipe(Effect.scoped),
3152 );
3153
3154 it.effect("finishes a workspace command whose stream dropped before its inbox event", () =>
3155 Effect.gen(function* () {
3156 // `session.command` answers 204 without the inbox item's id, and the
3157 // stream drops before `session.inbox.enqueued` would have named it.
3158 const { runtime, thread } = yield* resumed([
3159 out("command.list", "<any>"),
3160 reply("command.list", { location: { directory: WORK }, data: [{ name: "hello" }] }),
3161 // Where the history stood before the command.
3162 out("message.list", { sessionID: SESSION, order: "desc", limit: "1" }),
3163 reply("message.list", {
3164 data: [
3165 { id: "msg_idle_before", time: { created: 1 }, type: "idle", outcome: "succeeded" },
3166 ],
3167 cursor: {},
3168 }),
3169 out("session.command", { sessionID: SESSION, name: "hello", text: "WORLD" }),
3170 reply("session.command", null),
3171 { type: "runtime_exit", status: "success" } as const,
3172 out("event.subscribe"),
3173 out("session.active"),
3174 replyData("session.active", {}),
3175 out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }),
3176 reply("message.list", {
3177 data: [
3178 { id: "msg_idle_cmd", time: { created: 4 }, type: "idle", outcome: "succeeded" },
3179 {
3180 id: "msg_assistant_cmd",
3181 time: { created: 3 },
3182 type: "assistant",
3183 agent: "build",
3184 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
3185 content: [{ type: "text", text: "HELLO WORLD" }],
3186 finish: "stop",
3187 },
3188 // The command's expanded template, the turn's own user item.
3189 {
3190 id: "msg_user_cmd",
3191 time: { created: 2 },
3192 text: "Reply with exactly: HELLO WORLD",
3193 type: "user",
3194 },
3195 // The previous turn, which already ended before this one started.
3196 { id: "msg_idle_before", time: { created: 1 }, type: "idle", outcome: "succeeded" },
3197 ],
3198 cursor: {},
3199 }),
3200 ]);
3201 const events = yield* runtime.events.pipe(
3202 Stream.takeUntil((event) => event.type === "turn.terminal"),
3203 Stream.runCollect,
3204 Effect.forkScoped,
3205 );
3206 yield* runtime.startTurn({
3207 ...turnInput(thread),
3208 message: { ...turnInput(thread).message, text: "/hello WORLD" },
3209 });
3210 const collected = yield* Fiber.join(events);
3211 const texts = collected.flatMap((event) =>
3212 event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
3213 ? [event.turnItem.text]
3214 : [],
3215 );
3216 assert.include(texts, "HELLO WORLD");
3217 assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" });
3218 }).pipe(Effect.scoped),
3219 );
3220
3221 it.effect(
3222 "backfills and ends a subagent's turn whose execution ended while the stream was down",
3223 () =>
3224 Effect.gen(function* () {
3225 const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-sub" };
3226 // The subagent's own prompt, as OpenCode queues it: its id is the
3227 // subagent's first history item, where its turn's history begins.
3228 const SUB_PROMPT = "msg_0f73f91e0002lgRNb7FtMzcoQu";
3229 const { runtime, thread } = yield* resumed([
3230 out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "<any>" }),
3231 promptAccepted,
3232 event("session.execution.started", { sessionID: SESSION }),
3233 event("session.tool.input.started", { ...tool, name: "subagent" }),
3234 event("session.tool.called", {
3235 ...tool,
3236 name: "subagent",
3237 input: { description: "Sleep", prompt: "sleep" },
3238 executed: false,
3239 }),
3240 event("session.created", childCreated(CHILD)),
3241 event("session.tool.progress", {
3242 ...tool,
3243 metadata: { sessionID: CHILD, status: "running" },
3244 }),
3245 event("session.inbox.enqueued", {
3246 inboxID: SUB_PROMPT,
3247 sessionID: CHILD,
3248 item: { type: "user", payload: { text: "sleep" }, delivery: "steer" },
3249 }),
3250 event("session.execution.started", { sessionID: CHILD }),
3251 // The stream drops while both run; the subagent ends meanwhile. The
3252 // subagent's history is read first: its turn ends before its caller's.
3253 { type: "runtime_exit", status: "success" } as const,
3254 out("event.subscribe"),
3255 out("session.active"),
3256 replyData("session.active", { [SESSION]: { type: "running" } }),
3257 out("message.list", { sessionID: CHILD, order: "desc", limit: "50" }),
3258 reply("message.list", {
3259 data: [
3260 { id: "msg_idle_sub", time: { created: 4 }, type: "idle", outcome: "succeeded" },
3261 {
3262 id: "msg_assistant_sub",
3263 time: { created: 3 },
3264 type: "assistant",
3265 agent: "general",
3266 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
3267 content: [{ type: "text", text: "Slept while the stream was down." }],
3268 finish: "stop",
3269 },
3270 { id: SUB_PROMPT, time: { created: 2 }, text: "sleep", type: "user" },
3271 ],
3272 cursor: {},
3273 }),
3274 out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }),
3275 reply("message.list", {
3276 data: [{ id: PROMPT_ID, time: { created: 1 }, text: "hi", type: "user" }],
3277 cursor: {},
3278 }),
3279 // The parent still runs, so what it waits on is read back too.
3280 ...openRequests([]),
3281 // The parent goes on after the reconnect and ends its turn.
3282 event("session.execution.succeeded", { sessionID: SESSION }),
3283 ]);
3284 const events = yield* runtime.events.pipe(
3285 Stream.takeUntil((event) => event.type === "turn.terminal"),
3286 Stream.runCollect,
3287 Effect.forkScoped,
3288 );
3289 yield* runtime.startTurn(withLineage(thread));
3290 const collected = yield* Fiber.join(events);
3291 const childTurn = collected.findLast(
3292 (event) =>
3293 event.type === "provider_turn.updated" &&
3294 event.providerTurn.nativeTurnRef?.nativeId === `${CHILD}:turn:1`,
3295 );
3296 // The subagent's missed answer is shown, and its turn ended with its execution.
3297 assert.isTrue(
3298 collected.some(
3299 (event) =>
3300 event.type === "turn_item.updated" &&
3301 event.turnItem.type === "assistant_message" &&
3302 event.turnItem.text === "Slept while the stream was down.",
3303 ),
3304 );
3305 assert.equal(
3306 childTurn?.type === "provider_turn.updated" ? childTurn.providerTurn.status : undefined,
3307 "completed",
3308 );
3309 }).pipe(Effect.scoped),
3310 );
3311
3312 it.effect("keeps a turn still running after a reconnect open for its next events", () =>
3313 Effect.gen(function* () {
3314 const { runtime, thread } = yield* resumed([
3315 out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "<any>" }),
3316 promptAccepted,
3317 ...reconnected({ [SESSION]: { type: "running" } }),
3318 ...openRequests([]),
3319 event("session.text.ended", {
3320 sessionID: SESSION,
3321 assistantMessageID: "msg_assistant_after",
3322 ordinal: 0,
3323 text: "Arrived on the new stream.",
3324 }),
3325 event("session.execution.succeeded", { sessionID: SESSION }),
3326 ]);
3327 const events = yield* runtime.events.pipe(
3328 Stream.takeUntil((event) => event.type === "turn.terminal"),
3329 Stream.runCollect,
3330 Effect.forkScoped,
3331 );
3332 yield* runtime.startTurn(turnInput(thread));
3333 const collected = yield* Fiber.join(events);
3334 const texts = collected.flatMap((event) =>
3335 event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
3336 ? [event.turnItem.text]
3337 : [],
3338 );
3339 assert.includeMembers(texts, [
3340 "Sent while the stream was down.",
3341 "Arrived on the new stream.",
3342 ]);
3343 assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" });
3344 }).pipe(Effect.scoped),
3345 );
3346
3347 it.effect("shows a permission asked while the stream was down, and answers it", () =>
3348 Effect.gen(function* () {
3349 const { runtime, thread } = yield* resumed(
3350 [
3351 out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "<any>" }),
3352 promptAccepted,
3353 // `permission.asked` went by while the stream was down; the run waits on it.
3354 ...reconnected({ [SESSION]: { type: "running" } }),
3355 ...openRequests([shellAsk.data]),
3356 out("permission.reply", {
3357 sessionID: SESSION,
3358 requestID: shellAsk.data.id,
3359 decision: "once",
3360 }),
3361 reply("permission.reply", null),
3362 event("session.execution.succeeded", { sessionID: SESSION }),
3363 ],
3364 { supervised: true },
3365 );
3366 const requested = yield* requestOf(runtime).pipe(Effect.forkScoped);
3367 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
3368 yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required"));
3369 const request = yield* Fiber.join(requested);
3370 assert.equal(request?.nativeRequestRef?.nativeId, shellAsk.data.id);
3371 yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" });
3372 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
3373 }).pipe(Effect.scoped),
3374 );
3375
3376 it.effect("settles a request OpenCode no longer lists after the stream came back", () =>
3377 Effect.gen(function* () {
3378 const { runtime, thread } = yield* resumed(
3379 [
3380 out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "<any>" }),
3381 promptAccepted,
3382 shellAskEvent,
3383 // Answered in another OpenCode client while the stream was down.
3384 ...reconnected({ [SESSION]: { type: "running" } }),
3385 ...openRequests([]),
3386 event("session.execution.succeeded", { sessionID: SESSION }),
3387 ],
3388 { supervised: true },
3389 );
3390 const events = yield* runtime.events.pipe(
3391 Stream.takeUntil((event) => event.type === "turn.terminal"),
3392 Stream.runCollect,
3393 Effect.forkScoped,
3394 );
3395 yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required"));
3396 const collected = yield* Fiber.join(events);
3397 const statuses = collected.flatMap((event) =>
3398 event.type === "runtime_request.updated" ? [event.runtimeRequest.status] : [],
3399 );
3400 assert.deepEqual(statuses, ["pending", "cancelled"]);
3401 assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" });
3402 }).pipe(Effect.scoped),
3403 );
3404
3405 it.effect(
3406 "registers T3's MCP server for the thread alone and removes it when the thread unloads",
3407 () =>
3408 Effect.gen(function* () {
3409 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
3410 yield* mcpSessions.set({
3411 environmentId: EnvironmentId.make("environment:opencode2-adapter"),
3412 threadId,
3413 providerSessionId: "mcp:opencode2-adapter",
3414 providerInstanceId: instanceId,
3415 endpoint: "http://127.0.0.1:3773/mcp",
3416 authorizationHeader: "Bearer thread-credential",
3417 browserToolsAvailable: false,
3418 });
3419 yield* Effect.addFinalizer(() => mcpSessions.clear(threadId));
3420 const server = "t3-code-thread_opencode2-adapter";
3421 const { runtime, thread } = yield* resumed([
3422 // Registered for the session's directory under the thread's own name;
3423 // the session's rules allow only this name's tools (see `t3Rules`).
3424 out("mcp.add", {
3425 server,
3426 "location[directory]": WORK,
3427 config: {
3428 type: "remote",
3429 url: "http://127.0.0.1:3773/mcp",
3430 headers: { Authorization: "Bearer thread-credential" },
3431 oauth: false,
3432 },
3433 }),
3434 reply("mcp.add", null),
3435 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
3436 promptAccepted,
3437 event("session.execution.succeeded", { sessionID: SESSION }),
3438 out("mcp.remove", { server, "location[directory]": WORK }),
3439 reply("mcp.remove", null),
3440 ]);
3441 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
3442 yield* runtime.startTurn(turnInput(thread));
3443 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
3444 yield* runtime.unloadThread!({ providerThread: thread });
3445 }).pipe(Effect.scoped),
3446 );
3447
3448 it.effect("registers a long thread id's MCP server under a name OpenCode accepts", () =>
3449 Effect.gen(function* () {
3450 // Spelled out, this delegated thread's server name would be 120 characters.
3451 const child = ThreadId.make(
3452 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Asubproject-b-round1",
3453 );
3454 const server = "t3-code-aa73fa1e03099934";
3455 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
3456 yield* mcpSessions.set({
3457 environmentId: EnvironmentId.make("environment:opencode2-adapter"),
3458 threadId: child,
3459 providerSessionId: "mcp:opencode2-adapter",
3460 providerInstanceId: instanceId,
3461 endpoint: "http://127.0.0.1:3773/mcp",
3462 authorizationHeader: "Bearer thread-credential",
3463 browserToolsAvailable: false,
3464 });
3465 yield* Effect.addFinalizer(() => mcpSessions.clear(child));
3466 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
3467 ...opening,
3468 out("session.get", { sessionID: SESSION }),
3469 // The session already has this thread's rules, so they are not rewritten.
3470 replyData(
3471 "session.get",
3472 sessionInfo({
3473 permissions: [
3474 { action: "*", resource: "*", effect: "allow" },
3475 { action: "t3-code-*", resource: "*", effect: "deny" },
3476 { action: `${server}_*`, resource: "*", effect: "allow" },
3477 ],
3478 }),
3479 ),
3480 ...noOpenRequests,
3481 out("mcp.add", {
3482 server,
3483 "location[directory]": WORK,
3484 config: {
3485 type: "remote",
3486 url: "http://127.0.0.1:3773/mcp",
3487 headers: { Authorization: "Bearer thread-credential" },
3488 oauth: false,
3489 },
3490 }),
3491 reply("mcp.add", null),
3492 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
3493 promptAccepted,
3494 event("session.execution.succeeded", { sessionID: SESSION }),
3495 out("mcp.remove", { server, "location[directory]": WORK }),
3496 reply("mcp.remove", null),
3497 ]);
3498 const thread = yield* runtime.resumeThread({
3499 providerThread: { ...providerThread(yield* DateTime.now), appThreadId: child },
3500 threadId: child,
3501 modelSelection: bigPickle,
3502 runtimePolicy: policy(),
3503 });
3504 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
3505 yield* runtime.startTurn({ ...turnInput(thread), threadId: child });
3506 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
3507 yield* runtime.unloadThread!({ providerThread: thread });
3508 }).pipe(Effect.scoped),
3509 );
3510
3511 it.effect("names each thread's MCP server within OpenCode's limits, one name per thread", () =>
3512 Effect.gen(function* () {
3513 const project = "thread:project:ce04e4e2-6c29-4ff0-a1d7-b089dd63e258";
3514 const ids = [
3515 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c4`,
3516 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c5`,
3517 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around1",
3518 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around2",
3519 ];
3520 const names = yield* Effect.forEach(ids, t3McpServerName);
3521 for (const name of names) assert.match(name, /^t3-code-[A-Za-z0-9_-]{1,56}$/);
3522 assert.equal(new Set(names).size, ids.length);
3523 assert.deepEqual(yield* Effect.forEach(ids, t3McpServerName), names);
3524 // A digested name is the one the synchronous node:crypto version produced.
3525 assert.equal(names[0], "t3-code-63abb5df2b188bdd");
3526 // A name that already fits stays readable.
3527 assert.equal(yield* t3McpServerName(threadId), "t3-code-thread_opencode2-adapter");
3528 }).pipe(Effect.provide(NodeCrypto.layer)),
3529 );
3530
3531 it.effect("reads user and assistant text from the session's message list", () =>
3532 Effect.gen(function* () {
3533 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
3534 ...opening,
3535 out("message.list", { sessionID: SESSION, order: "asc", limit: "100" }),
3536 reply("message.list", history),
3537 ]);
3538 const snapshot = yield* runtime.readThreadSnapshot({
3539 providerThread: providerThread(yield* DateTime.now),
3540 });
3541 assert.deepEqual(
3542 snapshot.messages.map((message) => [message.role, message.text]),
3543 [
3544 ["user", history.data[0]!.text],
3545 ["assistant", "391 is not prime: it's the product 17 × 23."],
3546 ],
3547 );
3548 assert.equal(
3549 snapshot.providerThread.nativeConversationHeadRef?.nativeId,
3550 history.data[0]!.id,
3551 );
3552 }).pipe(Effect.scoped),
3553 );
3554
3555 it.effect("ends a turn before re-reading a model list that never answers", () =>
3556 Effect.gen(function* () {
3557 // The session opened before the catalog loaded, so the turn's model has no window.
3558 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
3559 out("event.subscribe"),
3560 out("model.list", "<any>"),
3561 reply("model.list", { location: { directory: WORK }, data: [] }),
3562 out("session.get", { sessionID: SESSION }),
3563 replyData("session.get", sessionInfo()),
3564 ...noOpenRequests,
3565 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
3566 promptAccepted,
3567 event("session.execution.succeeded", { sessionID: SESSION }),
3568 out("model.list", "<any>"),
3569 reply("model.list", "<hang>"),
3570 ]);
3571 const thread = yield* runtime.resumeThread({
3572 providerThread: providerThread(yield* DateTime.now),
3573 threadId,
3574 modelSelection: bigPickle,
3575 runtimePolicy: policy(),
3576 });
3577 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
3578 yield* runtime.startTurn(turnInput(thread));
3579 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
3580 // The terminal did not wait on the re-read, which is still in flight.
3581 for (let i = 0; i < 20; i++) yield* Effect.yieldNow;
3582 }).pipe(Effect.scoped),
3583 );
3584
3585 it.effect("reports cache writes as cache creation, not only as input", () =>
3586 Effect.gen(function* () {
3587 const { runtime, thread } = yield* resumed([
3588 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
3589 promptAccepted,
3590 // A step from a provider that reports prompt-cache writes.
3591 event("session.step.ended", {
3592 sessionID: SESSION,
3593 assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z",
3594 finish: "stop",
3595 cost: 0,
3596 tokens: { input: 1200, output: 40, reasoning: 0, cache: { read: 300, write: 2500 } },
3597 }),
3598 event("session.execution.succeeded", { sessionID: SESSION }),
3599 ]);
3600 const turn = yield* runtime.events.pipe(
3601 Stream.filter(
3602 (event) =>
3603 event.type === "provider_turn.updated" && event.providerTurn.completedAt !== null,
3604 ),
3605 Stream.runHead,
3606 Effect.forkScoped,
3607 );
3608 yield* runtime.startTurn(turnInput(thread));
3609 const settled = Option.getOrUndefined(yield* Fiber.join(turn));
3610 assert.deepInclude(
3611 settled?.type === "provider_turn.updated" ? settled.providerTurn.turnTokenUsage : undefined,
3612 {
3613 inputTokens: 1200 + 300 + 2500,
3614 cachedInputTokens: 300,
3615 cacheCreationTokens: 2500,
3616 outputTokens: 40,
3617 },
3618 );
3619 }).pipe(Effect.scoped),
3620 );
3621
3622 it.effect("reports a model's input limit as its context window", () =>
3623 Effect.gen(function* () {
3624 const runtime = yield* openCode2ReplayRuntime([...opening]);
3625 assert.equal(runtime.getModelContextWindow?.(bigPickle), 160000);
3626 }).pipe(Effect.scoped),
3627 );
3628
3629 it.effect("reports each directory's own limit for a model its project config changed", () =>
3630 Effect.gen(function* () {
3631 // 2.0.18 lists big-pickle at 48k input for a project whose opencode.json
3632 // sets that limit, and at its catalog 160k everywhere else.
3633 const custom = "/work/opencode2-custom";
3634 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
3635 ...opening,
3636 out("model.list", { "location[directory]": custom }),
3637 reply("model.list", {
3638 location: { directory: custom },
3639 data: modelCatalog.data.map((model) => ({
3640 ...model,
3641 limit: { context: 64000, input: 48000, output: 8000 },
3642 })),
3643 }),
3644 out("session.create", {
3645 location: { directory: custom },
3646 model: { providerID: "opencode", id: "big-pickle" },
3647 permissions: t3Rules,
3648 }),
3649 replyData("session.create", sessionInfo({ location: { directory: custom } })),
3650 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
3651 promptAccepted,
3652 event("session.step.ended", {
3653 sessionID: SESSION,
3654 assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z",
3655 finish: "stop",
3656 cost: 0,
3657 tokens: { input: 1200, output: 40, reasoning: 0, cache: { read: 0, write: 0 } },
3658 }),
3659 event("session.execution.succeeded", { sessionID: SESSION }),
3660 ]);
3661 const customPolicy = { ...policy(), cwd: custom };
3662 const thread = yield* runtime.ensureThread({
3663 threadId,
3664 modelSelection: bigPickle,
3665 runtimePolicy: customPolicy,
3666 });
3667 assert.deepEqual(thread.nativeMetadata?.modelSelection, {
3668 ...bigPickle,
3669 options: [{ id: "variant", value: "default" }],
3670 });
3671 // Each directory keeps its own limit, whichever was read last.
3672 assert.equal(runtime.getModelContextWindow?.(bigPickle, WORK), 160000);
3673 assert.equal(runtime.getModelContextWindow?.(bigPickle, custom), 48000);
3674 const settled = yield* runtime.events.pipe(
3675 Stream.filter(
3676 (event) =>
3677 event.type === "provider_turn.updated" && event.providerTurn.completedAt !== null,
3678 ),
3679 Stream.runHead,
3680 Effect.forkScoped,
3681 );
3682 yield* runtime.startTurn({ ...turnInput(thread), runtimePolicy: customPolicy });
3683 const turn = Option.getOrUndefined(yield* Fiber.join(settled));
3684 assert.equal(
3685 turn?.type === "provider_turn.updated" ? turn.providerTurn.tokenUsage?.maxTokens : null,
3686 48000,
3687 );
3688 }).pipe(Effect.scoped),
3689 );
3690
3691 it.effect("clears a staged revert whose commit failed, before the next prompt too", () =>
3692 Effect.gen(function* () {
3693 const kept = "msg_t3_turn_run-attempt:kept:1";
3694 const dropped = "msg_t3_turn_run-attempt:dropped:1";
3695 const { runtime, thread } = yield* resumed([
3696 out("message.list", "<any>"),
3697 reply("message.list", {
3698 data: [
3699 { id: dropped, time: { created: 2 }, text: "second", type: "user" },
3700 { id: kept, time: { created: 1 }, text: "first", type: "user" },
3701 ],
3702 cursor: {},
3703 }),
3704 out("session.revert.stage", { sessionID: SESSION, messageID: dropped, files: false }),
3705 replyData("session.revert.stage", { messageID: dropped, files: [] }),
3706 out("session.revert.commit", { sessionID: SESSION }),
3707 reply("session.revert.commit", {
3708 status: 500,
3709 body: { _tag: "UnknownError", message: "disk I/O error" },
3710 }),
3711 // Uncleared, OpenCode would commit the stage on the next prompt.
3712 out("session.revert.clear", { sessionID: SESSION }),
3713 reply("session.revert.clear", {
3714 status: 500,
3715 body: { _tag: "UnknownError", message: "busy" },
3716 }),
3717 // The next turn clears it first, then waits out the empty execution `clear` runs.
3718 out("session.revert.clear", { sessionID: SESSION }),
3719 reply("session.revert.clear", null),
3720 event("session.execution.started", { sessionID: SESSION }),
3721 event("session.execution.succeeded", { sessionID: SESSION }),
3722 out("session.prompt", { sessionID: SESSION, id: "<any>", text: "<any>" }),
3723 promptAccepted,
3724 event("session.execution.started", { sessionID: SESSION }),
3725 event("session.execution.succeeded", { sessionID: SESSION }),
3726 ]);
3727 const now = yield* DateTime.now;
3728 const turn = (attempt: string, ordinal: number) => ({
3729 id: ProviderTurnId.make(`provider-turn:${attempt}`),
3730 providerThreadId: thread.id,
3731 nodeId: NodeId.make(`node:${attempt}`),
3732 runAttemptId: RunAttemptId.make(`run-attempt:${attempt}:1`),
3733 nativeTurnRef: {
3734 driver: OPENCODE_PROVIDER,
3735 nativeId: `msg_t3_turn_run-attempt:${attempt}:1`,
3736 strength: "weak" as const,
3737 },
3738 ordinal,
3739 status: "completed" as const,
3740 startedAt: now,
3741 completedAt: now,
3742 });
3743 const rollback = yield* runtime
3744 .rollbackThread({
3745 providerThread: thread,
3746 target: {
3747 type: "provider_turn",
3748 checkpointId: CheckpointId.make("checkpoint:kept"),
3749 appRunOrdinal: 1,
3750 providerTurn: turn("kept", 1),
3751 },
3752 providerThreadTurns: [turn("kept", 1), turn("dropped", 2)],
3753 })
3754 .pipe(Effect.exit);
3755 assert.isTrue(Exit.isFailure(rollback));
3756 yield* runtime.startTurn(turnInput(thread));
3757 assert.equal((yield* terminalOf(runtime))?.status, "completed");
3758 }).pipe(Effect.scoped),
3759 );
3760
3761 it.effect("offers no follow-up for the execution a cleared revert starts", () =>
3762 Effect.gen(function* () {
3763 const kept = "msg_t3_turn_run-attempt:kept:1";
3764 const dropped = "msg_t3_turn_run-attempt:dropped:1";
3765 const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = [];
3766 const { runtime, thread } = yield* resumed([
3767 out("message.list", "<any>"),
3768 reply("message.list", {
3769 data: [
3770 { id: dropped, time: { created: 2 }, text: "second", type: "user" },
3771 { id: kept, time: { created: 1 }, text: "first", type: "user" },
3772 ],
3773 cursor: {},
3774 }),
3775 out("session.revert.stage", { sessionID: SESSION, messageID: dropped, files: false }),
3776 replyData("session.revert.stage", { messageID: dropped, files: [] }),
3777 out("session.revert.commit", { sessionID: SESSION }),
3778 reply("session.revert.commit", {
3779 status: 500,
3780 body: { _tag: "UnknownError", message: "disk I/O error" },
3781 }),
3782 // Uncleared, OpenCode would commit the stage on the next prompt. The
3783 // clear wakes the session into an empty execution of its own, with no
3784 // turn of T3's running: it is no subagent's follow-up.
3785 out("session.revert.clear", { sessionID: SESSION }),
3786 reply("session.revert.clear", null),
3787 event("session.revert.cleared", { sessionID: SESSION }),
3788 event("session.execution.started", { sessionID: SESSION }),
3789 event("session.execution.succeeded", { sessionID: SESSION }),
3790 ]).pipe(
3791 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
3792 offer: (request) => Effect.sync(() => void offers.push(request)),
3793 take: Effect.never,
3794 }),
3795 );
3796 const now = yield* DateTime.now;
3797 const turn = (attempt: string, ordinal: number) => ({
3798 id: ProviderTurnId.make(`provider-turn:${attempt}`),
3799 providerThreadId: thread.id,
3800 nodeId: NodeId.make(`node:${attempt}`),
3801 runAttemptId: RunAttemptId.make(`run-attempt:${attempt}:1`),
3802 nativeTurnRef: {
3803 driver: OPENCODE_PROVIDER,
3804 nativeId: `msg_t3_turn_run-attempt:${attempt}:1`,
3805 strength: "weak" as const,
3806 },
3807 ordinal,
3808 status: "completed" as const,
3809 startedAt: now,
3810 completedAt: now,
3811 });
3812 const rollback = yield* runtime
3813 .rollbackThread({
3814 providerThread: thread,
3815 target: {
3816 type: "provider_turn",
3817 checkpointId: CheckpointId.make("checkpoint:kept"),
3818 appRunOrdinal: 1,
3819 providerTurn: turn("kept", 1),
3820 },
3821 providerThreadTurns: [turn("kept", 1), turn("dropped", 2)],
3822 })
3823 .pipe(Effect.exit);
3824 assert.isTrue(Exit.isFailure(rollback));
3825 assert.lengthOf(offers, 0);
3826 assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
3827 }).pipe(Effect.scoped),
3828 );
3829
3830 it.effect("prompts under ids no other session on the server can hold", () =>
3831 Effect.gen(function* () {
3832 // Two T3 databases on one external server repeat thread ids and run
3833 // ordinals, so their turns can share an attempt id and their steers a
3834 // message id. OpenCode refuses a prompt id another session already
3835 // holds with 409 ConflictError; the replay refuses a client id it
3836 // already gave another recorded message.
3837 const OTHER = "ses_f148ca2deffeOtherSession000";
3838 const steerInto = (session: string, recorded: string): ReadonlyArray<ProviderReplayEntry> => [
3839 out("session.prompt", {
3840 sessionID: session,
3841 id: recorded,
3842 text: "<any>",
3843 delivery: "steer",
3844 }),
3845 replyData("session.prompt", {
3846 id: recorded,
3847 sessionID: session,
3848 time: { created: 1790656601500 },
3849 type: "user",
3850 payload: { text: "Also say STEERED." },
3851 delivery: "steer",
3852 }),
3853 event("session.inbox.delivered", { sessionID: session, inboxID: recorded }),
3854 event("session.execution.succeeded", { sessionID: session }),
3855 ];
3856 const promptInto = (
3857 session: string,
3858 recorded: string,
3859 ): ReadonlyArray<ProviderReplayEntry> => [
3860 out("session.prompt", { sessionID: session, id: recorded, text: "<any>" }),
3861 replyData("session.prompt", {
3862 id: recorded,
3863 sessionID: session,
3864 time: { created: 1790656601410 },
3865 type: "user",
3866 payload: { text: "hi" },
3867 delivery: "steer",
3868 }),
3869 event("session.execution.started", { sessionID: session }),
3870 ];
3871 const { runtime, thread } = yield* resumed([
3872 out("session.get", { sessionID: OTHER }),
3873 replyData("session.get", sessionInfo({ id: OTHER })),
3874 out("permission.list", { sessionID: OTHER }),
3875 replyData("permission.list", []),
3876 out("session.form.list", { sessionID: OTHER }),
3877 replyData("session.form.list", []),
3878 ...promptInto(SESSION, "msg_recorded_turn_a"),
3879 ...steerInto(SESSION, "msg_recorded_steer_a"),
3880 // The other session gets its own instructions entry before its first prompt.
3881 out("session.instructions.entry.put", { sessionID: OTHER, key: "t3-code", value: "<any>" }),
3882 reply("session.instructions.entry.put", null),
3883 ...promptInto(OTHER, "msg_recorded_turn_b"),
3884 ...steerInto(OTHER, "msg_recorded_steer_b"),
3885 ]);
3886 const other = {
3887 ...thread,
3888 id: ProviderThreadId.make("provider-thread:opencode2-adapter:other"),
3889 nativeThreadRef: {
3890 driver: OPENCODE_PROVIDER,
3891 nativeId: OTHER,
3892 strength: "strong" as const,
3893 },
3894 };
3895 yield* runtime.resumeThread({
3896 providerThread: other,
3897 threadId,
3898 modelSelection: bigPickle,
3899 runtimePolicy: policy(),
3900 });
3901 const ids = yield* IdAllocator.IdAllocatorV2;
3902 const ended = yield* terminals(runtime, 2);
3903 for (const [session, providerThread] of [
3904 [SESSION, thread],
3905 [OTHER, other],
3906 ] as const) {
3907 yield* runtime.startTurn(turnInput(providerThread));
3908 yield* runtime.steerTurn({
3909 threadId,
3910 runId: RunId.make("run:opencode2-adapter"),
3911 providerThread,
3912 providerTurnId: ids.derive.providerTurn({
3913 driver: OPENCODE_PROVIDER,
3914 nativeTurnId: `${session}:attempt:attempt:opencode2-adapter`,
3915 }),
3916 message: {
3917 ...turnInput(providerThread).message,
3918 messageId: MessageId.make("message:opencode2-adapter:steer"),
3919 text: "Also say STEERED.",
3920 },
3921 });
3922 }
3923 const statuses = [...(yield* Fiber.join(ended))].map((terminal) => terminal.status);
3924 assert.deepEqual(statuses, ["completed", "completed"]);
3925 }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)),
3926 );
3927
3928 it.effect.each([
3929 ["refuses a rollback while a timed-out Stop's run is still going", true],
3930 ["rolls back once a timed-out Stop's run has left the server", false],
3931 ] as const)("%s", ([, running]) =>
3932 Effect.gen(function* () {
3933 const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`;
3934 const { runtime, thread } = yield* resumed([
3935 ...stopTimedOut,
3936 // The server says whether the stopped run still goes.
3937 out("session.active"),
3938 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),
3939 // Gone: the cut is made. Running: nothing is read or cut meanwhile.
3940 ...(running
3941 ? []
3942 : [
3943 out("message.list", "<any>"),
3944 reply("message.list", {
3945 data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }],
3946 cursor: {},
3947 }),
3948 out("session.revert.stage", {
3949 sessionID: SESSION,
3950 messageID: prompt,
3951 files: false,
3952 }),
3953 replyData("session.revert.stage", { messageID: prompt, files: [] }),
3954 out("session.revert.commit", { sessionID: SESSION }),
3955 reply("session.revert.commit", null),
3956 out("message.list", "<any>"),
3957 reply("message.list", { data: [], cursor: {} }),
3958 ]),
3959 ]);
3960 yield* stopFirstTurn(runtime, thread);
3961 const now = yield* DateTime.now;
3962 const first = {
3963 id: yield* providerTurnId,
3964 providerThreadId: thread.id,
3965 nodeId: NodeId.make("node:opencode2-adapter"),
3966 runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"),
3967 nativeTurnRef: {
3968 driver: OPENCODE_PROVIDER,
3969 nativeId: prompt,
3970 strength: "weak" as const,
3971 },
3972 ordinal: 1,
3973 status: "interrupted" as const,
3974 startedAt: now,
3975 completedAt: now,
3976 };
3977 const rollback = yield* runtime
3978 .rollbackThread({
3979 providerThread: thread,
3980 target: {
3981 type: "thread_start",
3982 checkpointId: CheckpointId.make("checkpoint:start"),
3983 appRunOrdinal: 0,
3984 },
3985 providerThreadTurns: [first],
3986 })
3987 .pipe(Effect.exit);
3988 if (running) {
3989 assert.isTrue(Exit.isFailure(rollback));
3990 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;
3991 assert.equal(
3992 (error as { _tag?: string } | undefined)?._tag,
3993 "ProviderAdapterProtocolError",
3994 );
3995 } else {
3996 assert.isTrue(Exit.isSuccess(rollback));
3997 }
3998 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
3999 );
4000
4001 it.effect("takes back a stranded steer again when its first cancel failed", () =>
4002 Effect.gen(function* () {
4003 const steerId = `msg_t3_steer_${SESSION}:message:opencode2-adapter:steer`;
4004 const cancelOut = out("session.inbox.cancel", { sessionID: SESSION, inboxID: steerId });
4005 const { runtime, thread } = yield* resumed([
4006 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4007 promptAccepted,
4008 event("session.execution.started", { sessionID: SESSION }),
4009 out("session.prompt", {
4010 sessionID: SESSION,
4011 id: steerId,
4012 text: "<any>",
4013 delivery: "steer",
4014 }),
4015 replyData("session.prompt", {
4016 id: steerId,
4017 sessionID: SESSION,
4018 time: { created: 1790656601500 },
4019 type: "user",
4020 payload: { text: "Also say STEERED." },
4021 delivery: "steer",
4022 }),
4023 // Stopped before the steer was read: it stays in OpenCode's inbox.
4024 out("session.interrupt", { sessionID: SESSION }),
4025 reply("session.interrupt", { interrupted: true }),
4026 event("session.execution.interrupted", { sessionID: SESSION }),
4027 // The next turn's cancel fails, so the turn after it tries again.
4028 cancelOut,
4029 reply("session.inbox.cancel", {
4030 status: 500,
4031 body: { _tag: "UnknownError", message: "busy" },
4032 }),
4033 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4034 promptAccepted,
4035 event("session.execution.started", { sessionID: SESSION }),
4036 event("session.execution.succeeded", { sessionID: SESSION }),
4037 cancelOut,
4038 reply("session.inbox.cancel", null),
4039 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4040 promptAccepted,
4041 event("session.execution.started", { sessionID: SESSION }),
4042 event("session.execution.succeeded", { sessionID: SESSION }),
4043 ]);
4044 const seen: Array<string> = [];
4045 const ended = yield* Deferred.make<void>();
4046 const nextEnded = yield* Deferred.make<void>();
4047 // One consumer reads every terminal; a second reader would race it for the queue.
4048 const thirdEnded = yield* Deferred.make<void>();
4049 yield* runtime.events.pipe(
4050 Stream.tap((event) =>
4051 Effect.gen(function* () {
4052 if (event.type !== "turn.terminal") return;
4053 seen.push(event.status);
4054 if (seen.length === 1) yield* Deferred.succeed(ended, undefined);
4055 if (seen.length === 2) yield* Deferred.succeed(nextEnded, undefined);
4056 if (seen.length === 3) yield* Deferred.succeed(thirdEnded, undefined);
4057 }),
4058 ),
4059 Stream.runDrain,
4060 Effect.forkScoped,
4061 );
4062 yield* runtime.startTurn(turnInput(thread));
4063 yield* runtime.steerTurn({
4064 threadId,
4065 runId: RunId.make("run:opencode2-adapter"),
4066 providerThread: thread,
4067 providerTurnId: yield* providerTurnId,
4068 message: {
4069 ...turnInput(thread).message,
4070 messageId: MessageId.make("message:opencode2-adapter:steer"),
4071 text: "Also say STEERED.",
4072 },
4073 });
4074 yield* runtime.interruptTurn({
4075 providerThread: thread,
4076 providerTurnId: yield* providerTurnId,
4077 });
4078 yield* Deferred.await(ended);
4079 yield* runtime.startTurn(secondTurn(thread));
4080 yield* Deferred.await(nextEnded);
4081 yield* runtime.startTurn({
4082 ...secondTurn(thread),
4083 runId: RunId.make("run:opencode2-adapter:3"),
4084 runOrdinal: 3,
4085 providerTurnOrdinal: 3,
4086 attemptId: RunAttemptId.make("attempt:opencode2-adapter:3"),
4087 });
4088 yield* Deferred.await(thirdEnded);
4089 }).pipe(Effect.scoped),
4090 );
4091
4092 it.effect("drops the turns a rollback cut from the snapshot it returns", () =>
4093 Effect.gen(function* () {
4094 const kept = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`;
4095 const dropped = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter:2`;
4096 const { runtime, thread } = yield* resumed([
4097 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4098 promptAccepted,
4099 event("session.execution.started", { sessionID: SESSION }),
4100 event("session.execution.succeeded", { sessionID: SESSION }),
4101 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4102 promptAccepted,
4103 event("session.execution.started", { sessionID: SESSION }),
4104 event("session.execution.succeeded", { sessionID: SESSION }),
4105 out("message.list", "<any>"),
4106 reply("message.list", {
4107 data: [
4108 { id: dropped, time: { created: 2 }, text: "second", type: "user" },
4109 { id: kept, time: { created: 1 }, text: "first", type: "user" },
4110 ],
4111 cursor: {},
4112 }),
4113 out("session.revert.stage", { sessionID: SESSION, messageID: dropped, files: false }),
4114 replyData("session.revert.stage", { messageID: dropped, files: [] }),
4115 out("session.revert.commit", { sessionID: SESSION }),
4116 reply("session.revert.commit", null),
4117 out("message.list", "<any>"),
4118 reply("message.list", {
4119 data: [{ id: kept, time: { created: 1 }, text: "first", type: "user" }],
4120 cursor: {},
4121 }),
4122 ]);
4123 const turns: Array<OrchestrationV2ProviderTurn> = [];
4124 const bothEnded = yield* Deferred.make<void>();
4125 yield* runtime.events.pipe(
4126 Stream.tap((event) =>
4127 Effect.gen(function* () {
4128 if (event.type === "provider_turn.updated" && event.providerTurn.status !== "running") {
4129 turns.push(event.providerTurn);
4130 if (turns.length === 2) yield* Deferred.succeed(bothEnded, undefined);
4131 }
4132 }),
4133 ),
4134 Stream.runDrain,
4135 Effect.forkScoped,
4136 );
4137 yield* runtime.startTurn(turnInput(thread));
4138 yield* Effect.gen(function* () {
4139 while (turns.length === 0) yield* Effect.yieldNow;
4140 });
4141 yield* runtime.startTurn(secondTurn(thread));
4142 yield* Deferred.await(bothEnded);
4143 const [first, second] = turns;
4144 const snapshot = yield* runtime.rollbackThread({
4145 providerThread: thread,
4146 target: {
4147 type: "provider_turn",
4148 checkpointId: CheckpointId.make("checkpoint:first"),
4149 appRunOrdinal: 1,
4150 providerTurn: first!,
4151 },
4152 providerThreadTurns: [first!, second!],
4153 });
4154 assert.deepEqual(
4155 snapshot.providerTurns.map((turn) => turn.id),
4156 [first!.id],
4157 );
4158 }).pipe(Effect.scoped),
4159 );
4160
4161 it.effect("starts a turn sent during a rollback only once the cut is made", () =>
4162 Effect.gen(function* () {
4163 const prompted = `msg_t3_turn_${SESSION}:attempt:earlier`;
4164 // The replay is strictly ordered: a prompt sent while the rollback still
4165 // reads or cuts the history fails it.
4166 const { runtime, thread } = yield* resumed([
4167 out("message.list", "<any>"),
4168 reply("message.list", {
4169 data: [{ id: prompted, time: { created: 1 }, text: "earlier", type: "user" }],
4170 cursor: {},
4171 }),
4172 out("session.revert.stage", { sessionID: SESSION, messageID: prompted, files: false }),
4173 replyData("session.revert.stage", { messageID: prompted, files: [] }),
4174 out("session.revert.commit", { sessionID: SESSION }),
4175 reply("session.revert.commit", null),
4176 out("message.list", "<any>"),
4177 reply("message.list", { data: [], cursor: {} }),
4178 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4179 promptAccepted,
4180 event("session.execution.started", { sessionID: SESSION }),
4181 event("session.execution.succeeded", { sessionID: SESSION }),
4182 ]);
4183 const now = yield* DateTime.now;
4184 const earlier = {
4185 id: ProviderTurnId.make("provider-turn:earlier"),
4186 providerThreadId: thread.id,
4187 nodeId: NodeId.make("node:earlier"),
4188 runAttemptId: RunAttemptId.make("attempt:earlier"),
4189 nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId: prompted, strength: "weak" as const },
4190 ordinal: 1,
4191 status: "completed" as const,
4192 startedAt: now,
4193 completedAt: now,
4194 };
4195 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
4196 const rollback = yield* runtime
4197 .rollbackThread({
4198 providerThread: thread,
4199 target: {
4200 type: "thread_start",
4201 checkpointId: CheckpointId.make("checkpoint:start"),
4202 appRunOrdinal: 0,
4203 },
4204 providerThreadTurns: [earlier],
4205 })
4206 .pipe(Effect.forkScoped);
4207 yield* Effect.yieldNow;
4208 yield* runtime.startTurn({ ...turnInput(thread), providerTurnOrdinal: 1 });
4209 yield* Fiber.join(rollback);
4210 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
4211 }).pipe(Effect.scoped),
4212 );
4213
4214 it.effect.each([
4215 ["refuses to roll back a session not loaded yet while the server runs it", true],
4216 [
4217 "keeps the recorded turns before the target when rolling back a session not loaded yet",
4218 false,
4219 ],
4220 ] as const)("%s", ([, running]) =>
4221 Effect.gen(function* () {
4222 const first = `msg_t3_turn_${SESSION}:attempt:first`;
4223 const second = `msg_t3_turn_${SESSION}:attempt:second`;
4224 // A runtime that never loaded the session, as after a T3 restart
4225 // against a server that kept running.
4226 const runtime = yield* openCode2ReplayRuntime([
4227 ...opening,
4228 out("session.active"),
4229 reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }),
4230 ...(running
4231 ? []
4232 : [
4233 out("session.get", { sessionID: SESSION }),
4234 replyData("session.get", sessionInfo()),
4235 ...noOpenRequests,
4236 out("message.list", "<any>"),
4237 reply("message.list", {
4238 data: [
4239 { id: second, time: { created: 2 }, text: "second", type: "user" },
4240 { id: first, time: { created: 1 }, text: "first", type: "user" },
4241 ],
4242 cursor: {},
4243 }),
4244 out("session.revert.stage", {
4245 sessionID: SESSION,
4246 messageID: second,
4247 files: false,
4248 }),
4249 replyData("session.revert.stage", { messageID: second, files: [] }),
4250 out("session.revert.commit", { sessionID: SESSION }),
4251 reply("session.revert.commit", null),
4252 out("message.list", "<any>"),
4253 reply("message.list", {
4254 data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }],
4255 cursor: {},
4256 }),
4257 ]),
4258 ]);
4259 const thread = providerThread(yield* DateTime.now);
4260 const now = yield* DateTime.now;
4261 const recorded = (key: string, ordinal: number, nativeId: string) => ({
4262 id: ProviderTurnId.make(`provider-turn:${key}`),
4263 providerThreadId: thread.id,
4264 nodeId: NodeId.make(`node:${key}`),
4265 runAttemptId: RunAttemptId.make(`attempt:${key}`),
4266 nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const },
4267 ordinal,
4268 status: "completed" as const,
4269 startedAt: now,
4270 completedAt: now,
4271 });
4272 const kept = recorded("first", 1, first);
4273 const rollback = yield* runtime
4274 .rollbackThread({
4275 providerThread: thread,
4276 target: {
4277 type: "provider_turn",
4278 checkpointId: CheckpointId.make("checkpoint:first"),
4279 appRunOrdinal: 1,
4280 providerTurn: kept,
4281 },
4282 providerThreadTurns: [kept, recorded("second", 2, second)],
4283 })
4284 .pipe(Effect.exit);
4285 if (running) {
4286 const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined;
4287 assert.equal(
4288 (error as { _tag?: string } | undefined)?._tag,
4289 "ProviderAdapterProtocolError",
4290 );
4291 } else {
4292 assert.isTrue(Exit.isSuccess(rollback));
4293 assert.deepEqual(
4294 Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [],
4295 [kept.id],
4296 );
4297 }
4298 }).pipe(Effect.scoped),
4299 );
4300
4301 it.effect("refuses to fork a session while OpenCode runs a follow-up on it", () =>
4302 Effect.gen(function* () {
4303 // OpenCode starts the thread's session on its own (a background
4304 // subagent's report). The replay fails on a fork request.
4305 const offered = yield* Deferred.make<void>();
4306 const { runtime, thread } = yield* resumed([
4307 event("session.execution.started", { sessionID: SESSION }),
4308 ]).pipe(
4309 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
4310 offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
4311 take: Effect.never,
4312 }),
4313 );
4314 yield* Deferred.await(offered);
4315 const refused = yield* runtime
4316 .forkThread({
4317 sourceProviderThread: thread,
4318 targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"),
4319 })
4320 .pipe(Effect.flip);
4321 assert.equal(refused._tag, "ProviderAdapterProtocolError");
4322 }).pipe(Effect.scoped),
4323 );
4324
4325 it.effect("asks the server before forking a session this runtime has not loaded", () =>
4326 Effect.gen(function* () {
4327 const runtime = yield* openCode2ReplayRuntime([
4328 ...opening,
4329 out("session.active"),
4330 reply("session.active", { data: { [SESSION]: { type: "running" } } }),
4331 ]);
4332 const refused = yield* runtime
4333 .forkThread({
4334 sourceProviderThread: providerThread(yield* DateTime.now),
4335 targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"),
4336 })
4337 .pipe(Effect.flip);
4338 assert.equal(refused._tag, "ProviderAdapterProtocolError");
4339 }).pipe(Effect.scoped),
4340 );
4341
4342 it.effect("lets the next turn start when a rollback's stage never answers", () =>
4343 Effect.gen(function* () {
4344 const prompted = `msg_t3_turn_${SESSION}:attempt:earlier`;
4345 const { runtime, thread } = yield* resumed([
4346 out("message.list", "<any>"),
4347 reply("message.list", {
4348 data: [{ id: prompted, time: { created: 1 }, text: "earlier", type: "user" }],
4349 cursor: {},
4350 }),
4351 out("session.revert.stage", { sessionID: SESSION, messageID: prompted, files: false }),
4352 reply("session.revert.stage", "<hang>"),
4353 // The stalled stage is cleared, which runs its empty execution.
4354 out("session.revert.clear", { sessionID: SESSION }),
4355 reply("session.revert.clear", null),
4356 event("session.execution.started", { sessionID: SESSION }),
4357 event("session.execution.succeeded", { sessionID: SESSION }),
4358 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4359 promptAccepted,
4360 event("session.execution.started", { sessionID: SESSION }),
4361 event("session.execution.succeeded", { sessionID: SESSION }),
4362 ]);
4363 const now = yield* DateTime.now;
4364 const earlier = {
4365 id: ProviderTurnId.make("provider-turn:earlier"),
4366 providerThreadId: thread.id,
4367 nodeId: NodeId.make("node:earlier"),
4368 runAttemptId: RunAttemptId.make("attempt:earlier"),
4369 nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId: prompted, strength: "weak" as const },
4370 ordinal: 1,
4371 status: "completed" as const,
4372 startedAt: now,
4373 completedAt: now,
4374 };
4375 const rollback = yield* runtime
4376 .rollbackThread({
4377 providerThread: thread,
4378 target: {
4379 type: "thread_start",
4380 checkpointId: CheckpointId.make("checkpoint:start"),
4381 appRunOrdinal: 0,
4382 },
4383 providerThreadTurns: [earlier],
4384 })
4385 .pipe(Effect.exit, Effect.forkScoped);
4386 yield* TestClock.adjust("11 seconds");
4387 assert.isTrue(Exit.isFailure(yield* Fiber.join(rollback)));
4388 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
4389 yield* runtime.startTurn(turnInput(thread));
4390 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
4391 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
4392 );
4393
4394 it.effect("refuses to fork a session while its turn runs", () =>
4395 Effect.gen(function* () {
4396 // The replay fails on a fork request: only the running turn's prompt is expected.
4397 const { runtime, thread } = yield* resumed([
4398 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4399 promptAccepted,
4400 ]);
4401 yield* runtime.startTurn(turnInput(thread));
4402 const refused = yield* runtime
4403 .forkThread({
4404 sourceProviderThread: thread,
4405 targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"),
4406 })
4407 .pipe(Effect.flip);
4408 assert.equal(refused._tag, "ProviderAdapterProtocolError");
4409 }).pipe(Effect.scoped),
4410 );
4411
4412 it.effect("moves a fork into its target thread's worktree before the first prompt", () =>
4413 Effect.gen(function* () {
4414 const FORK = "ses_f1484db83ffeLGtrRCFimo1H0e";
4415 const target = "/work/opencode2-fork-target";
4416 const { runtime, thread } = yield* resumed([
4417 out("session.fork", { sessionID: SESSION }),
4418 replyData("session.fork", sessionInfo({ id: FORK })),
4419 // The fork's T3 MCP server is the target thread's.
4420 out("session.update", {
4421 sessionID: FORK,
4422 permissions: [
4423 { action: "*", resource: "*", effect: "allow" },
4424 { action: "t3-code-*", resource: "*", effect: "deny" },
4425 { action: "t3-code-thread_opencode2-adapter_fork_*", resource: "*", effect: "allow" },
4426 ],
4427 }),
4428 reply("session.update", null),
4429 // OpenCode makes the fork where its source runs; the target thread runs elsewhere.
4430 out("session.move", { sessionID: FORK, directory: target }),
4431 reply("session.move", null),
4432 ]);
4433 const forked = yield* runtime.forkThread({
4434 sourceProviderThread: thread,
4435 targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"),
4436 runtimePolicy: { ...policy(), cwd: target },
4437 });
4438 assert.equal(forked.nativeThreadRef?.nativeId, FORK);
4439 }).pipe(Effect.scoped),
4440 );
4441
4442 /**
4443 * A turn launches a background subagent; its report wakes the session into
4444 * a continuation turn. Then `after` runs (a fork or a rollback to the first
4445 * turn), which must cut before the report the continuation answers.
4446 */
4447 const continued = (after: ReadonlyArray<ProviderReplayEntry>) =>
4448 Effect.gen(function* () {
4449 const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = [];
4450 const { runtime, thread } = yield* resumed([
4451 ...backgroundLaunch(CHILD),
4452 event("session.execution.succeeded", { sessionID: SESSION }),
4453 event("session.execution.succeeded", { sessionID: CHILD }),
4454 event("session.inbox.enqueued", {
4455 inboxID: "msg_report",
4456 sessionID: SESSION,
4457 item: {
4458 type: "synthetic",
4459 payload: {
4460 text: CONTINUED_REPORT,
4461 description: "Sleep",
4462 metadata: {
4463 source: "subagent",
4464 childID: CHILD,
4465 agent: "General",
4466 state: "completed",
4467 },
4468 },
4469 delivery: "steer",
4470 },
4471 }),
4472 event("session.execution.started", { sessionID: SESSION }),
4473 event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report" }),
4474 event("session.text.ended", {
4475 sessionID: SESSION,
4476 assistantMessageID: "msg_followup",
4477 ordinal: 0,
4478 text: "CHILD_OK",
4479 }),
4480 event("session.execution.succeeded", { sessionID: SESSION }),
4481 ...after,
4482 ]).pipe(
4483 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
4484 offer: (request) => Effect.sync(() => void offers.push(request)),
4485 take: Effect.never,
4486 }),
4487 );
4488 const turns: Array<OrchestrationV2ProviderTurn> = [];
4489 const bothEnded = yield* Deferred.make<void>();
4490 yield* runtime.events.pipe(
4491 Stream.tap((event) =>
4492 Effect.gen(function* () {
4493 if (event.type === "provider_turn.updated" && event.providerTurn.status !== "running") {
4494 turns.push(event.providerTurn);
4495 if (turns.length === 2) yield* Deferred.succeed(bothEnded, undefined);
4496 }
4497 }),
4498 ),
4499 Stream.runDrain,
4500 Effect.forkScoped,
4501 );
4502 yield* runtime.startTurn(withLineage(thread));
4503 yield* Effect.gen(function* () {
4504 while (offers.length === 0) yield* Effect.yieldNow;
4505 }).pipe(Effect.timeout("2 seconds"), Effect.orDie);
4506 yield* runtime.startTurn(continuationTurn(thread, offers[0]!));
4507 yield* Deferred.await(bothEnded);
4508 const [first, second] = turns;
4509 return { runtime, thread, first: first!, second: second! };
4510 });
4511 const CONTINUED_PROMPT = `msg_t3_turn_${SESSION}:attempt:attempt:opencode2-adapter`;
4512 const CONTINUED_REPORT = `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`;
4513 const continuedHistory = {
4514 data: [
4515 { id: "msg_report", time: { created: 2 }, text: CONTINUED_REPORT, type: "synthetic" },
4516 { id: CONTINUED_PROMPT, time: { created: 1 }, text: "hi", type: "user" },
4517 ],
4518 cursor: {},
4519 };
4520
4521 it.effect("forks before a continuation's report, so the fork leaves out its answer", () =>
4522 Effect.gen(function* () {
4523 const FORK = "ses_f1484db83ffeLGtrRCFimo1H0e";
4524 const { runtime, thread, first, second } = yield* continued([
4525 out("message.list", "<any>"),
4526 reply("message.list", continuedHistory),
4527 // The replay fails on a fork without this cut.
4528 out("session.fork", { sessionID: SESSION, before: "msg_report" }),
4529 replyData("session.fork", sessionInfo({ id: FORK })),
4530 out("session.update", { sessionID: FORK, permissions: "<any>" }),
4531 reply("session.update", null),
4532 ]);
4533 const forked = yield* runtime.forkThread({
4534 sourceProviderThread: thread,
4535 targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"),
4536 providerTurnId: first.id,
4537 sourceProviderTurns: [first, second],
4538 });
4539 assert.equal(forked.nativeThreadRef?.nativeId, FORK);
4540 }).pipe(Effect.scoped),
4541 );
4542
4543 it.effect("rolls back before a continuation's report, so its answer leaves the history", () =>
4544 Effect.gen(function* () {
4545 const { runtime, thread, first, second } = yield* continued([
4546 out("message.list", "<any>"),
4547 reply("message.list", continuedHistory),
4548 // The replay fails on a rollback that makes no cut here.
4549 out("session.revert.stage", { sessionID: SESSION, messageID: "msg_report", files: false }),
4550 replyData("session.revert.stage", { messageID: "msg_report", files: [] }),
4551 out("session.revert.commit", { sessionID: SESSION }),
4552 reply("session.revert.commit", null),
4553 out("message.list", "<any>"),
4554 reply("message.list", { data: [continuedHistory.data[1]], cursor: {} }),
4555 ]);
4556 const snapshot = yield* runtime.rollbackThread({
4557 providerThread: thread,
4558 target: {
4559 type: "provider_turn",
4560 checkpointId: CheckpointId.make("checkpoint:first"),
4561 appRunOrdinal: 1,
4562 providerTurn: first,
4563 },
4564 providerThreadTurns: [first, second],
4565 });
4566 assert.deepEqual(
4567 snapshot.providerTurns.map((turn) => turn.id),
4568 [first.id],
4569 );
4570 }).pipe(Effect.scoped),
4571 );
4572
4573 it.effect("keeps a subagent a stopped background reply started off the next turn", () =>
4574 Effect.gen(function* () {
4575 const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB";
4576 const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD";
4577 const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN";
4578 const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" };
4579 const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" };
4580 const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = [];
4581 const gate = makeProviderReplayGate(["settled"]);
4582 const { runtime, thread } = yield* resumed(
4583 [
4584 ...backgroundLaunch(CHILD),
4585 // The same turn starts a second background subagent, which runs on.
4586 event("session.tool.input.started", { ...toolB, name: "subagent" }),
4587 event("session.tool.called", {
4588 ...toolB,
4589 name: "subagent",
4590 input: { description: "Sleep", prompt: "sleep", background: true },
4591 executed: false,
4592 }),
4593 event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }),
4594 event("session.tool.progress", {
4595 ...toolB,
4596 metadata: { sessionID: CHILD_B, status: "running" },
4597 }),
4598 event("session.execution.succeeded", { sessionID: SESSION }),
4599 event("session.execution.started", { sessionID: CHILD_B }),
4600 // The first subagent's report wakes the parent into a reply T3 holds
4601 // for its continuation.
4602 event("session.execution.succeeded", { sessionID: CHILD }),
4603 event("session.inbox.enqueued", {
4604 inboxID: "msg_report",
4605 sessionID: SESSION,
4606 item: {
4607 type: "synthetic",
4608 payload: {
4609 text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`,
4610 description: "Sleep",
4611 metadata: {
4612 source: "subagent",
4613 childID: CHILD,
4614 agent: "General",
4615 state: "completed",
4616 },
4617 },
4618 delivery: "steer",
4619 },
4620 }),
4621 event("session.execution.started", { sessionID: SESSION }),
4622 // The reply starts a background subagent of its own, named only by
4623 // the reply's held events.
4624 event("session.tool.input.started", { ...spawn, name: "subagent" }),
4625 event("session.tool.called", {
4626 ...spawn,
4627 name: "subagent",
4628 input: { description: "Sleep", prompt: "sleep", background: true },
4629 executed: false,
4630 }),
4631 event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }),
4632 event("session.tool.progress", {
4633 ...spawn,
4634 metadata: { sessionID: SPAWNED, status: "running" },
4635 }),
4636 event("session.execution.succeeded", { sessionID: SESSION }),
4637 // The second subagent's text, shown live, marks that the reply has ended.
4638 event("session.text.ended", {
4639 sessionID: CHILD_B,
4640 assistantMessageID: "msg_child_b",
4641 ordinal: 0,
4642 text: "B_WORKING",
4643 }),
4644 // The user's Stop stops the second subagent and drops the held reply.
4645 out("session.interrupt", { sessionID: CHILD_B }),
4646 reply("session.interrupt", { interrupted: true }),
4647 event("session.execution.interrupted", { sessionID: CHILD_B }),
4648 // The subagent only the dropped reply named is stopped too; that Stop
4649 // fails, so it runs on.
4650 out("session.interrupt", { sessionID: SPAWNED }),
4651 reply("session.interrupt", {
4652 status: 500,
4653 body: { _tag: "UnknownError", message: "interrupt failed" },
4654 }),
4655 // An unrelated prompt runs while that subagent, and a subagent it
4656 // starts, ask for permissions, which no turn shows: they are refused.
4657 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4658 promptAccepted,
4659 event("session.execution.started", { sessionID: SESSION }),
4660 event("permission.asked", { ...shellAsk.data, id: "per_spawned", sessionID: SPAWNED }),
4661 out("permission.reply", {
4662 sessionID: SPAWNED,
4663 requestID: "per_spawned",
4664 decision: "reject",
4665 }),
4666 reply("permission.reply", null),
4667 event("session.created", {
4668 ...childCreated(NESTED),
4669 sessionID: NESTED,
4670 parentID: SPAWNED,
4671 }),
4672 event("permission.asked", { ...shellAsk.data, id: "per_nested", sessionID: NESTED }),
4673 out("permission.reply", {
4674 sessionID: NESTED,
4675 requestID: "per_nested",
4676 decision: "reject",
4677 }),
4678 reply("permission.reply", null),
4679 event("session.text.ended", {
4680 sessionID: SESSION,
4681 assistantMessageID: "msg_unrelated",
4682 ordinal: 0,
4683 text: "UNRELATED",
4684 }),
4685 event("session.execution.succeeded", { sessionID: SESSION }),
4686 // The next Stop tries the subagent again, and the one it started, and reaches both.
4687 out("session.interrupt", { sessionID: SPAWNED }),
4688 reply("session.interrupt", { interrupted: true }),
4689 out("session.interrupt", { sessionID: NESTED }),
4690 reply("session.interrupt", { interrupted: true }),
4691 // OpenCode reports the stopped subagent to the thread's session and
4692 // wakes it; that execution answers no turn, so it is stopped and no
4693 // continuation is offered for it.
4694 event("session.execution.interrupted", { sessionID: SPAWNED }),
4695 event("session.inbox.enqueued", {
4696 inboxID: "msg_report_spawned",
4697 sessionID: SESSION,
4698 item: {
4699 type: "synthetic",
4700 payload: {
4701 text: `<subagent sessionID="${SPAWNED}" state="cancelled" description="Sleep">\n</subagent>`,
4702 description: "Sleep",
4703 metadata: {
4704 source: "subagent",
4705 childID: SPAWNED,
4706 agent: "General",
4707 state: "cancelled",
4708 },
4709 },
4710 delivery: "steer",
4711 },
4712 }),
4713 event("session.execution.started", { sessionID: SESSION }),
4714 out("session.interrupt", { sessionID: SESSION }),
4715 reply("session.interrupt", { interrupted: true }),
4716 event("session.execution.interrupted", { sessionID: SESSION }, "settled"),
4717 ],
4718 { replayGate: gate },
4719 ).pipe(
4720 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
4721 offer: (request) => Effect.sync(() => void offers.push(request)),
4722 take: Effect.never,
4723 }),
4724 );
4725 const collected: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
4726 const replyEnded = yield* Deferred.make<void>();
4727 const bothEnded = yield* Deferred.make<void>();
4728 yield* runtime.events.pipe(
4729 Stream.tap((event) =>
4730 Effect.gen(function* () {
4731 collected.push(event);
4732 if (event.type === "turn_item.updated" && event.turnItem.type === "assistant_message") {
4733 if (event.turnItem.text === "B_WORKING")
4734 yield* Deferred.succeed(replyEnded, undefined);
4735 }
4736 if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) {
4737 yield* Deferred.succeed(bothEnded, undefined);
4738 }
4739 }),
4740 ),
4741 Stream.runDrain,
4742 Effect.forkScoped,
4743 );
4744 yield* runtime.startTurn(withLineage(thread));
4745 yield* Deferred.await(replyEnded);
4746 assert.lengthOf(offers, 1);
4747 // The user stops the thread before the reply's continuation runs.
4748 yield* runtime.interruptTurn({
4749 providerThread: thread,
4750 providerTurnId: yield* providerTurnId,
4751 requestRuntimeRestart: true,
4752 });
4753 yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread });
4754 yield* Deferred.await(bothEnded);
4755 // A later Stop reaches both subagents; once stopped they are not tried
4756 // again (the replay fails on any further request).
4757 for (let stops = 0; stops < 2; stops++) {
4758 yield* runtime.interruptTurn({
4759 providerThread: thread,
4760 providerTurnId: yield* providerTurnId,
4761 requestRuntimeRestart: true,
4762 });
4763 }
4764 // The stopped subagent's report wakes nothing T3 runs.
4765 yield* Effect.promise(() => gate.waitForReached("settled"));
4766 gate.release("settled");
4767 assert.lengthOf(offers, 1);
4768 // The unrelated turn shows no request it never made.
4769 assert.deepEqual(
4770 collected.flatMap((event) =>
4771 event.type === "runtime_request.updated"
4772 ? [`${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`]
4773 : [],
4774 ),
4775 [],
4776 );
4777 }).pipe(Effect.scoped),
4778 );
4779
4780 it.effect(
4781 "keeps every subagent a stopped background reply's subagent started off the next turn",
4782 () =>
4783 Effect.gen(function* () {
4784 const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB";
4785 const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD";
4786 const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN";
4787 const DEEP = "ses_f1485c529ffeEEEEEEEEEEEEEE";
4788 const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" };
4789 const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" };
4790 const { runtime, thread } = yield* resumed([
4791 ...backgroundLaunch(CHILD),
4792 // The same turn starts a second background subagent, which runs on.
4793 event("session.tool.input.started", { ...toolB, name: "subagent" }),
4794 event("session.tool.called", {
4795 ...toolB,
4796 name: "subagent",
4797 input: { description: "Sleep", prompt: "sleep", background: true },
4798 executed: false,
4799 }),
4800 event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }),
4801 event("session.tool.progress", {
4802 ...toolB,
4803 metadata: { sessionID: CHILD_B, status: "running" },
4804 }),
4805 event("session.execution.succeeded", { sessionID: SESSION }),
4806 event("session.execution.started", { sessionID: CHILD_B }),
4807 // The first subagent's report wakes the parent into a reply T3 holds.
4808 event("session.execution.succeeded", { sessionID: CHILD }),
4809 event("session.inbox.enqueued", {
4810 inboxID: "msg_report",
4811 sessionID: SESSION,
4812 item: {
4813 type: "synthetic",
4814 payload: {
4815 text: `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`,
4816 description: "Sleep",
4817 metadata: {
4818 source: "subagent",
4819 childID: CHILD,
4820 agent: "General",
4821 state: "completed",
4822 },
4823 },
4824 delivery: "steer",
4825 },
4826 }),
4827 event("session.execution.started", { sessionID: SESSION }),
4828 // The held reply starts a subagent, which starts one, which starts another.
4829 event("session.tool.input.started", { ...spawn, name: "subagent" }),
4830 event("session.tool.called", {
4831 ...spawn,
4832 name: "subagent",
4833 input: { description: "Sleep", prompt: "sleep", background: true },
4834 executed: false,
4835 }),
4836 event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }),
4837 event("session.tool.progress", {
4838 ...spawn,
4839 metadata: { sessionID: SPAWNED, status: "running" },
4840 }),
4841 event("session.created", {
4842 ...childCreated(NESTED),
4843 sessionID: NESTED,
4844 parentID: SPAWNED,
4845 }),
4846 event("session.created", { ...childCreated(DEEP), sessionID: DEEP, parentID: NESTED }),
4847 event("session.execution.succeeded", { sessionID: SESSION }),
4848 // The second subagent's text, shown live, marks that the reply has ended.
4849 event("session.text.ended", {
4850 sessionID: CHILD_B,
4851 assistantMessageID: "msg_child_b",
4852 ordinal: 0,
4853 text: "B_WORKING",
4854 }),
4855 // The user's Stop stops the second subagent and drops the held reply,
4856 // then each subagent under the reply, the deepest of which runs on.
4857 out("session.interrupt", { sessionID: CHILD_B }),
4858 reply("session.interrupt", { interrupted: true }),
4859 event("session.execution.interrupted", { sessionID: CHILD_B }),
4860 out("session.interrupt", { sessionID: SPAWNED }),
4861 reply("session.interrupt", { interrupted: true }),
4862 out("session.interrupt", { sessionID: NESTED }),
4863 reply("session.interrupt", { interrupted: true }),
4864 out("session.interrupt", { sessionID: DEEP }),
4865 reply("session.interrupt", {
4866 status: 500,
4867 body: { _tag: "UnknownError", message: "interrupt failed" },
4868 }),
4869 // An unrelated prompt runs while the deepest asks for a permission,
4870 // which no turn shows: it is refused.
4871 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
4872 promptAccepted,
4873 event("session.execution.started", { sessionID: SESSION }),
4874 event("permission.asked", { ...shellAsk.data, id: "per_deep", sessionID: DEEP }),
4875 out("permission.reply", { sessionID: DEEP, requestID: "per_deep", decision: "reject" }),
4876 reply("permission.reply", null),
4877 event("session.execution.succeeded", { sessionID: SESSION }),
4878 ]);
4879 const collected: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
4880 const replyEnded = yield* Deferred.make<void>();
4881 const bothEnded = yield* Deferred.make<void>();
4882 yield* runtime.events.pipe(
4883 Stream.tap((event) =>
4884 Effect.gen(function* () {
4885 collected.push(event);
4886 if (
4887 event.type === "turn_item.updated" &&
4888 event.turnItem.type === "assistant_message" &&
4889 event.turnItem.text === "B_WORKING"
4890 ) {
4891 yield* Deferred.succeed(replyEnded, undefined);
4892 }
4893 if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) {
4894 yield* Deferred.succeed(bothEnded, undefined);
4895 }
4896 }),
4897 ),
4898 Stream.runDrain,
4899 Effect.forkScoped,
4900 );
4901 yield* runtime.startTurn(withLineage(thread));
4902 yield* Deferred.await(replyEnded);
4903 yield* runtime.interruptTurn({
4904 providerThread: thread,
4905 providerTurnId: yield* providerTurnId,
4906 requestRuntimeRestart: true,
4907 });
4908 yield* runtime.startTurn({
4909 ...secondTurn(thread),
4910 appThread: withLineage(thread).appThread,
4911 });
4912 yield* Deferred.await(bothEnded);
4913 // The unrelated turn shows no request it never made.
4914 assert.deepEqual(
4915 collected.flatMap((event) =>
4916 event.type === "runtime_request.updated"
4917 ? [
4918 `${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`,
4919 ]
4920 : [],
4921 ),
4922 [],
4923 );
4924 }).pipe(Effect.scoped),
4925 );
4926
4927 it.effect("ends a continuation while a skipped reply's subagent does not answer its Stop", () =>
4928 Effect.gen(function* () {
4929 const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB";
4930 const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD";
4931 const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" };
4932 const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply_a", id: "call-spawn" };
4933 const report = (inboxID: string, child: string) =>
4934 event("session.inbox.enqueued", {
4935 inboxID,
4936 sessionID: SESSION,
4937 item: {
4938 type: "synthetic",
4939 payload: {
4940 text: `<subagent sessionID="${child}" state="completed" description="Sleep">\nOK\n</subagent>`,
4941 description: "Sleep",
4942 metadata: {
4943 source: "subagent",
4944 childID: child,
4945 agent: "General",
4946 state: "completed",
4947 },
4948 },
4949 delivery: "steer",
4950 },
4951 });
4952 const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = [];
4953 const bothOffered = yield* Deferred.make<void>();
4954 const { runtime, thread } = yield* resumed([
4955 ...backgroundLaunch(CHILD),
4956 event("session.tool.input.started", { ...toolB, name: "subagent" }),
4957 event("session.tool.called", {
4958 ...toolB,
4959 name: "subagent",
4960 input: { description: "Sleep", prompt: "sleep", background: true },
4961 executed: false,
4962 }),
4963 event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }),
4964 event("session.tool.progress", {
4965 ...toolB,
4966 metadata: { sessionID: CHILD_B, status: "running" },
4967 }),
4968 event("session.execution.succeeded", { sessionID: SESSION }),
4969 // Reply A starts a subagent of its own, named only by its held events.
4970 event("session.execution.succeeded", { sessionID: CHILD }),
4971 report("msg_report_a", CHILD),
4972 event("session.execution.started", { sessionID: SESSION }),
4973 event("session.tool.input.started", { ...spawn, name: "subagent" }),
4974 event("session.tool.called", {
4975 ...spawn,
4976 name: "subagent",
4977 input: { description: "Sleep", prompt: "sleep", background: true },
4978 executed: false,
4979 }),
4980 event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }),
4981 event("session.execution.succeeded", { sessionID: SESSION }),
4982 event("session.execution.succeeded", { sessionID: CHILD_B }),
4983 report("msg_report_b", CHILD_B),
4984 event("session.execution.started", { sessionID: SESSION }),
4985 event("session.text.ended", {
4986 sessionID: SESSION,
4987 assistantMessageID: "msg_reply_b",
4988 ordinal: 0,
4989 text: "REPLY_B",
4990 }),
4991 event("session.execution.succeeded", { sessionID: SESSION }),
4992 // B's continuation skips reply A and stops its subagent, which never answers.
4993 out("session.interrupt", { sessionID: SPAWNED }),
4994 reply("session.interrupt", "<hang>"),
4995 ]).pipe(
4996 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
4997 offer: (request) =>
4998 Effect.suspend(() =>
4999 offers.push(request) === 2
5000 ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid)
5001 : Effect.void,
5002 ),
5003 take: Effect.never,
5004 }),
5005 );
5006 const ended = yield* terminals(runtime, 2);
5007 yield* runtime.startTurn(withLineage(thread));
5008 yield* Deferred.await(bothOffered);
5009 yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b"));
5010 // B's reply replays and ends its turn while that Stop still waits.
5011 assert.deepEqual(
5012 [...(yield* Fiber.join(ended))].map((terminal) => terminal.status),
5013 ["completed", "completed"],
5014 );
5015 }).pipe(Effect.scoped),
5016 );
5017
5018 it.effect("replays only its own background reply into a continuation turn", () =>
5019 Effect.gen(function* () {
5020 const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB";
5021 const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" };
5022 const report = (inboxID: string, child: string, text: string) =>
5023 event("session.inbox.enqueued", {
5024 inboxID,
5025 sessionID: SESSION,
5026 item: {
5027 type: "synthetic",
5028 payload: {
5029 text: `<subagent sessionID="${child}" state="completed" description="Sleep">\n${text}\n</subagent>`,
5030 description: "Sleep",
5031 metadata: {
5032 source: "subagent",
5033 childID: child,
5034 agent: "General",
5035 state: "completed",
5036 },
5037 },
5038 delivery: "steer",
5039 },
5040 });
5041 const reply = (assistantMessageID: string, text: string) =>
5042 event("session.text.ended", { sessionID: SESSION, assistantMessageID, ordinal: 0, text });
5043 const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = [];
5044 const bothOffered = yield* Deferred.make<void>();
5045 const { runtime, thread } = yield* resumed([
5046 ...backgroundLaunch(CHILD),
5047 // The same turn starts a second background subagent.
5048 event("session.tool.input.started", { ...toolB, name: "subagent" }),
5049 event("session.tool.called", {
5050 ...toolB,
5051 name: "subagent",
5052 input: { description: "Sleep", prompt: "sleep", background: true },
5053 executed: false,
5054 }),
5055 event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }),
5056 event("session.tool.progress", {
5057 ...toolB,
5058 metadata: { sessionID: CHILD_B, status: "running" },
5059 }),
5060 event("session.execution.succeeded", { sessionID: SESSION }),
5061 // Each subagent's report wakes the parent into a reply OpenCode runs on its own.
5062 event("session.execution.succeeded", { sessionID: CHILD }),
5063 report("msg_report_a", CHILD, "A_OK"),
5064 event("session.execution.started", { sessionID: SESSION }),
5065 event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report_a" }),
5066 reply("msg_reply_a", "REPLY_A"),
5067 event("session.execution.succeeded", { sessionID: SESSION }),
5068 event("session.execution.succeeded", { sessionID: CHILD_B }),
5069 report("msg_report_b", CHILD_B, "B_OK"),
5070 event("session.execution.started", { sessionID: SESSION }),
5071 event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report_b" }),
5072 reply("msg_reply_b", "REPLY_B"),
5073 event("session.execution.succeeded", { sessionID: SESSION }),
5074 ]).pipe(
5075 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
5076 offer: (request) =>
5077 Effect.suspend(() =>
5078 offers.push(request) === 2
5079 ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid)
5080 : Effect.void,
5081 ),
5082 take: Effect.never,
5083 }),
5084 );
5085 const collected: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
5086 const ended = yield* Deferred.make<void>();
5087 yield* runtime.events.pipe(
5088 Stream.tap((event) =>
5089 Effect.gen(function* () {
5090 collected.push(event);
5091 if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) {
5092 yield* Deferred.succeed(ended, undefined);
5093 }
5094 }),
5095 ),
5096 Stream.runDrain,
5097 Effect.forkScoped,
5098 );
5099 yield* runtime.startTurn(withLineage(thread));
5100 yield* Deferred.await(bothOffered);
5101 // A's continuation was cancelled before it started; B's runs.
5102 yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b"));
5103 yield* Deferred.await(ended);
5104 const continuationId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
5105 driver: OPENCODE_PROVIDER,
5106 nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter:wake-b`,
5107 });
5108 assert.deepEqual(
5109 collected.flatMap((event) =>
5110 event.type === "turn_item.updated" &&
5111 event.turnItem.type === "assistant_message" &&
5112 event.turnItem.providerTurnId === continuationId
5113 ? [event.turnItem.text]
5114 : [],
5115 ),
5116 ["REPLY_B"],
5117 );
5118 // Nothing is left held for a continuation that will never run.
5119 assert.isFalse(yield* runtime.hasPendingBackgroundWork!);
5120 }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)),
5121 );
5122
5123 it.effect("ends a follow-up turn a steer joined exactly once", () =>
5124 Effect.gen(function* () {
5125 const reportText = `<subagent sessionID="${CHILD}" state="completed" description="Sleep">\nCHILD_OK\n</subagent>`;
5126 const steerId = `msg_t3_steer_${SESSION}:message:opencode2-adapter:steer`;
5127 const offers: Array<ProviderContinuationRequests.ProviderContinuationRequest> = [];
5128 const { runtime, thread } = yield* resumed([
5129 ...backgroundLaunch(CHILD),
5130 event("session.execution.succeeded", { sessionID: SESSION }),
5131 event("session.execution.succeeded", { sessionID: CHILD }),
5132 event("session.inbox.enqueued", {
5133 inboxID: "msg_report",
5134 sessionID: SESSION,
5135 item: {
5136 type: "synthetic",
5137 payload: {
5138 text: reportText,
5139 description: "Sleep",
5140 metadata: {
5141 source: "subagent",
5142 childID: CHILD,
5143 agent: "General",
5144 state: "completed",
5145 },
5146 },
5147 delivery: "steer",
5148 },
5149 }),
5150 // OpenCode starts the follow-up on its own; T3 holds it for its turn.
5151 event("session.execution.started", { sessionID: SESSION }),
5152 event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report" }),
5153 // The user steers into the follow-up turn; its execution reads the steer.
5154 out("session.prompt", {
5155 sessionID: SESSION,
5156 id: steerId,
5157 text: "<any>",
5158 delivery: "steer",
5159 }),
5160 replyData("session.prompt", {
5161 id: steerId,
5162 sessionID: SESSION,
5163 time: { created: 1790656601500 },
5164 type: "user",
5165 payload: { text: "Also say STEERED." },
5166 delivery: "steer",
5167 }),
5168 event("session.inbox.delivered", { sessionID: SESSION, inboxID: steerId }),
5169 event("session.text.ended", {
5170 sessionID: SESSION,
5171 assistantMessageID: "msg_followup",
5172 ordinal: 0,
5173 text: "CHILD_OK STEERED",
5174 }),
5175 event("session.execution.succeeded", { sessionID: SESSION }),
5176 ]).pipe(
5177 Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
5178 offer: (request) => Effect.sync(() => void offers.push(request)),
5179 take: Effect.never,
5180 }),
5181 );
5182 const terminals: Array<string> = [];
5183 const followUpEnded = yield* Deferred.make<void>();
5184 yield* runtime.events.pipe(
5185 Stream.tap((event) =>
5186 Effect.gen(function* () {
5187 if (event.type !== "turn.terminal") return;
5188 terminals.push(event.status);
5189 if (terminals.length === 2) yield* Deferred.succeed(followUpEnded, undefined);
5190 }),
5191 ),
5192 Stream.runDrain,
5193 Effect.forkScoped,
5194 );
5195 yield* runtime.startTurn(withLineage(thread));
5196 // The launching turn ends; then the held follow-up is offered and taken.
5197 yield* Effect.gen(function* () {
5198 while (offers.length === 0) yield* Effect.yieldNow;
5199 }).pipe(Effect.timeout("2 seconds"), Effect.orDie);
5200 const followUp = continuationTurn(thread, offers[0]!);
5201 yield* runtime.startTurn(followUp);
5202 const followUpTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
5203 driver: OPENCODE_PROVIDER,
5204 nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter:wake`,
5205 });
5206 yield* runtime.steerTurn({
5207 threadId,
5208 runId: followUp.runId,
5209 providerThread: thread,
5210 providerTurnId: followUpTurnId,
5211 message: {
5212 ...turnInput(thread).message,
5213 messageId: MessageId.make("message:opencode2-adapter:steer"),
5214 text: "Also say STEERED.",
5215 },
5216 });
5217 yield* Deferred.await(followUpEnded);
5218 // The launching turn and the steered follow-up end once each. The
5219 // replay has no frame after the follow-up's end, so a second end of the
5220 // follow-up (or another continuation) would show here or fail the replay.
5221 yield* Effect.yieldNow;
5222 assert.deepEqual(terminals, ["completed", "completed"]);
5223 assert.lengthOf(offers, 1);
5224 }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)),
5225 );
5226});
5227
5228describe("OpenCode2 adapter server connection", () => {
5229 it.live("returns the server it reconnected to once the session closes", () =>
5230 Effect.gen(function* () {
5231 // A spawned server stops after it has no borrowers for a while, so a
5232 // session must not keep holding the connection it reconnected with.
5233 const borrowers = { current: 0 };
5234 yield* Effect.gen(function* () {
5235 yield* openCode2ReplayRuntime(
5236 [
5237 ...opening,
5238 { type: "runtime_exit", status: "success" },
5239 out("event.subscribe"),
5240 event("server.connected", {}),
5241 ],
5242 { borrowers },
5243 );
5244 // Reconnected: the dropped connection is returned and the new one is held.
5245 yield* Effect.sleep("200 millis");
5246 assert.equal(borrowers.current, 1);
5247 }).pipe(Effect.scoped);
5248 assert.equal(borrowers.current, 0);
5249 }).pipe(Effect.provide(layerThreadServices)),
5250 );
5251});
5252
5253/** The provider turn the adapter derives for `turnInput`'s attempt. */
5254const providerTurnId = Effect.gen(function* () {
5255 const ids = yield* IdAllocator.IdAllocatorV2;
5256 return ids.derive.providerTurn({
5257 driver: OPENCODE_PROVIDER,
5258 nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`,
5259 });
5260}).pipe(Effect.provide(IdAllocator.layer));
5261
5262it.layer(layerThreadServices)("OpenCode reported model variants", (it) => {
5263 it.effect(
5264 "updates reported variants from selected-model and step events without duplicate updates",
5265 () =>
5266 Effect.gen(function* () {
5267 const model = { providerID: "opencode", id: "big-pickle", variant: "thinking" };
5268 const step = {
5269 sessionID: SESSION,
5270 assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z",
5271 agent: "build",
5272 model,
5273 started: 1,
5274 };
5275 const { runtime, thread } = yield* resumed([
5276 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
5277 promptAccepted,
5278 event("session.execution.started", { sessionID: SESSION }),
5279 event("session.model.selected", { sessionID: SESSION, model }),
5280 event("session.step.started", step),
5281 event("session.step.started", { ...step, model: { ...model, variant: "none" } }),
5282 event("session.execution.succeeded", { sessionID: SESSION }),
5283 ]);
5284 const collected = yield* runtime.events.pipe(
5285 Stream.takeUntil((event) => event.type === "turn.terminal"),
5286 Stream.runCollect,
5287 Effect.forkScoped,
5288 );
5289 yield* runtime.startTurn(turnInput(thread));
5290 const updates = (yield* Fiber.join(collected)).filter(
5291 (event) => event.type === "provider_thread.updated",
5292 );
5293 assert.deepEqual(
5294 updates.map(
5295 (event) => event.providerThread.nativeMetadata?.modelSelection?.options?.[0]?.value,
5296 ),
5297 ["default", "thinking", "none", "none"],
5298 );
5299 }).pipe(Effect.scoped),
5300 );
5301});