1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2/**
3 * Runs OpenCode 2 through the whole orchestrator with the real driver: the
4 * driver probes the binary, spawns `opencode serve`, and routes to the 2.x
5 * adapter. One thread reads a file, runs a shell command, and stops another;
6 * a Supervised thread approves one command, declines another, and answers a
7 * question; a plan-mode thread may write only its plan; and a thread steers,
8 * queues, forks and rolls back.
9 *
10 * OPENCODE2_BIN=/path/to/opencode OPENCODE2_LIVE_ROOT=/scratch/dir \
11 * vp test run src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts
12 *
13 * The server runs with isolated HOME and XDG directories on the free
14 * `opencode/big-pickle` model; `OPENCODE2_MODEL` picks another (its provider's
15 * key comes from the test's environment, which the spawned server inherits).
16 * A second run covers plan mode, a workspace command and skill, and `/compact`;
17 * a third a generated title, T3's MCP server (`OPENCODE2_MCP_URL` names a
18 * stand-in one) and a turn cut off by a killed server. Each step waits up to
19 * `OPENCODE2_STEP_WAIT` seconds (120 by default).
20 */
21import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
22import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
23import * as NodeServices from "@effect/platform-node/NodeServices";
24import * as NodeSqlite from "node:sqlite";
25import { assert, it } from "@effect/vitest";
26import {
27 CommandId,
28 EnvironmentId,
29 MessageId,
30 type ModelSelection,
31 type OrchestrationV2Command,
32 type OrchestrationV2ThreadProjection,
33 ProjectId,
34 ProviderDriverKind,
35 ProviderInstanceId,
36 ThreadId,
37} from "@t3tools/contracts";
38import * as Effect from "effect/Effect";
39import * as FileSystem from "effect/FileSystem";
40import * as Layer from "effect/Layer";
41import * as Path from "effect/Path";
42import * as Schema from "effect/Schema";
43import { FetchHttpClient, HttpClient } from "effect/http";
44import { ChildProcess, ChildProcessSpawner } from "effect/process";
45import { describe } from "vite-plus/test";
46
47import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
48import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
49import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
50import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
51import * as ServerConfig from "../config.ts";
52import * as SqlitePersistence from "../persistence/Sqlite.ts";
53import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
54import * as CodexInstallation from "../provider/CodexInstallation.ts";
55import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
56import * as ModelManifest from "../provider/ModelManifest.ts";
57import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
58import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
59import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
60import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
61import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client";
62import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
63import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
64import * as ServerSettings from "../serverSettings.ts";
65import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
66import * as VcsProcess from "../vcs/VcsProcess.ts";
67import * as EffectWorker from "./EffectWorker.ts";
68import * as Orchestrator from "./Orchestrator.ts";
69import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
70import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts";
71import * as RuntimeLayer from "./runtimeLayer.ts";
72import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
73import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
74import * as ProviderContinuationService from "./ProviderContinuationService.ts";
75import * as ThreadManagementService from "./ThreadManagementService.ts";
76import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts";
77
78const binaryPath = process.env.OPENCODE2_BIN;
79const ROOT = process.env.OPENCODE2_LIVE_ROOT ?? "";
80const INSTANCE = ProviderInstanceId.make("opencode");
81const MODEL: ModelSelection = {
82 instanceId: INSTANCE,
83 model: process.env.OPENCODE2_MODEL ?? "opencode/big-pickle",
84};
85// The free model the thread switches to mid-conversation.
86const SWITCHED_MODEL = "opencode/mimo-v2.6-flash-free";
87
88/** The OpenCode servers the driver spawned, newest last, so a test can kill one by its own PID. */
89const spawnedPids: Array<number> = [];
90const layerSpawnedServers = Layer.succeed(
91 OpenCodeServerLedger.OpenCodeServerLedger,
92 OpenCodeServerLedger.OpenCodeServerLedger.of({
93 track: ({ pid }) =>
94 Effect.sync(() => {
95 spawnedPids.push(pid);
96 return Effect.void;
97 }),
98 }),
99);
100
101/**
102 * Credentials for T3's MCP server. `OPENCODE2_MCP_URL` points them at a stand-in
103 * MCP server the run can see called; without it they point nowhere, as in replay.
104 */
105const MCP_URL = process.env.OPENCODE2_MCP_URL ?? "http://127.0.0.1/mcp";
106const layerMcpRegistry = Layer.succeed(
107 McpSessionRegistry.McpSessionRegistry,
108 McpSessionRegistry.McpSessionRegistry.of({
109 issue: ({ threadId, providerInstanceId }) =>
110 Effect.succeed({
111 config: {
112 environmentId: EnvironmentId.make("environment:opencode2-live"),
113 threadId,
114 providerSessionId: `mcp-live:${threadId}`,
115 providerInstanceId,
116 endpoint: MCP_URL,
117 authorizationHeader: `Bearer mcp-live:${threadId}`,
118 browserToolsAvailable: false,
119 },
120 }),
121 resolve: () => Effect.succeed(undefined),
122 touch: () => Effect.void,
123 revokeProviderSession: () => Effect.void,
124 revokeThread: () => Effect.void,
125 revokeAll: Effect.void,
126 }),
127);
128
129const layerPlatformTest = Layer.merge(
130 NodeServices.layer,
131 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
132 resolveLink: () => Effect.die("unused title link"),
133 }),
134);
135const layerServerConfig = ServerConfig.layerTest(`${ROOT}/work`, { prefix: "t3-opencode2-live-" });
136const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(
137 Layer.provide(VcsProcess.layer),
138 Layer.provide(layerServerConfig),
139 Layer.provide(layerPlatformTest),
140);
141// Isolated OpenCode state: the server never touches the developer's own data.
142const layerServerSettings = ServerSettings.layerTest({
143 providerInstances: {
144 [INSTANCE]: {
145 driver: ProviderDriverKind.make("opencode"),
146 enabled: true,
147 environment: [
148 { name: "HOME", value: ROOT },
149 { name: "XDG_CONFIG_HOME", value: `${ROOT}/config` },
150 { name: "XDG_DATA_HOME", value: `${ROOT}/data` },
151 { name: "XDG_STATE_HOME", value: `${ROOT}/state` },
152 { name: "XDG_CACHE_HOME", value: `${ROOT}/cache` },
153 ],
154 // `OPENCODE2_SERVER_URL` connects to an external server instead of spawning one.
155 config: {
156 enabled: true,
157 binaryPath,
158 ...(process.env.OPENCODE2_SERVER_URL === undefined
159 ? {}
160 : {
161 serverUrl: process.env.OPENCODE2_SERVER_URL,
162 serverPassword: process.env.OPENCODE2_SERVER_PASSWORD ?? "",
163 }),
164 },
165 },
166 },
167});
168const layerBackgroundPolicy = BackgroundPolicy.layer.pipe(
169 Layer.provide(Layer.effect(HostPowerMonitor.HostPowerMonitor, HostPowerMonitor.make())),
170 Layer.provide(layerServerSettings),
171);
172const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pipe(
173 Layer.provide(
174 Layer.mergeAll(
175 layerServerConfig.pipe(Layer.provide(layerPlatformTest)),
176 layerServerSettings,
177 ServerSecretStore.layer.pipe(
178 Layer.provide(layerServerConfig),
179 Layer.provide(layerPlatformTest),
180 ),
181 NodeServices.layer,
182 FetchHttpClient.layer,
183 OpenCodeRuntime.layer.pipe(
184 Layer.provide(layerSpawnedServers),
185 Layer.provide(layerPlatformTest),
186 ),
187 Layer.succeed(
188 ProviderEventLoggers.ProviderEventLoggers,
189 ProviderEventLoggers.NoOpProviderEventLoggers,
190 ),
191 ProviderLatestVersions.layer,
192 ModelManifest.layerTest,
193 AntigravityInstallation.AntigravityInstallation.layer.pipe(
194 Layer.provide(layerServerConfig.pipe(Layer.provide(layerPlatformTest))),
195 Layer.provide(FetchHttpClient.layer),
196 Layer.provide(layerPlatformTest),
197 ),
198 // The Codex driver now resolves managed ChatGPT installs; these runs never launch Codex.
199 Layer.mock(CodexInstallation.CodexInstallation)({
200 managedDirectory: "unused-managed-installation",
201 }),
202 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
203 getEnvironmentId: Effect.succeed(
204 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
205 ),
206 }),
207 ),
208 ),
209);
210const layerOrchestration = RuntimeLayer.layer.pipe(
211 Layer.provide(ProviderTurnStartServiceTestkit.layer),
212 Layer.provide(layerMcpRegistry),
213 Layer.provide(SqlitePersistence.layerMemory),
214 Layer.provide(CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry))),
215 Layer.provide(layerServerConfig),
216 Layer.provide(layerServerSettings),
217 // Merged, not only provided: the test reads the same instance the orchestrator uses.
218 Layer.provideMerge(layerProviderInstanceRegistry),
219 Layer.provide(ResetCreditCoordinator.layer),
220 Layer.provide(layerBackgroundPolicy),
221 Layer.provide(layerPlatformTest),
222 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
223);
224
225// Starts the continuation run a provider wake asks for, as the production layer does.
226const layerContinuationWorker = ProviderContinuationService.layer.pipe(
227 Layer.provide(
228 Layer.unwrap(
229 Effect.gen(function* () {
230 const orchestrator = yield* Orchestrator.OrchestratorV2;
231 return Layer.mock(ThreadManagementService.ThreadManagementService)({
232 dispatch: orchestrator.dispatch,
233 getThreadRecords: orchestrator.getThreadRecords,
234 getThreadProjection: orchestrator.getThreadProjection,
235 });
236 }),
237 ),
238 ),
239 Layer.provide(IdAllocator.layer),
240 Layer.provide(ProviderContinuationRequests.layer),
241);
242const layerLive = layerContinuationWorker.pipe(Layer.provideMerge(layerOrchestration));
243
244// How long one step may take, in seconds. A free model sometimes takes minutes
245// before its first tool call; `OPENCODE2_STEP_WAIT` raises it for such runs.
246const STEP_WAIT_SECONDS = Number(process.env.OPENCODE2_STEP_WAIT ?? "120");
247
248const settled = (projection: OrchestrationV2ThreadProjection) =>
249 projection.runs.length > 0 &&
250 projection.runs.every(
251 (run) => !["queued", "starting", "running", "waiting"].includes(run.status),
252 );
253
254const waitFor = Effect.fn("OpenCode2Live.waitFor")(function* (
255 threadId: ThreadId,
256 done: (projection: OrchestrationV2ThreadProjection) => boolean,
257) {
258 const orchestrator = yield* Orchestrator.OrchestratorV2;
259 for (let attempt = 0; attempt < STEP_WAIT_SECONDS * 2; attempt += 1) {
260 const projection = yield* orchestrator.getThreadProjection(threadId);
261 if (done(projection)) return projection;
262 yield* Effect.sleep("500 millis");
263 }
264 const last = yield* orchestrator.getThreadProjection(threadId);
265 const items = last.turnItems.map((item) =>
266 item.type === "error" ? `error:${item.failure.message}` : `${item.type}:${item.status}`,
267 );
268 return yield* Effect.die(
269 new Error(
270 `Timed out waiting on OpenCode 2 thread ${threadId}: runs ${last.runs.map((run) => run.status).join(",")}; items ${items.join(",")}`,
271 ),
272 );
273});
274
275const send = Effect.fn("OpenCode2Live.send")(function* (
276 threadId: ThreadId,
277 key: string,
278 text: string,
279 modelSelection: ModelSelection = MODEL,
280) {
281 const orchestrator = yield* Orchestrator.OrchestratorV2;
282 yield* orchestrator.dispatch({
283 type: "message.dispatch",
284 createdBy: "user",
285 creationSource: "web",
286 commandId: CommandId.make(`command:opencode2-live:${key}`),
287 threadId,
288 messageId: MessageId.make(`message:opencode2-live:${key}`),
289 text,
290 attachments: [],
291 modelSelection,
292 dispatchMode: { type: "start_immediately" },
293 });
294});
295
296const AssistantModel = Schema.fromJsonString(
297 Schema.Struct({
298 model: Schema.Struct({ providerID: Schema.String, id: Schema.String }),
299 agent: Schema.optional(Schema.String),
300 }),
301);
302const decodeAssistantModel = Schema.decodeUnknownSync(AssistantModel);
303
304/**
305 * The `provider/model` of each assistant message in a native session, oldest
306 * first, from the spawned server's own database under the isolated XDG root.
307 */
308const assistantModels = (nativeSessionId: string) =>
309 Effect.sync(() => {
310 const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, { readOnly: true });
311 try {
312 return db
313 .prepare(
314 "SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq",
315 )
316 .all(nativeSessionId)
317 .map((row) => decodeAssistantModel(row.data).model)
318 .map((model) => `${model.providerID}/${model.id}`);
319 } finally {
320 db.close();
321 }
322 });
323
324/** The agent that wrote each assistant message in a native session, oldest first. */
325const assistantAgents = (nativeSessionId: string) =>
326 Effect.sync(() => {
327 const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, { readOnly: true });
328 try {
329 return db
330 .prepare(
331 "SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq",
332 )
333 .all(nativeSessionId)
334 .map((row) => decodeAssistantModel(row.data).agent);
335 } finally {
336 db.close();
337 }
338 });
339
340describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchestrator", () => {
341 it.live(
342 "runs a tool turn and stops a running shell command through the real driver",
343 () =>
344 Effect.gen(function* () {
345 const fs = yield* FileSystem.FileSystem;
346 const path = yield* Path.Path;
347 yield* fs.writeFileString(path.join(ROOT, "work", "hello.txt"), "hello from t3 live\n");
348 yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
349
350 // A status check starts a fresh server, which lists no models for its
351 // first few hundred milliseconds; the picker must still get them.
352 const instance =
353 yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE);
354 assert.isDefined(instance);
355 const status = yield* instance!.snapshot.refresh;
356 assert.equal(status.status, "ready");
357 assert.include(
358 status.models.map((model) => model.slug),
359 "opencode/big-pickle",
360 );
361 const orchestrator = yield* Orchestrator.OrchestratorV2;
362 const threadId = ThreadId.make("thread:opencode2-live");
363 yield* orchestrator.dispatch({
364 type: "thread.create",
365 createdBy: "user",
366 creationSource: "web",
367 commandId: CommandId.make("command:opencode2-live:create"),
368 threadId,
369 projectId: ProjectId.make("project:opencode2-live"),
370 title: "OpenCode 2 live",
371 modelSelection: MODEL,
372 runtimeMode: "full-access",
373 interactionMode: "default",
374 branch: null,
375 worktreePath: `${ROOT}/work`,
376 });
377
378 yield* send(
379 threadId,
380 "tools",
381 // The shell call outlasts the spawned server's 30 second idle timeout.
382 "Use the read tool to read hello.txt, then run the shell command `sleep 35 && echo TOOL_OK` with the shell tool in the foreground (not in the background) and wait for it to finish, then reply DONE.",
383 );
384 const first = yield* waitFor(threadId, settled);
385 assert.deepEqual(
386 first.runs.map((run) => run.status),
387 ["completed"],
388 );
389 const shell = first.turnItems.find((item) => item.type === "command_execution");
390 assert.deepInclude(shell, { status: "completed", exitCode: 0 });
391 assert.include(shell?.type === "command_execution" ? shell.output : "", "TOOL_OK");
392 assert.isDefined(
393 first.turnItems.find((item) => item.type === "dynamic_tool" && item.toolName === "read"),
394 );
395 assert.isAbove(first.providerTurns[0]?.tokenUsage?.maxTokens ?? 0, 0);
396
397 yield* send(
398 threadId,
399 "stop",
400 "Run the shell command `sleep 60 && echo LATE` with the shell tool in the foreground (not in the background) and wait for it to finish, then reply DONE.",
401 );
402 const running = yield* waitFor(threadId, (projection) =>
403 projection.turnItems.some(
404 (item) =>
405 item.type === "command_execution" &&
406 item.status === "running" &&
407 item.input.includes("sleep 60"),
408 ),
409 );
410 const secondRun = running.runs.at(-1)!;
411 yield* orchestrator.dispatch({
412 type: "run.interrupt",
413 commandId: CommandId.make("command:opencode2-live:interrupt"),
414 threadId,
415 runId: secondRun.id,
416 });
417 const stopped = yield* waitFor(threadId, settled);
418 assert.deepEqual(
419 stopped.runs.map((run) => run.status),
420 ["completed", "interrupted"],
421 );
422 const sleep = stopped.turnItems.find(
423 (item) => item.type === "command_execution" && item.input.includes("sleep 60"),
424 );
425 assert.equal(sleep?.status, "interrupted");
426
427 // A model change applies to the same native session on the next turn.
428 const switched: ModelSelection = { instanceId: INSTANCE, model: SWITCHED_MODEL };
429 yield* send(threadId, "switch", "Reply with exactly: SWITCHED", switched);
430 const third = yield* waitFor(
431 threadId,
432 (projection) => projection.runs.length === 3 && settled(projection),
433 );
434 assert.equal(third.runs.at(-1)?.status, "completed");
435 const sessionId = third.providerThreads[0]?.nativeThreadRef?.nativeId;
436 assert.isDefined(sessionId);
437 const models = yield* assistantModels(sessionId!);
438 assert.equal(models.at(-1), SWITCHED_MODEL);
439 assert.notEqual(models[0], SWITCHED_MODEL);
440 }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped),
441 360_000,
442 );
443
444 it.live(
445 "asks before each shell command in Supervised, runs the approved one, skips the declined one, and answers a question",
446 () =>
447 Effect.gen(function* () {
448 const fs = yield* FileSystem.FileSystem;
449 const path = yield* Path.Path;
450 yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
451 const orchestrator = yield* Orchestrator.OrchestratorV2;
452 const threadId = ThreadId.make("thread:opencode2-live-supervised");
453 yield* orchestrator.dispatch({
454 type: "thread.create",
455 createdBy: "user",
456 creationSource: "web",
457 commandId: CommandId.make("command:opencode2-live:supervised-create"),
458 threadId,
459 projectId: ProjectId.make("project:opencode2-live"),
460 title: "OpenCode 2 live supervised",
461 modelSelection: MODEL,
462 runtimeMode: "approval-required",
463 interactionMode: "default",
464 branch: null,
465 worktreePath: `${ROOT}/work`,
466 });
467 const pendingAsk = (projection: OrchestrationV2ThreadProjection) =>
468 projection.runtimeRequests.find((request) => request.status === "pending");
469 const answer = (
470 key: string,
471 request: OrchestrationV2ThreadProjection["runtimeRequests"][number],
472 response: { decision: "accept" | "decline" } | { answers: Record<string, string> },
473 ) =>
474 orchestrator.dispatch({
475 type: "runtime-request.respond",
476 commandId: CommandId.make(`command:opencode2-live:${key}`),
477 threadId,
478 requestId: request.id,
479 ...response,
480 });
481
482 yield* send(
483 threadId,
484 "approve",
485 "Run the shell command `touch approved.txt` with the shell tool, then reply DONE.",
486 );
487 const asked = yield* waitFor(
488 threadId,
489 (projection) => pendingAsk(projection) !== undefined,
490 );
491 assert.equal(pendingAsk(asked)?.kind, "command");
492 yield* answer("approve-answer", pendingAsk(asked)!, { decision: "accept" });
493 const approved = yield* waitFor(threadId, settled);
494 assert.equal(approved.runs.at(-1)?.status, "completed");
495 assert.isTrue(yield* fs.exists(path.join(ROOT, "work", "approved.txt")));
496
497 yield* send(
498 threadId,
499 "decline",
500 "Run the shell command `touch declined.txt` with the shell tool, then reply DONE.",
501 );
502 const second = yield* waitFor(
503 threadId,
504 (projection) => projection.runs.length === 2 && pendingAsk(projection) !== undefined,
505 );
506 yield* answer("decline-answer", pendingAsk(second)!, { decision: "decline" });
507 // The model may try again; every retry is declined too.
508 let declined = yield* waitFor(
509 threadId,
510 (projection) =>
511 (projection.runs.length === 2 && settled(projection)) ||
512 pendingAsk(projection) !== undefined,
513 );
514 for (let retry = 0; pendingAsk(declined) !== undefined && retry < 3; retry += 1) {
515 yield* answer(`decline-retry-${retry}`, pendingAsk(declined)!, { decision: "decline" });
516 declined = yield* waitFor(
517 threadId,
518 (projection) =>
519 (projection.runs.length === 2 && settled(projection)) ||
520 pendingAsk(projection) !== undefined,
521 );
522 }
523 assert.equal(declined.runs.at(-1)?.status, "completed");
524 assert.isFalse(yield* fs.exists(path.join(ROOT, "work", "declined.txt")));
525
526 yield* send(
527 threadId,
528 "question",
529 "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color.",
530 );
531 const questioned = yield* waitFor(
532 threadId,
533 (projection) => projection.runs.length === 3 && pendingAsk(projection) !== undefined,
534 );
535 const question = pendingAsk(questioned)!;
536 assert.equal(question.kind, "user_input");
537 const form = questioned.turnItems.find(
538 (item) => item.type === "user_input_request" && item.requestId === question.id,
539 );
540 const firstQuestion = form?.type === "user_input_request" ? form.questions[0] : undefined;
541 assert.isDefined(firstQuestion);
542 yield* answer("question-answer", question, { answers: { [firstQuestion!.id]: "Blue" } });
543 const answered = yield* waitFor(
544 threadId,
545 (projection) => projection.runs.length === 3 && settled(projection),
546 );
547 assert.equal(answered.runs.at(-1)?.status, "completed");
548 const reply = answered.messages.findLast((message) => message.role === "assistant");
549 assert.match(reply?.text ?? "", /blue/i);
550 }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped),
551 600_000,
552 );
553
554 it.live(
555 "lets plan mode write only the plan agent's plan directory under Full access",
556 () =>
557 Effect.gen(function* () {
558 const fs = yield* FileSystem.FileSystem;
559 const path = yield* Path.Path;
560 yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
561 const orchestrator = yield* Orchestrator.OrchestratorV2;
562 const threadId = ThreadId.make("thread:opencode2-live-plan");
563 yield* orchestrator.dispatch({
564 type: "thread.create",
565 createdBy: "user",
566 creationSource: "web",
567 commandId: CommandId.make("command:opencode2-live:plan-create"),
568 threadId,
569 projectId: ProjectId.make("project:opencode2-live"),
570 title: "OpenCode 2 live plan",
571 modelSelection: MODEL,
572 runtimeMode: "full-access",
573 interactionMode: "plan",
574 branch: null,
575 worktreePath: `${ROOT}/work`,
576 });
577 // The plan agent's directory: `$HOME/.opencode/plan` under the isolated HOME.
578 const planDir = path.join(ROOT, ".opencode", "plan");
579 // The plan agent runs under the session's plan rules: the edit deny
580 // holds for the workspace, and the plan directory stays writable.
581 yield* send(
582 threadId,
583 "plan",
584 `This is a permission test. Do exactly these two tool calls and nothing else: 1) use the write tool to create ${planDir}/probe-plan.md containing PLAN_OK; 2) use the write tool to create plan_write_probe.txt in the current directory containing NO. Then reply with which calls succeeded.`,
585 );
586 const planned = yield* waitFor(threadId, settled);
587 assert.equal(planned.runs.at(-1)?.status, "completed");
588 assert.isFalse(yield* fs.exists(path.join(ROOT, "work", "plan_write_probe.txt")));
589 assert.isTrue(yield* fs.exists(path.join(planDir, "probe-plan.md")));
590 }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped),
591 360_000,
592 );
593
594 it.live(
595 "runs subagents, wakes the thread for a background one, and stops one still running",
596 () =>
597 Effect.gen(function* () {
598 yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
599 const orchestrator = yield* Orchestrator.OrchestratorV2;
600 // One thread per case: a model that just answered one prompt may repeat it.
601 const thread = Effect.fn("OpenCode2Live.subagentThread")(function* (key: string) {
602 const threadId = ThreadId.make(`thread:opencode2-live-subagents-${key}`);
603 yield* orchestrator.dispatch({
604 type: "thread.create",
605 createdBy: "user",
606 creationSource: "web",
607 commandId: CommandId.make(`command:opencode2-live:subagents-${key}-create`),
608 threadId,
609 projectId: ProjectId.make("project:opencode2-live"),
610 title: `OpenCode 2 live subagents ${key}`,
611 modelSelection: MODEL,
612 runtimeMode: "full-access",
613 interactionMode: "default",
614 branch: null,
615 worktreePath: `${ROOT}/work`,
616 });
617 return threadId;
618 });
619 const childOf = (projection: OrchestrationV2ThreadProjection) =>
620 Effect.gen(function* () {
621 const childThreadId = projection.subagents[0]?.childThreadId;
622 assert.isDefined(childThreadId ?? undefined);
623 return yield* orchestrator.getThreadProjection(childThreadId!);
624 });
625
626 // Foreground: the turn waits for the child thread's answer.
627 const fgThread = yield* thread("fg");
628 yield* send(
629 fgThread,
630 "subagents-fg",
631 "Use the subagent tool (foreground, not background) to delegate to the general subagent with the prompt: 'Reply exactly FG_CHILD_OK.' Wait for it, then reply exactly FG_PARENT_OK.",
632 );
633 const foreground = yield* waitFor(fgThread, settled);
634 assert.deepEqual(
635 foreground.runs.map((run) => run.status),
636 ["completed"],
637 );
638 assert.equal(foreground.subagents[0]?.status, "completed");
639 const fgChild = yield* childOf(foreground);
640 assert.isTrue(
641 fgChild.turnItems.some(
642 (item) => item.type === "assistant_message" && item.text.includes("FG_CHILD_OK"),
643 ),
644 );
645
646 // Background: when the child ends after run 1, OpenCode wakes the parent
647 // and T3 opens run 2 for that execution. A child that ends while run 1
648 // still runs has its report delivered into run 1 instead, which then
649 // answers it, and no continuation run opens. Either way the subagent
650 // completes and nothing is left running.
651 const bgThread = yield* thread("bg");
652 yield* send(
653 bgThread,
654 "subagents-bg",
655 "Use the subagent tool with background set to true to delegate to the general subagent with the prompt: 'Run the shell command `sleep 8` with the shell tool, then reply exactly BG_CHILD_OK.' As soon as it is launched, reply exactly BG_LAUNCHED and end your turn without waiting.",
656 );
657 const woke = yield* waitFor(
658 bgThread,
659 (projection) =>
660 settled(projection) &&
661 projection.subagents[0]?.status === "completed" &&
662 !projection.turnItems.some(
663 (item) => item.status === "running" || item.status === "waiting",
664 ),
665 );
666 assert.isTrue(woke.runs.every((run) => run.status === "completed"));
667 const answeredIn = woke.runs.at(-1)!;
668 if (woke.runs.length === 2) {
669 const wakeMessage = woke.messages.find(
670 (message) => message.id === answeredIn.userMessageId,
671 );
672 assert.equal(
673 `${wakeMessage?.createdBy}:${wakeMessage?.creationSource}`,
674 "agent:provider",
675 );
676 } else {
677 assert.lengthOf(woke.runs, 1);
678 }
679 assert.isTrue(
680 woke.turnItems.some(
681 (item) => item.runId === answeredIn.id && item.type === "assistant_message",
682 ),
683 "the parent's answer to the report lands in the run that took it",
684 );
685
686 // Stop while a background child runs: OpenCode keeps a background
687 // child running past a parent Stop, so T3 stops it, and nothing wakes.
688 const stopThread = yield* thread("stop");
689 yield* send(
690 stopThread,
691 "subagents-stop",
692 "Use the subagent tool with background set to true to delegate to the general subagent with the prompt: 'Run the shell command `sleep 60` with the shell tool, then reply exactly LATE.' As soon as it is launched, reply exactly STOP_LAUNCHED and end your turn without waiting.",
693 );
694 const launched = yield* waitFor(
695 stopThread,
696 (projection) =>
697 projection.runs[0]?.status !== "running" &&
698 projection.subagents[0]?.status === "running",
699 );
700 yield* orchestrator.dispatch({
701 type: "run.interrupt",
702 commandId: CommandId.make("command:opencode2-live:subagents-stop-interrupt"),
703 threadId: stopThread,
704 runId: launched.runs[0]!.id,
705 });
706 yield* waitFor(
707 stopThread,
708 (projection) =>
709 settled(projection) &&
710 projection.subagents[0] !== undefined &&
711 projection.subagents[0].status !== "running",
712 );
713 // Still settled once OpenCode has reported the stopped child to its parent.
714 yield* Effect.sleep("5 seconds");
715 const final = yield* orchestrator.getThreadProjection(stopThread);
716 assert.lengthOf(final.runs, 1);
717 assert.equal(final.subagents[0]?.status, "interrupted");
718 assert.isFalse(
719 final.turnItems.some((item) => item.status === "running" || item.status === "waiting"),
720 );
721 const stoppedChild = yield* childOf(final);
722 assert.isFalse(
723 stoppedChild.turnItems.some((item) => item.status === "running"),
724 "the stopped child shows nothing running",
725 );
726 }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped),
727 480_000,
728 );
729
730 it.live(
731 "steers a running turn, queues two messages, forks an earlier turn and rolls back a write",
732 () =>
733 Effect.gen(function* () {
734 const fs = yield* FileSystem.FileSystem;
735 const path = yield* Path.Path;
736 yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
737 const orchestrator = yield* Orchestrator.OrchestratorV2;
738 // A worktree of its own, so rolling back restores files for this thread only.
739 const work = path.join(ROOT, "inbox-work");
740 yield* fs.makeDirectory(work, { recursive: true });
741 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
742 const git = (...args: ReadonlyArray<string>) =>
743 spawner.exitCode(ChildProcess.make("git", ["-C", work, ...args]));
744 yield* git("init", "-q");
745 yield* git(
746 "-c",
747 "user.name=t3",
748 "-c",
749 "user.email=t3@example.com",
750 "commit",
751 "-q",
752 "--allow-empty",
753 "-m",
754 "init",
755 );
756 const threadId = ThreadId.make("thread:opencode2-live-inbox");
757 yield* orchestrator.dispatch({
758 type: "thread.create",
759 createdBy: "user",
760 creationSource: "web",
761 commandId: CommandId.make("command:opencode2-live-inbox:create"),
762 threadId,
763 projectId: ProjectId.make("project:opencode2-live-inbox"),
764 title: "OpenCode 2 live inbox",
765 modelSelection: MODEL,
766 runtimeMode: "full-access",
767 interactionMode: "default",
768 branch: null,
769 worktreePath: work,
770 });
771 const dispatch = (
772 key: string,
773 text: string,
774 dispatchMode: Extract<
775 OrchestrationV2Command,
776 { type: "message.dispatch" }
777 >["dispatchMode"],
778 ) =>
779 orchestrator.dispatch({
780 type: "message.dispatch",
781 createdBy: "user",
782 creationSource: "web",
783 commandId: CommandId.make(`command:opencode2-live-inbox:${key}`),
784 threadId,
785 messageId: MessageId.make(`message:opencode2-live-inbox:${key}`),
786 text,
787 attachments: [],
788 modelSelection: MODEL,
789 dispatchMode,
790 });
791 const assistantText = (projection: OrchestrationV2ThreadProjection, runId: string) =>
792 projection.turnItems
793 .flatMap((item) =>
794 item.type === "assistant_message" && item.runId === runId ? [item.text] : [],
795 )
796 .join("\n");
797
798 // 1. A turn that writes ALPHA, which the rollback keeps.
799 yield* dispatch(
800 "alpha",
801 "Use the write tool to create reverted.txt containing exactly ALPHA, then reply DONE.",
802 { type: "start_immediately" },
803 );
804 const first = yield* waitFor(threadId, settled);
805 assert.equal(first.runs[0]?.status, "completed");
806 assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "ALPHA");
807
808 // 2. A long shell turn: steered while it runs, with two messages queued behind it.
809 yield* dispatch(
810 "shell",
811 "Run the shell command `sleep 15 && echo A` with the shell tool in the foreground (not in the background) and wait for it, then use the write tool to overwrite reverted.txt with exactly BETA, then reply DONE_A.",
812 { type: "start_immediately" },
813 );
814 const running = yield* waitFor(threadId, (projection) =>
815 projection.turnItems.some(
816 (item) => item.type === "command_execution" && item.status === "running",
817 ),
818 );
819 const shellRun = running.runs.at(-1)!;
820 yield* dispatch("steer", "Also include the word STEERED in your final reply.", {
821 type: "steer_active",
822 targetRunId: shellRun.id,
823 });
824 yield* dispatch("queued-b", "Reply with exactly QUEUED_B.", { type: "queue_after_active" });
825 yield* dispatch("queued-c", "Reply with exactly QUEUED_C.", { type: "queue_after_active" });
826 const drained = yield* waitFor(
827 threadId,
828 (projection) => projection.runs.length === 4 && settled(projection),
829 );
830 const [, shell, queuedB, queuedC] = drained.runs;
831 assert.deepEqual(
832 drained.runs.map((run) => run.status),
833 ["completed", "completed", "completed", "completed"],
834 );
835 // The steer joined the running turn: its answer is in that run, one provider turn each.
836 assert.include(assistantText(drained, shell!.id), "STEERED");
837 assert.include(assistantText(drained, queuedB!.id), "QUEUED_B");
838 assert.include(assistantText(drained, queuedC!.id), "QUEUED_C");
839 assert.lengthOf(drained.providerTurns, 4);
840 // Steered natively: a steer user item in the shell run, no restart attempt.
841 assert.deepEqual(
842 drained.turnItems.flatMap((item) =>
843 item.type === "user_message" && item.runId === shell!.id ? [item.inputIntent] : [],
844 ),
845 ["turn_start", "steer"],
846 );
847 assert.lengthOf(
848 drained.attempts.filter((attempt) => attempt.runId === shell!.id),
849 1,
850 );
851 assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "BETA");
852
853 // 3. Fork from the first turn: the fork only knows what came before the shell turn.
854 const forkId = ThreadId.make("thread:opencode2-live-inbox-fork");
855 yield* orchestrator.dispatch({
856 type: "thread.fork",
857 createdBy: "user",
858 creationSource: "web",
859 commandId: CommandId.make("command:opencode2-live-inbox:fork"),
860 sourceThreadId: threadId,
861 targetThreadId: forkId,
862 sourcePoint: { type: "run", runId: first.runs[0]!.id },
863 });
864 // In its own worktree, so the source's rollback below restores files for
865 // the source alone. The fork's session moves there with it.
866 const forkWork = path.join(ROOT, "inbox-fork-work");
867 yield* fs.makeDirectory(forkWork, { recursive: true });
868 yield* orchestrator.dispatch({
869 type: "thread.metadata.update",
870 commandId: CommandId.make("command:opencode2-live-inbox:fork-worktree"),
871 threadId: forkId,
872 worktreePath: forkWork,
873 });
874 yield* orchestrator.dispatch({
875 type: "message.dispatch",
876 createdBy: "user",
877 creationSource: "web",
878 commandId: CommandId.make("command:opencode2-live-inbox:fork-ask"),
879 threadId: forkId,
880 messageId: MessageId.make("message:opencode2-live-inbox:fork-ask"),
881 text: "Did I ask you to reply with QUEUED_B in this conversation? Answer only YES or NO.",
882 attachments: [],
883 modelSelection: MODEL,
884 dispatchMode: { type: "start_immediately" },
885 });
886 const forked = yield* waitFor(forkId, settled);
887 assert.equal(forked.contextTransfers[0]?.resolution?.strategy, "native_fork");
888 assert.equal(forked.runs[0]?.status, "completed");
889 assert.include(assistantText(forked, forked.runs[0]!.id).toUpperCase(), "NO");
890
891 // 4. Roll the source back to the first turn: OpenCode's history drops the
892 // later turns and T3's checkpoint puts ALPHA back.
893 const checkpoint = drained.checkpoints.find(
894 (candidate) => candidate.appRunOrdinal === 1 && candidate.status === "ready",
895 );
896 assert.isDefined(checkpoint);
897 yield* orchestrator.dispatch({
898 type: "checkpoint.rollback",
899 commandId: CommandId.make("command:opencode2-live-inbox:rollback"),
900 threadId,
901 scopeId: checkpoint!.scopeId,
902 checkpointId: checkpoint!.id,
903 });
904 const rolledBack = yield* waitFor(threadId, (projection) =>
905 projection.runs.slice(1).every((run) => run.status === "rolled_back"),
906 );
907 assert.deepEqual(
908 rolledBack.runs.map((run) => run.status),
909 ["completed", "rolled_back", "rolled_back", "rolled_back"],
910 );
911 assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "ALPHA");
912 yield* dispatch(
913 "after",
914 "Did I ask you to reply with QUEUED_B in this conversation? Answer only YES or NO.",
915 { type: "start_immediately" },
916 );
917 const after = yield* waitFor(
918 threadId,
919 (projection) => projection.runs.length === 5 && settled(projection),
920 );
921 assert.include(assistantText(after, after.runs[4]!.id).toUpperCase(), "NO");
922 // OpenCode's own history kept only the first turn before the new one.
923 const sessionId = after.providerThreads[0]?.nativeThreadRef?.nativeId;
924 const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, {
925 readOnly: true,
926 });
927 const userMessages = db
928 .prepare(
929 "SELECT id FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq",
930 )
931 .all(sessionId!)
932 .map((row) => String(row.id));
933 db.close();
934 assert.lengthOf(userMessages, 2);
935 }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped),
936 600_000,
937 );
938
939 it.live(
940 "runs plan mode, a workspace command and skill, and /compact through the real driver",
941 () =>
942 Effect.gen(function* () {
943 const fs = yield* FileSystem.FileSystem;
944 const path = yield* Path.Path;
945 const work = path.join(ROOT, "work");
946 yield* fs.makeDirectory(path.join(work, ".opencode", "command"), { recursive: true });
947 yield* fs.makeDirectory(path.join(work, ".opencode", "skills", "greet"), {
948 recursive: true,
949 });
950 yield* fs.writeFileString(
951 path.join(work, ".opencode", "command", "hello.md"),
952 "---\ndescription: Say hello to the workspace\n---\nReply with exactly: HELLO $ARGUMENTS\n",
953 );
954 yield* fs.writeFileString(
955 path.join(work, ".opencode", "skills", "greet", "SKILL.md"),
956 "---\nname: greet\ndescription: Greets the user with the secret word MANGO.\n---\nWhen this skill is active, begin your reply with the exact word MANGO.\n",
957 );
958 yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
959
960 // The workspace's own command and skill reach the composer's pickers.
961 const instance =
962 yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE);
963 assert.isDefined(instance);
964 const workspace = yield* instance!.snapshotForCwd!(work);
965 assert.include(
966 workspace.skills.map((skill) => skill.name),
967 "greet",
968 );
969 assert.includeMembers(
970 workspace.slashCommands.map((command) => command.name),
971 ["compact", "hello"],
972 );
973
974 const orchestrator = yield* Orchestrator.OrchestratorV2;
975 const threadId = ThreadId.make("thread:opencode2-live-modes");
976 yield* orchestrator.dispatch({
977 type: "thread.create",
978 createdBy: "user",
979 creationSource: "web",
980 commandId: CommandId.make("command:opencode2-live-modes:create"),
981 threadId,
982 projectId: ProjectId.make("project:opencode2-live-modes"),
983 title: "OpenCode 2 live modes",
984 modelSelection: MODEL,
985 runtimeMode: "full-access",
986 interactionMode: "plan",
987 branch: null,
988 worktreePath: work,
989 });
990 const runs = (count: number) => (projection: OrchestrationV2ThreadProjection) =>
991 projection.runs.length === count && settled(projection);
992 const lastReply = (projection: OrchestrationV2ThreadProjection) =>
993 projection.turnItems.findLast((item) => item.type === "assistant_message");
994
995 // Plan mode is OpenCode's plan agent: it plans instead of editing.
996 yield* send(
997 threadId,
998 "modes-plan",
999 "Plan how to add a --verbose flag to a script named cli.js; the file may not exist yet, so plan it from scratch. Do not ask me anything. Present a short plan; do not implement it.",
1000 );
1001 const planned = yield* waitFor(threadId, runs(1));
1002 assert.equal(planned.runs[0]?.status, "completed");
1003 const sessionId = planned.providerThreads[0]?.nativeThreadRef?.nativeId;
1004 assert.isDefined(sessionId);
1005 const planSteps = yield* assistantAgents(sessionId!);
1006 assert.isAbove(planSteps.length, 0);
1007 assert.isTrue(planSteps.every((agent) => agent === "plan"));
1008
1009 yield* orchestrator.dispatch({
1010 type: "thread.interaction-mode.set",
1011 commandId: CommandId.make("command:opencode2-live-modes:default"),
1012 threadId,
1013 interactionMode: "default",
1014 });
1015 yield* send(threadId, "modes-command", "/hello WORLD");
1016 const commanded = yield* waitFor(threadId, runs(2));
1017 assert.equal(commanded.runs[1]?.status, "completed");
1018 assert.include(lastReply(commanded)?.text ?? "", "HELLO WORLD");
1019 assert.equal((yield* assistantAgents(sessionId!)).at(-1), "build");
1020
1021 yield* send(threadId, "modes-skill", "Use $greet to say hi in three words.");
1022 const skilled = yield* waitFor(threadId, runs(3));
1023 assert.equal(skilled.runs[2]?.status, "completed");
1024 assert.include(lastReply(skilled)?.text ?? "", "MANGO");
1025
1026 yield* send(threadId, "modes-compact", "/compact");
1027 const compacted = yield* waitFor(threadId, runs(4));
1028 assert.equal(compacted.runs[3]?.status, "completed");
1029 const compaction = compacted.turnItems.find((item) => item.type === "compaction");
1030 assert.equal(compaction?.status, "completed");
1031 assert.equal(compaction?.runId, compacted.runs[3]?.id);
1032 assert.isAbove(
1033 compaction?.type === "compaction" ? (compaction.summary ?? "").length : 0,
1034 0,
1035 );
1036 }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped),
1037 360_000,
1038 );
1039
1040 it.live(
1041 "generates a title, calls T3's MCP server, and reconciles a turn cut off by a killed server",
1042 () =>
1043 Effect.gen(function* () {
1044 const work = `${ROOT}/work`;
1045 yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
1046 const orchestrator = yield* Orchestrator.OrchestratorV2;
1047 const instance =
1048 yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE);
1049 assert.isDefined(instance);
1050 yield* instance!.snapshot.refresh;
1051
1052 // A thread title, generated in a temporary session on the 2.x server.
1053 const title = yield* instance!.textGeneration.generateThreadTitle({
1054 cwd: work,
1055 message: "fix the login redirect loop after oauth",
1056 modelSelection: MODEL,
1057 });
1058 assert.isAbove(title.title.length, 0);
1059
1060 const threadId = ThreadId.make("thread:opencode2-live-restart");
1061 yield* orchestrator.dispatch({
1062 type: "thread.create",
1063 createdBy: "user",
1064 creationSource: "web",
1065 commandId: CommandId.make("command:opencode2-live-restart:create"),
1066 threadId,
1067 projectId: ProjectId.make("project:opencode2-live-restart"),
1068 title: "OpenCode 2 live restart",
1069 modelSelection: MODEL,
1070 runtimeMode: "full-access",
1071 interactionMode: "default",
1072 branch: null,
1073 worktreePath: work,
1074 });
1075 const runs = (count: number) => (projection: OrchestrationV2ThreadProjection) =>
1076 projection.runs.length === count && settled(projection);
1077
1078 // T3's MCP server for this thread: the stand-in's one tool answers a marker.
1079 if (process.env.OPENCODE2_MCP_URL !== undefined) {
1080 yield* send(
1081 threadId,
1082 "restart-mcp",
1083 "Call the echo_marker tool from the T3 Code MCP server with word 'kiwi', then reply with its exact output and nothing else.",
1084 );
1085 const called = yield* waitFor(threadId, runs(1));
1086 assert.equal(called.runs[0]?.status, "completed");
1087 const reply = called.turnItems.findLast((item) => item.type === "assistant_message");
1088 assert.include(reply?.type === "assistant_message" ? reply.text : "", "MARKER-KIWI-7Q9");
1089 }
1090 const before = (yield* orchestrator.getThreadProjection(threadId)).runs.length;
1091
1092 // The spawned server dies mid-command; T3 restarts it and settles the turn.
1093 yield* send(
1094 threadId,
1095 "restart-killed",
1096 "Run the shell command `sleep 60 && echo LATE` with the shell tool in the foreground and wait for it, then reply DONE.",
1097 );
1098 yield* waitFor(threadId, (projection) =>
1099 projection.turnItems.some(
1100 (item) =>
1101 item.type === "command_execution" &&
1102 item.status === "running" &&
1103 item.input.includes("sleep 60"),
1104 ),
1105 );
1106 const pid = spawnedPids.at(-1);
1107 assert.isDefined(pid);
1108 process.kill(pid!, "SIGKILL");
1109 const reconciled = yield* waitFor(threadId, runs(before + 1));
1110 assert.equal(reconciled.runs.at(-1)?.status, "interrupted");
1111
1112 yield* send(
1113 threadId,
1114 "restart-next",
1115 "What did I last ask you to run? Answer in one short sentence.",
1116 );
1117 const next = yield* waitFor(threadId, runs(before + 2));
1118 assert.equal(next.runs.at(-1)?.status, "completed");
1119 const answer = next.turnItems.findLast((item) => item.type === "assistant_message");
1120 assert.include(answer?.type === "assistant_message" ? answer.text : "", "sleep 60");
1121 assert.lengthOf(next.providerThreads, 1);
1122 }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped),
1123 360_000,
1124 );
1125
1126 // Needs an external server behind a proxy that cuts its event streams on
1127 // `GET <proxy>/__drop`, so only the stream drops while the server stays up.
1128 it.live.runIf(process.env.OPENCODE2_DROP_URL !== undefined)(
1129 "picks a turn back up after an external server's event stream drops mid-turn",
1130 () =>
1131 Effect.gen(function* () {
1132 const work = `${ROOT}/work`;
1133 yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
1134 const orchestrator = yield* Orchestrator.OrchestratorV2;
1135 const threadId = ThreadId.make("thread:opencode2-live-drop");
1136 yield* orchestrator.dispatch({
1137 type: "thread.create",
1138 createdBy: "user",
1139 creationSource: "web",
1140 commandId: CommandId.make("command:opencode2-live-drop:create"),
1141 threadId,
1142 projectId: ProjectId.make("project:opencode2-live-drop"),
1143 title: "OpenCode 2 live drop",
1144 modelSelection: MODEL,
1145 runtimeMode: "full-access",
1146 interactionMode: "default",
1147 branch: null,
1148 worktreePath: work,
1149 });
1150 yield* send(
1151 threadId,
1152 "drop-running",
1153 "Run the shell command `sleep 8 && echo AFTER_DROP` with the shell tool in the foreground and wait for it, then reply with its exact output.",
1154 );
1155 yield* waitFor(threadId, (projection) =>
1156 projection.turnItems.some(
1157 (item) => item.type === "command_execution" && item.status === "running",
1158 ),
1159 );
1160 yield* HttpClient.get(process.env.OPENCODE2_DROP_URL!).pipe(
1161 Effect.provide(FetchHttpClient.layer),
1162 Effect.orDie,
1163 );
1164 // The turn keeps running on the new stream and ends with its reply.
1165 const done = yield* waitFor(threadId, settled);
1166 assert.equal(done.runs[0]?.status, "completed");
1167 const shell = done.turnItems.find((item) => item.type === "command_execution");
1168 assert.equal(shell?.status, "completed");
1169 const reply = done.turnItems.findLast((item) => item.type === "assistant_message");
1170 assert.include(reply?.type === "assistant_message" ? reply.text : "", "AFTER_DROP");
1171 // The thread's T3 MCP server is registered on the external server for now.
1172 const opencode = yield* OpenCode2Client.make.pipe(Effect.provide(FetchHttpClient.layer));
1173 const api = yield* opencode.connect({
1174 baseUrl: process.env.OPENCODE2_SERVER_URL!,
1175 password: process.env.OPENCODE2_SERVER_PASSWORD ?? "",
1176 });
1177 const servers = yield* api.client.mcp.list({ location: { directory: work } });
1178 assert.deepEqual(
1179 servers.data.map((server) => server.name),
1180 [],
1181 "an external server gets no T3 MCP server, as with 1.x",
1182 );
1183 }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped),
1184 360_000,
1185 );
1186});