| 1 | import * 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 | */ |
| 21 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 22 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 23 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 24 | import * as NodeSqlite from "node:sqlite"; |
| 25 | import { assert, it } from "@effect/vitest"; |
| 26 | import { |
| 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"; |
| 38 | import * as Effect from "effect/Effect"; |
| 39 | import * as FileSystem from "effect/FileSystem"; |
| 40 | import * as Layer from "effect/Layer"; |
| 41 | import * as Path from "effect/Path"; |
| 42 | import * as Schema from "effect/Schema"; |
| 43 | import { FetchHttpClient, HttpClient } from "effect/http"; |
| 44 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 45 | import { describe } from "vite-plus/test"; |
| 46 | |
| 47 | import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; |
| 48 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 49 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 50 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; |
| 51 | import * as ServerConfig from "../config.ts"; |
| 52 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 53 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; |
| 54 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; |
| 55 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 56 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 57 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 58 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 59 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 60 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 61 | import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; |
| 62 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; |
| 63 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; |
| 64 | import * as ServerSettings from "../serverSettings.ts"; |
| 65 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 66 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 67 | import * as EffectWorker from "./EffectWorker.ts"; |
| 68 | import * as Orchestrator from "./Orchestrator.ts"; |
| 69 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 70 | import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; |
| 71 | import * as RuntimeLayer from "./runtimeLayer.ts"; |
| 72 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 73 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 74 | import * as ProviderContinuationService from "./ProviderContinuationService.ts"; |
| 75 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 76 | import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; |
| 77 | |
| 78 | const binaryPath = process.env.OPENCODE2_BIN; |
| 79 | const ROOT = process.env.OPENCODE2_LIVE_ROOT ?? ""; |
| 80 | const INSTANCE = ProviderInstanceId.make("opencode"); |
| 81 | const 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. |
| 86 | const 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. */ |
| 89 | const spawnedPids: Array<number> = []; |
| 90 | const 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 | */ |
| 105 | const MCP_URL = process.env.OPENCODE2_MCP_URL ?? "http://127.0.0.1/mcp"; |
| 106 | const 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 | |
| 129 | const layerPlatformTest = Layer.merge( |
| 130 | NodeServices.layer, |
| 131 | Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ |
| 132 | resolveLink: () => Effect.die("unused title link"), |
| 133 | }), |
| 134 | ); |
| 135 | const layerServerConfig = ServerConfig.layerTest(`${ROOT}/work`, { prefix: "t3-opencode2-live-" }); |
| 136 | const 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. |
| 142 | const 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 | }); |
| 168 | const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( |
| 169 | Layer.provide(Layer.effect(HostPowerMonitor.HostPowerMonitor, HostPowerMonitor.make())), |
| 170 | Layer.provide(layerServerSettings), |
| 171 | ); |
| 172 | const 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 | ); |
| 210 | const 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. |
| 226 | const 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 | ); |
| 242 | const 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. |
| 246 | const STEP_WAIT_SECONDS = Number(process.env.OPENCODE2_STEP_WAIT ?? "120"); |
| 247 | |
| 248 | const settled = (projection: OrchestrationV2ThreadProjection) => |
| 249 | projection.runs.length > 0 && |
| 250 | projection.runs.every( |
| 251 | (run) => !["queued", "starting", "running", "waiting"].includes(run.status), |
| 252 | ); |
| 253 | |
| 254 | const 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 | |
| 275 | const 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 | |
| 296 | const AssistantModel = Schema.fromJsonString( |
| 297 | Schema.Struct({ |
| 298 | model: Schema.Struct({ providerID: Schema.String, id: Schema.String }), |
| 299 | agent: Schema.optional(Schema.String), |
| 300 | }), |
| 301 | ); |
| 302 | const 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 | */ |
| 308 | const 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. */ |
| 325 | const 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 | |
| 340 | describe.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 | }); |