authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 18:30:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 18:30:57-07:00
loge62161bc5b53389a462d066836d47d25df0f4134
tree95efd08cde40d42df6f911c664d0a5d0f89576a4
parent456930a09db29efdd3d4a397fe214e9ede2d6975
signature Signed by PGP key B5690EEEBB952194

refactor(provider-opencode): move OpenCode into its own provider package (#17345)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

127 files changed, 20465 insertions(+), 20376 deletions(-)

apps/mobile/package.json+1
...@@ -71,6 +71,7 @@...@@ -71,6 +71,7 @@
71 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",71 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
72 "@t3tools/provider-core": "workspace:*",72 "@t3tools/provider-core": "workspace:*",
73 "@t3tools/provider-muse": "workspace:*",73 "@t3tools/provider-muse": "workspace:*",
74 "@t3tools/provider-opencode": "workspace:*",
74 "@t3tools/provider-pi": "workspace:*",75 "@t3tools/provider-pi": "workspace:*",
75 "@t3tools/shared": "workspace:*",76 "@t3tools/shared": "workspace:*",
76 "@tabler/icons-react-native": "^3.44.0",77 "@tabler/icons-react-native": "^3.44.0",
apps/mobile/src/components/ProviderIcon.tsx-9
...@@ -138,15 +138,6 @@ export function ProviderIcon(props: ProviderIconProps) {...@@ -138,15 +138,6 @@ export function ProviderIcon(props: ProviderIconProps) {
138 );138 );
139 }139 }
140140
141 if (props.provider === "opencode") {
142 return (
143 <Svg width={size} height={size} viewBox="0 0 32 40" fill="none">
144 <Path d="M24 32H8V16H24V32Z" fill={isDarkMode ? "#4B4646" : "#CFCECD"} />
145 <Path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill={isDarkMode ? "#F1ECEC" : "#211E1E"} />
146 </Svg>
147 );
148 }
149
150 // codex (and unknown drivers)141 // codex (and unknown drivers)
151 return (142 return (
152 <Svg width={size} height={size} viewBox="100 100 411 411" fill="none">143 <Svg width={size} height={size} viewBox="100 100 411 411" fill="none">
apps/mobile/src/lib/providerClients.ts+2-1
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import { isProviderDriverKind } from "@t3tools/contracts";1import { isProviderDriverKind } from "@t3tools/contracts";
2import { makeProviderClientRegistry } from "@t3tools/provider-core/client";2import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
3import { museClient } from "@t3tools/provider-muse/client";3import { museClient } from "@t3tools/provider-muse/client";
4import { openCodeClient } from "@t3tools/provider-opencode/client";
4import { piClient } from "@t3tools/provider-pi/client";5import { piClient } from "@t3tools/provider-pi/client";
56
6/** The provider client definitions this mobile build ships. */7/** The provider client definitions this mobile build ships. */
7const providerClients = makeProviderClientRegistry([museClient, piClient]);8const providerClients = makeProviderClientRegistry([museClient, openCodeClient, piClient]);
89
9/** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */10/** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */
10export function getProviderClient(driver: string | null | undefined) {11export function getProviderClient(driver: string | null | undefined) {
apps/server/package.json+1-3
...@@ -38,11 +38,9 @@...@@ -38,11 +38,9 @@
38 "@napi-rs/keyring": "^1.3.0",38 "@napi-rs/keyring": "^1.3.0",
39 "@noble/hashes": "catalog:",39 "@noble/hashes": "catalog:",
40 "@opencode-ai/sdk": "^1.3.15",40 "@opencode-ai/sdk": "^1.3.15",
41 "@opencode/client": "2.0.23",
42 "@opencode/protocol": "2.0.23",
43 "@opencode/schema": "2.0.23",
44 "@t3tools/provider-core": "workspace:*",41 "@t3tools/provider-core": "workspace:*",
45 "@t3tools/provider-muse": "workspace:*",42 "@t3tools/provider-muse": "workspace:*",
43 "@t3tools/provider-opencode": "workspace:*",
46 "@t3tools/provider-pi": "workspace:*",44 "@t3tools/provider-pi": "workspace:*",
47 "diff": "8.0.3",45 "diff": "8.0.3",
48 "effect": "catalog:",46 "effect": "catalog:",
apps/server/src/atomicWrite.test.ts deleted-185
...@@ -1,185 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9
10import { writeFileStringAtomically } from "./atomicWrite.ts";
11
12it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => {
13 it.effect("keeps a symlinked file linked and rewrites its destination", () =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
18 const destination = path.join(root, "dotfiles", "settings.json");
19 const link = path.join(root, "home", "settings.json");
20 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
21 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
22 yield* fs.writeFileString(destination, "before");
23 yield* fs.symlink(destination, link);
24
25 yield* writeFileStringAtomically({ filePath: link, contents: "after" });
26
27 assert.strictEqual(yield* fs.readLink(link), destination);
28 assert.strictEqual(yield* fs.readFileString(destination), "after");
29 }),
30 );
31
32 it.effect("keeps a dangling symlink linked and creates its destination", () =>
33 Effect.gen(function* () {
34 const fs = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
37 const destination = path.join(root, "dotfiles", "settings.json");
38 const link = path.join(root, "home", "settings.json");
39 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
40 yield* fs.symlink(destination, link);
41
42 yield* writeFileStringAtomically({ filePath: link, contents: "fresh" });
43
44 assert.strictEqual(yield* fs.readLink(link), destination);
45 assert.strictEqual(yield* fs.readFileString(destination), "fresh");
46 }),
47 );
48
49 it.effect("fails on a symlink cycle without replacing either link", () =>
50 Effect.gen(function* () {
51 const fs = yield* FileSystem.FileSystem;
52 const path = yield* Path.Path;
53 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
54 const first = path.join(root, "first.json");
55 const second = path.join(root, "second.json");
56 yield* fs.symlink(second, first);
57 yield* fs.symlink(first, second);
58
59 const result = yield* Effect.exit(
60 writeFileStringAtomically({ filePath: first, contents: "after" }),
61 );
62
63 assert.isTrue(Exit.isFailure(result));
64 assert.strictEqual(yield* fs.readLink(first), second);
65 assert.strictEqual(yield* fs.readLink(second), first);
66 }),
67 );
68
69 it.effect("resolves a relative link through a symlinked parent directory", () =>
70 Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
74 const destination = path.join(root, "dotfiles", "config", "settings.json");
75 const linkedState = path.join(root, "dotfiles", "state");
76 const home = path.join(root, "home");
77 const link = path.join(home, "state", "settings.json");
78 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
79 yield* fs.makeDirectory(linkedState, { recursive: true });
80 yield* fs.makeDirectory(home, { recursive: true });
81 yield* fs.symlink(linkedState, path.join(home, "state"));
82 yield* fs.writeFileString(destination, "before");
83 yield* fs.symlink("../config/settings.json", link);
84
85 yield* writeFileStringAtomically({ filePath: link, contents: "after" });
86
87 assert.strictEqual(yield* fs.readLink(link), "../config/settings.json");
88 assert.strictEqual(yield* fs.readFileString(destination), "after");
89 }),
90 );
91
92 it.effect("creates a missing file and its directory", () =>
93 Effect.gen(function* () {
94 const fs = yield* FileSystem.FileSystem;
95 const path = yield* Path.Path;
96 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
97 const filePath = path.join(root, "nested", "settings.json");
98
99 yield* writeFileStringAtomically({ filePath, contents: "fresh" });
100
101 assert.strictEqual(yield* fs.readFileString(filePath), "fresh");
102 }),
103 );
104});
105
106it.effect("surfaces an unreadable link instead of writing over it", () =>
107 Effect.gen(function* () {
108 const readLinkFailure = PlatformError.systemError({
109 _tag: "Unknown",
110 module: "FileSystem",
111 method: "readLink",
112 pathOrDescriptor: "/home/settings.json",
113 });
114
115 const result = yield* Effect.exit(
116 writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }),
117 );
118
119 assert.deepStrictEqual(result, Exit.fail(readLinkFailure));
120 }).pipe(
121 Effect.provide(
122 Layer.mergeAll(
123 Path.layer,
124 FileSystem.layerNoop({
125 readLink: () =>
126 Effect.fail(
127 PlatformError.systemError({
128 _tag: "Unknown",
129 module: "FileSystem",
130 method: "readLink",
131 pathOrDescriptor: "/home/settings.json",
132 }),
133 ),
134 rename: () => Effect.die("an unreadable link must not be replaced"),
135 }),
136 ),
137 ),
138 ),
139);
140
141it.effect("succeeds when the write lands but its temp directory cannot be removed", () =>
142 Effect.gen(function* () {
143 const renamed: Array<string> = [];
144 const removed: Array<string> = [];
145 const fileSystem = FileSystem.layerNoop({
146 // The target does not exist yet, so it is written in place.
147 readLink: (path) =>
148 Effect.fail(
149 PlatformError.systemError({
150 _tag: "NotFound",
151 module: "FileSystem",
152 method: "readLink",
153 pathOrDescriptor: path,
154 }),
155 ),
156 makeDirectory: () => Effect.void,
157 makeTempDirectory: () => Effect.succeed("/home/settings.json.abc123"),
158 writeFileString: () => Effect.void,
159 rename: (_from, to) => Effect.sync(() => void renamed.push(to)),
160 remove: (path) =>
161 Effect.sync(() => void removed.push(path)).pipe(
162 Effect.andThen(
163 Effect.fail(
164 PlatformError.systemError({
165 _tag: "PermissionDenied",
166 module: "FileSystem",
167 method: "remove",
168 pathOrDescriptor: path,
169 }),
170 ),
171 ),
172 ),
173 });
174
175 const result = yield* Effect.exit(
176 writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }).pipe(
177 Effect.provide(Layer.mergeAll(Path.layer, fileSystem)),
178 ),
179 );
180
181 assert.deepStrictEqual(result, Exit.void);
182 assert.deepStrictEqual(renamed, ["/home/settings.json"]);
183 assert.deepStrictEqual(removed, ["/home/settings.json.abc123"]);
184 }),
185);
apps/server/src/atomicWrite.ts deleted-38
...@@ -1,38 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
5
6/**
7 * Replaces a file's contents via a sibling temp file and rename. A symlinked
8 * target is resolved first so the link survives and its destination is
9 * rewritten, since renaming over the link itself would swap it for a regular file.
10 */
11export const writeFileStringAtomically = (input: {
12 readonly filePath: string;
13 readonly contents: string;
14}) =>
15 Effect.scoped(
16 Effect.gen(function* () {
17 const fs = yield* FileSystem.FileSystem;
18 const path = yield* Path.Path;
19 const targetPath = yield* resolveSymlinkTarget(input.filePath);
20 const targetDirectory = path.dirname(targetPath);
21
22 yield* fs.makeDirectory(targetDirectory, { recursive: true });
23 // The temp directory is cleanup, not part of the write: failing to remove
24 // it (a virus scanner holding it on Windows) must not fail a write that
25 // already landed.
26 const tempDirectory = yield* Effect.acquireRelease(
27 fs.makeTempDirectory({
28 directory: targetDirectory,
29 prefix: `${path.basename(targetPath)}.`,
30 }),
31 (directory) => fs.remove(directory, { recursive: true }).pipe(Effect.ignore({ log: true })),
32 );
33 const tempPath = path.join(tempDirectory, "contents.tmp");
34
35 yield* fs.writeFileString(tempPath, input.contents);
36 yield* fs.rename(tempPath, targetPath);
37 }),
38 );
apps/server/src/cli/theme.ts+1-1
...@@ -35,7 +35,7 @@ import * as Path from "effect/Path";...@@ -35,7 +35,7 @@ import * as Path from "effect/Path";
35import * as Schema from "effect/Schema";35import * as Schema from "effect/Schema";
36import { Argument, Command, Flag } from "effect/cli";36import { Argument, Command, Flag } from "effect/cli";
3737
38import { writeFileStringAtomically } from "../atomicWrite.ts";38import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
39import * as ServerConfig from "../config.ts";39import * as ServerConfig from "../config.ts";
40import {40import {
41 MAX_THEME_FILE_BYTES,41 MAX_THEME_FILE_BYTES,
apps/server/src/device/LocalDeviceHost.ts+1-1
...@@ -47,7 +47,7 @@ import { HttpClient } from "effect/http";...@@ -47,7 +47,7 @@ import { HttpClient } from "effect/http";
47import * as ChildProcess from "effect/process/ChildProcess";47import * as ChildProcess from "effect/process/ChildProcess";
48import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";48import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
4949
50import { writeFileStringAtomically } from "../atomicWrite.ts";50import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
51import * as ServerConfig from "../config.ts";51import * as ServerConfig from "../config.ts";
52import * as ProcessRunner from "../processRunner.ts";52import * as ProcessRunner from "../processRunner.ts";
53import * as DeviceHost from "./DeviceHost.ts";53import * as DeviceHost from "./DeviceHost.ts";
apps/server/src/keybindings.ts+1-1
...@@ -41,7 +41,7 @@ import * as Scope from "effect/Scope";...@@ -41,7 +41,7 @@ import * as Scope from "effect/Scope";
41import * as Stream from "effect/Stream";41import * as Stream from "effect/Stream";
42import * as Semaphore from "effect/Semaphore";42import * as Semaphore from "effect/Semaphore";
43import * as ServerConfig from "./config.ts";43import * as ServerConfig from "./config.ts";
44import { writeFileStringAtomically } from "./atomicWrite.ts";44import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
45import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";45import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
46import {46import {
47 DEFAULT_KEYBINDINGS,47 DEFAULT_KEYBINDINGS,
apps/server/src/mcp/OrchestratorMcpService.ts+1-1
...@@ -75,7 +75,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg...@@ -75,7 +75,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg
75import {75import {
76 subagentResultForRun,76 subagentResultForRun,
77 delegatedTaskProgress,77 delegatedTaskProgress,
78} from "../orchestration-v2/SubagentProjection.ts";78} from "@t3tools/provider-core/server/subagentProjection";
79import {79import {
80 DispatchModeLimit,80 DispatchModeLimit,
81 type DispatchModeRefusal,81 type DispatchModeRefusal,
apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts+2-2
...@@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";...@@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
30import * as ModelManifest from "../provider/ModelManifest.ts";30import * as ModelManifest from "../provider/ModelManifest.ts";
31import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";31import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
32import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";32import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
33import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";33import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
34import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";34import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
35import * as ServerSettings from "../serverSettings.ts";35import * as ServerSettings from "../serverSettings.ts";
36import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";36import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
37import * as VcsProcess from "../vcs/VcsProcess.ts";37import * as VcsProcess from "../vcs/VcsProcess.ts";
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+1-1
...@@ -119,7 +119,7 @@ import {...@@ -119,7 +119,7 @@ import {
119 makeSubagentChildThread,119 makeSubagentChildThread,
120 makeSubagentConversationArtifacts,120 makeSubagentConversationArtifacts,
121 subagentThreadTitle,121 subagentThreadTitle,
122} from "../SubagentProjection.ts";122} from "@t3tools/provider-core/server/subagentProjection";
123import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";123import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
124124
125export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const;125export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const;
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+1-1
...@@ -140,7 +140,7 @@ import {...@@ -140,7 +140,7 @@ import {
140 makeSubagentChildThread,140 makeSubagentChildThread,
141 makeSubagentConversationArtifacts,141 makeSubagentConversationArtifacts,
142 subagentThreadTitle,142 subagentThreadTitle,
143} from "../SubagentProjection.ts";143} from "@t3tools/provider-core/server/subagentProjection";
144144
145export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent");145export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent");
146export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const;146export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const;
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+1-1
...@@ -165,7 +165,7 @@ import {...@@ -165,7 +165,7 @@ import {
165 makeSubagentChildThread,165 makeSubagentChildThread,
166 makeSubagentConversationArtifacts,166 makeSubagentConversationArtifacts,
167 subagentThreadTitle,167 subagentThreadTitle,
168} from "../SubagentProjection.ts";168} from "@t3tools/provider-core/server/subagentProjection";
169169
170const CODEX_PROVIDER = ProviderDriverKind.make("codex");170const CODEX_PROVIDER = ProviderDriverKind.make("codex");
171export const CODEX_DRIVER_KIND = CODEX_PROVIDER;171export const CODEX_DRIVER_KIND = CODEX_PROVIDER;
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts+1-1
...@@ -73,7 +73,7 @@ import {...@@ -73,7 +73,7 @@ import {
73 makeSubagentChildThread,73 makeSubagentChildThread,
74 makeSubagentConversationArtifacts,74 makeSubagentConversationArtifacts,
75 subagentThreadTitle,75 subagentThreadTitle,
76} from "../SubagentProjection.ts";76} from "@t3tools/provider-core/server/subagentProjection";
77import * as CursorAgentSdk from "./CursorAgentSdk.ts";77import * as CursorAgentSdk from "./CursorAgentSdk.ts";
78export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts";78export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts";
7979
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+5-2
...@@ -46,8 +46,11 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";...@@ -46,8 +46,11 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
46import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";46import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
47import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests";47import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests";
48import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";48import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
49import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts";49import {
50import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts";50 OPENCODE_2_STILL_STOPPING,
51 OPENCODE_PROVIDER,
52 t3McpServerName,
53} from "@t3tools/provider-opencode/testing";
51import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts";54import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts";
5255
53const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX";56const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX";
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts+10-32
...@@ -20,22 +20,19 @@ import * as HttpClientError from "effect/http/HttpClientError";...@@ -20,22 +20,19 @@ import * as HttpClientError from "effect/http/HttpClientError";
20import * as HttpClientResponse from "effect/http/HttpClientResponse";20import * as HttpClientResponse from "effect/http/HttpClientResponse";
21import * as UrlParams from "effect/http/UrlParams";21import * as UrlParams from "effect/http/UrlParams";
2222
23import * as ServerConfig from "../../config.ts";23import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client";
24import * as OpenCode2Client from "../../provider/opencode2/OpenCode2Client.ts";24import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server";
25import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts";
26import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";25import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
27import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";26import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
28import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate";27import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate";
29import {28import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
30 makeReplayServerConfig,29import { layerTestProviderHost } from "@t3tools/provider-testing/host";
31 type OrchestratorV2ProviderReplayHarness,30import { OPENCODE_PROVIDER } from "@t3tools/provider-opencode/testing";
32} from "../testkit/ProviderReplayHarness.ts";
33import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts";
34import {31import {
35 OpenCodeReplayController,32 OpenCodeReplayController,
36 OpenCodeReplayTranscriptDecodeError,33 OpenCodeReplayTranscriptDecodeError,
37} from "./OpenCodeAdapterV2.testkit.ts";34} from "./OpenCodeAdapterV2.testkit.ts";
38import * as OpenCode2AdapterV2 from "./OpenCode2AdapterV2.ts";35import * as OpenCode2Adapter from "@t3tools/provider-opencode/server/v2/adapter";
3936
40export const OPENCODE2_HTTP_PROTOCOL = "opencode2-http.sse" as const;37export const OPENCODE2_HTTP_PROTOCOL = "opencode2-http.sse" as const;
41const BASE_URL = "http://opencode2.replay";38const BASE_URL = "http://opencode2.replay";
...@@ -265,15 +262,12 @@ const makeReplayAdapter = (...@@ -265,15 +262,12 @@ const makeReplayAdapter = (
265) =>262) =>
266 Effect.gen(function* () {263 Effect.gen(function* () {
267 const server = yield* replayServer(transcript, options);264 const server = yield* replayServer(transcript, options);
268 return yield* OpenCode2AdapterV2.make(ProviderInstanceId.make("opencode")).pipe(265 return yield* OpenCode2Adapter.make(ProviderInstanceId.make("opencode")).pipe(
269 Effect.provideService(OpenCode2Server.OpenCode2Server, server),266 Effect.provideService(OpenCode2Server.OpenCode2Server, server),
270 );267 );
271 });268 });
272269
273const layerReplayServerConfig = (scenario: string) =>270const layerReplayHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer));
274 Layer.effect(ServerConfig.ServerConfig, makeReplayServerConfig(scenario).pipe(Effect.orDie)).pipe(
275 Layer.provide(NodeServices.layer),
276 );
277271
278function layerRegistry(272function layerRegistry(
279 transcript: OpenCode2ReplayTranscript,273 transcript: OpenCode2ReplayTranscript,
...@@ -283,15 +277,7 @@ function layerRegistry(...@@ -283,15 +277,7 @@ function layerRegistry(
283 makeReplayAdapter(transcript, { external: true, ...options }).pipe(277 makeReplayAdapter(transcript, { external: true, ...options }).pipe(
284 Effect.map((adapter) => ProviderAdapterRegistry.layerFromAdapters([adapter])),278 Effect.map((adapter) => ProviderAdapterRegistry.layerFromAdapters([adapter])),
285 ),279 ),
286 ).pipe(280 ).pipe(Layer.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer)));
287 Layer.provide(
288 Layer.mergeAll(
289 layerReplayServerConfig(transcript.scenario),
290 IdAllocator.layer,
291 NodeServices.layer,
292 ),
293 ),
294 );
295}281}
296282
297/**283/**
...@@ -327,15 +313,7 @@ export const openCode2ReplayRuntime = (...@@ -327,15 +313,7 @@ export const openCode2ReplayRuntime = (
327 cwd: "/work/opencode2",313 cwd: "/work/opencode2",
328 },314 },
329 });315 });
330 }).pipe(316 }).pipe(Effect.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer)));
331 Effect.provide(
332 Layer.mergeAll(
333 layerReplayServerConfig("opencode2_adapter"),
334 IdAllocator.layer,
335 NodeServices.layer,
336 ),
337 ),
338 );
339317
340export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<318export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
341 OpenCode2ReplayTranscript,319 OpenCode2ReplayTranscript,
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts deleted-4291
...@@ -1,4291 +0,0 @@
1/**
2 * The OpenCode 2 runtime behind the `opencode` driver. It talks to the
3 * instance's `opencode serve` process through the HTTP client and reads that
4 * server's `/api/event` stream, routed here by session id.
5 *
6 * A turn is one `session.prompt` (or `session.command`, or `session.compact`
7 * for `/compact`); the session's next `session.execution.*` terminal ends it.
8 * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at
9 * the running execution's next step boundary, so it ends with the turn it
10 * joined. Fork and rollback cut the native history before the user message a
11 * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of
12 * session permission rules, plan mode is also OpenCode's `plan` agent
13 * (switched before the prompt like the model), and OpenCode's permission asks
14 * and question forms become runtime requests on the asking session's thread, a
15 * subagent's on its parent's.
16 *
17 * A `subagent` call runs in a child session shown as a subagent thread. A
18 * background one outlives its turn; when it ends, OpenCode starts a parent
19 * execution T3 did not ask for, which waits here for the continuation turn T3
20 * opens for it.
21 *
22 * @module orchestration-v2/Adapters/OpenCode2AdapterV2
23 */
24
25import {
26 AbsolutePath,
27 Agent,
28 Form,
29 Location,
30 Model,
31 Permission,
32 Provider,
33 Session,
34 SessionMessage,
35 Skill,
36 type OpenCodeEvent,
37} from "@opencode/client/effect";
38import { Mcp } from "@opencode/schema/mcp";
39import {
40 isOrchestrationV2WorkActive,
41 type OrchestrationV2AppThread,
42 type OrchestrationV2ConversationMessage,
43 type OrchestrationV2ExecutionNode,
44 type OrchestrationV2ProviderCapabilities,
45 type OrchestrationV2ProviderSession,
46 type OrchestrationV2ProviderThread,
47 type OrchestrationV2ProviderTurn,
48 type OrchestrationV2RuntimeRequest,
49 type OrchestrationV2Subagent,
50 type OrchestrationV2TurnItem,
51 type OrchestrationV2UserInputQuestion,
52 type ModelSelection,
53 type ProviderApprovalDecision,
54 type ProviderInstanceId,
55 type RunId,
56 type RuntimeRequestId,
57} from "@t3tools/contracts";
58import type * as Cause from "effect/Cause";
59import * as DateTime from "effect/DateTime";
60import * as Deferred from "effect/Deferred";
61import * as Crypto from "effect/Crypto";
62import * as Effect from "effect/Effect";
63import * as Hex from "effect/encoding/Hex";
64import * as Exit from "effect/Exit";
65import * as Option from "effect/Option";
66import * as Queue from "effect/Queue";
67import * as Schedule from "effect/Schedule";
68import * as Scope from "effect/Scope";
69import * as Schema from "effect/Schema";
70import * as Semaphore from "effect/Semaphore";
71import * as Stream from "effect/Stream";
72
73import { resolveAttachmentPath } from "../../attachmentStore.ts";
74import * as ServerConfig from "../../config.ts";
75import { paginate, type OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts";
76import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts";
77import {
78 parseOpenCodeModelSlug,
79 type OpenCodeRuntimeError,
80} from "../../provider/opencodeRuntime.ts";
81import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
82import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
83import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions";
84import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
85import * as KeyedLock from "@t3tools/shared/KeyedLock";
86import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
87import { causeErrorTag } from "@t3tools/shared/observability";
88
89import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt";
90import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
91import {
92 backgroundWorkNotification,
93 type BackgroundWorkReport,
94} from "@t3tools/provider-core/server/notification";
95import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
96import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
97import {
98 makeSubagentChildThread,
99 makeSubagentConversationArtifacts,
100 subagentThreadTitle,
101} from "../SubagentProjection.ts";
102import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
103import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
104import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "./OpenCodeAdapterV2.ts";
105import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts";
106
107const OpenCode2ProviderCapabilities = {
108 sessions: {
109 // One server serves every location, so one session runtime owns them all.
110 supportsMultipleProviderThreadsPerSession: true,
111 supportsModelSwitchInSession: true,
112 supportsProviderSwitchingViaHandoff: true,
113 // A mode change rewrites the session's rules when its next turn resumes it.
114 supportsRuntimeModeSwitchInSession: true,
115 pendingRequestsSurviveRestart: false,
116 },
117 threads: {
118 canCreateEmptyThread: true,
119 canReadThreadSnapshot: true,
120 canRollbackThread: true,
121 canForkThread: true,
122 canForkFromTurn: true,
123 canForkFromSubagentThread: false,
124 exposesNativeThreadId: true,
125 },
126 turns: {
127 exposesNativeTurnId: false,
128 emitsTurnStarted: true,
129 emitsTurnCompleted: true,
130 supportsInterrupt: true,
131 supportsActiveSteering: true,
132 supportsSteeringByInterruptRestart: true,
133 // T3 holds queued messages and starts each as its own turn once the one
134 // before it ends, so OpenCode's own `queue` delivery is never used.
135 supportsQueuedMessages: true,
136 terminalStatusQuality: "strong",
137 },
138 streaming: {
139 streamsAssistantText: true,
140 streamsReasoning: true,
141 streamsToolOutput: false,
142 streamsPlanText: false,
143 emitsMessageCompleted: true,
144 },
145 tools: {
146 exposesToolItemIds: true,
147 emitsToolStarted: true,
148 emitsToolCompleted: true,
149 emitsToolOutput: true,
150 supportsMcpTools: false,
151 supportsDynamicToolCallbacks: false,
152 },
153 approvals: {
154 supportsCommandApproval: true,
155 supportsFileReadApproval: true,
156 supportsFileChangeApproval: true,
157 supportsApplyPatchApproval: true,
158 approvalsHaveNativeRequestIds: true,
159 approvalCallbacksAreLiveOnly: true,
160 approvalsCanOriginateFromSubagents: true,
161 },
162 planning: {
163 emitsPlanUpdated: false,
164 emitsTodoList: false,
165 emitsProposedPlan: false,
166 supportsStructuredQuestions: true,
167 planDeltasHaveItemIds: false,
168 },
169 subagents: {
170 supportsSubagents: true,
171 exposesSubagentThreadIds: true,
172 emitsSubagentLifecycle: true,
173 canWaitForSubagents: true,
174 canCloseSubagents: false,
175 canForkSubagentThread: false,
176 },
177 context: {
178 acceptsSystemContext: false,
179 acceptsDeveloperContext: false,
180 acceptsSyntheticUserContext: true,
181 canGenerateSummaries: false,
182 canConsumeHandoffSummaries: true,
183 supportsDeltaHandoff: true,
184 supportsFullThreadHandoff: true,
185 maxRecommendedHandoffChars: null,
186 },
187 checkpointing: {
188 appCanCheckpointFilesystem: true,
189 supportsNestedCheckpointScopes: true,
190 providerCanRollbackConversation: true,
191 providerRollbackReturnsSnapshot: true,
192 providerCanReadConversationSnapshot: true,
193 },
194 identity: {
195 nativeThreadIds: "strong",
196 nativeTurnIds: "weak",
197 nativeItemIds: "strong",
198 nativeRequestIds: "strong",
199 },
200 // OpenCode enforces each runtime mode through the session's permission rules.
201 runtimePolicy: { enforcement: "native" },
202} satisfies OrchestrationV2ProviderCapabilities;
203
204type EventOf<T extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { readonly type: T }>;
205type Tokens = EventOf<"session.step.ended">["data"]["tokens"];
206
207/** What a turn reports against: a run's own turn, or a subagent session's runless one. */
208interface TurnOwner {
209 readonly appThread: OrchestrationV2AppThread;
210 readonly threadId: OrchestrationV2AppThread["id"];
211 readonly runId: RunId | null;
212 readonly runOrdinal: number;
213 readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"];
214 readonly modelSelection: ModelSelection;
215 /** Where the turn runs: a subagent runs in its parent turn's directory. */
216 readonly runtimePolicy: Pick<ProviderAdapter.ProviderAdapterV2RuntimePolicy, "cwd">;
217}
218
219interface ActiveTurn {
220 readonly input: TurnOwner;
221 providerTurn: OrchestrationV2ProviderTurn;
222 /** Open text and reasoning blocks, keyed `<assistantMessageID>:<kind>:<ordinal>`. */
223 readonly texts: Map<string, OpenBlock>;
224 readonly tools: Map<string, { readonly name: string; input: Record<string, unknown> }>;
225 readonly startedAt: Map<string, DateTime.Utc>;
226 readonly ordinals: Map<string, number>;
227 nextOrdinal: number;
228 /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */
229 readonly usage: {
230 input: number;
231 cached: number;
232 cacheWrite: number;
233 output: number;
234 reasoning: number;
235 };
236 steps: number;
237 lastStep: Tokens | undefined;
238 /**
239 * The history item this turn's execution follows, for a turn with no prompt
240 * id of T3's: the session's newest item before a `/name` command (null when
241 * the history was empty), since `session.command` takes no id and answers
242 * without one; the report a continuation turn's execution answers; or, on a
243 * subagent's session, the first item queued for its turn. A reconnect
244 * backfills that turn from everything after this. Other turns backfill from
245 * their own prompt's id (`nativeTurnRef`).
246 */
247 before: string | null | undefined;
248 /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */
249 compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined;
250 compactions: number;
251 interrupted: boolean;
252 /**
253 * Set until the turn's prompt, command or compaction is sent. A Stop before
254 * then has nothing on the server to stop, so it ends the turn here and the
255 * request is never sent.
256 */
257 unsent: boolean;
258 /**
259 * Steers sent into this turn that OpenCode has not delivered yet, by inbox
260 * id. Each one wakes the session, so an execution that ends before reading
261 * one is followed by another that does, and the turn spans both.
262 */
263 readonly steers: Set<string>;
264 /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */
265 readonly settledInbox: Set<string>;
266 /** How the last execution ended, while the turn waits for the one that delivers its steers. */
267 heldEnd: TurnTerminal | undefined;
268 /**
269 * Set on a turn started after a timed-out Stop's run left the server: that
270 * run's tail can still be on the stream, and everything before this turn's
271 * own `session.execution.started` belongs to it.
272 */
273 awaitingStart: boolean;
274 /**
275 * Prefixes a subagent session's tool ids in native item ids: the model
276 * names tool calls, so a child's could repeat its parent's.
277 */
278 readonly scope: string;
279 /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */
280 usedSubagents: boolean;
281}
282
283/** A `subagent` tool call. A background one outlives the turn that made it. */
284interface SubagentCall {
285 readonly toolId: string;
286 readonly nativeId: string;
287 /** The calling session and the turn whose item the call is. */
288 readonly state: ThreadState;
289 readonly turn: ActiveTurn;
290 readonly ordinal: number;
291 readonly startedAt: DateTime.Utc;
292 prompt: string;
293 title: string | null;
294 agent: string | undefined;
295 model: string | null;
296 /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */
297 background: boolean;
298 child: ThreadState | undefined;
299 status: OrchestrationV2Subagent["status"];
300 result: string | null;
301 completedAt: DateTime.Utc | null;
302}
303
304/**
305 * An execution OpenCode ran on a thread's session without T3 asking: the
306 * parent's answer once a background subagent ended. Its events are held until
307 * the continuation turn T3 opens for it takes them.
308 */
309interface Wake {
310 readonly events: Array<OpenCode2StreamEvent>;
311 running: boolean;
312 /** The background subagents whose end it answers, as its turn's notification names them. */
313 readonly reports: Array<BackgroundWorkReport>;
314 /** What OpenCode told the model, the continuation's prompt text. */
315 readonly detail: string | null;
316 /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */
317 dropped: boolean;
318 /** The last report it delivered (inbox ids are history ids): its execution follows it. */
319 readonly after: string | undefined;
320 /** The first report it delivered, where fork and rollback cut before its turn. */
321 readonly first: string | undefined;
322}
323
324interface OpenBlock {
325 readonly block: { readonly assistantMessageID: string; readonly ordinal: number };
326 readonly kind: "text" | "reasoning";
327 readonly startedAt: DateTime.Utc;
328 text: string;
329}
330
331interface ThreadState {
332 readonly sessionId: string;
333 providerThread: OrchestrationV2ProviderThread;
334 readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>;
335 active: ActiveTurn | undefined;
336 /** What the native session runs now, so a changed selection is switched before prompting. */
337 model: ModelRef | undefined;
338 /**
339 * Set when a turn ended here while OpenCode may still be running it: a Stop
340 * that timed out, a prompt whose request failed without a clear answer, or a
341 * request T3 could not answer. Execution events carry only the session id,
342 * so the next execution end belongs to that run; it clears this and ends no turn.
343 */
344 unsettled: boolean;
345 /** The session's location, where its agents' path rules are read. */
346 directory: string;
347 /**
348 * The agent the session runs (`build`, or `plan` in plan mode); its own path
349 * rules stay in force, and a changed mode switches it before prompting.
350 */
351 agent: string;
352 /** The native session's rules as T3 last read or wrote them, and the policy they are for. */
353 rules: ReadonlyArray<Rule> | undefined;
354 policy: RulesPolicy;
355 /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */
356 readonly grants: Array<Rule>;
357 /** The session's `subagent` calls still running, by tool call id. */
358 readonly calls: Map<string, SubagentCall>;
359 /**
360 * Set on a subagent's session: the call that runs it and the thread it
361 * shows in. Each of its executions is a runless turn there.
362 */
363 subagent:
364 | {
365 call: SubagentCall;
366 readonly appThread: OrchestrationV2AppThread;
367 turns: number;
368 /** The prompt its next execution answers, shown as that turn's user message. */
369 prompt: string | undefined;
370 /**
371 * The first item queued for the session since its last turn began (its
372 * prompt, or a report it answers): where the next turn's history
373 * begins, which a reconnect reads back.
374 */
375 queued: string | undefined;
376 }
377 | undefined;
378 /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */
379 readonly wakes: Array<Wake>;
380 /**
381 * Background subagents whose end OpenCode queued for this session and has
382 * not delivered yet, by inbox id. The wake that delivers them names them.
383 */
384 readonly reports: Map<
385 string,
386 {
387 readonly inboxId: string;
388 readonly childId: string;
389 readonly report: BackgroundWorkReport;
390 readonly text: string;
391 }
392 >;
393 /**
394 * Background subagent sessions T3 stopped. OpenCode wakes the parent to
395 * report them; a wake that reports only these is stopped as well.
396 */
397 readonly stoppedChildren: Set<string>;
398 /**
399 * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's
400 * inbox, where the next prompt would deliver them first, so it cancels them.
401 */
402 readonly strandedSteers: Set<string>;
403 /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */
404 mcp:
405 | { readonly name: string; readonly directory: string; readonly credential: string }
406 | undefined;
407 instructions: string | undefined;
408}
409
410type TurnTerminal =
411 | { readonly status: "completed" | "interrupted" }
412 | { readonly status: "failed"; readonly failure: ReturnType<typeof makeProviderFailure> };
413
414type Rule = Permission.Rule;
415type RulesPolicy = Pick<
416 ProviderAdapter.ProviderAdapterV2RuntimePolicy,
417 "runtimeMode" | "interactionMode"
418>;
419type NativeForm = EventOf<"form.created">["data"]["form"];
420
421/** A permission ask or question form shown to the user and not answered yet. */
422interface PendingRequest {
423 readonly request: OrchestrationV2RuntimeRequest;
424 readonly item: OrchestrationV2TurnItem;
425 readonly node: OrchestrationV2ExecutionNode;
426 /** The thread the request is shown on: the asking session's, or its parent's. */
427 readonly state: ThreadState;
428 readonly turn: ActiveTurn;
429 /** The session that asked: the thread's own, or one of its subagents'. */
430 readonly sessionId: string;
431 /** Set once T3 sends its answer; the orchestrator has already recorded it. */
432 answering: boolean;
433 readonly native:
434 | {
435 readonly type: "permission";
436 readonly id: string;
437 readonly action: string;
438 readonly resources: ReadonlyArray<string>;
439 readonly save: ReadonlyArray<string>;
440 }
441 | { readonly type: "form"; readonly id: string; readonly form: NativeForm };
442}
443
444const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect });
445
446/**
447 * A session's permission rules. OpenCode checks the agent's rules and then
448 * these, and the last rule that matches decides, so these override the
449 * agent's. `paths` are the agent's own allows for its directories (saved tool
450 * output, the plan agent's plan directory), which the blanket rules here would
451 * otherwise override; `grants` are "Always allow this session" answers.
452 */
453/**
454 * T3's MCP server is registered per directory, not per session, so each thread
455 * gets its own `t3-code-<thread>` entry with its own credential. OpenCode names
456 * an MCP tool's permission `<server>_<tool>` (non-alphanumerics become `_`).
457 * OpenCode skips every tool of a server whose name is over 64 characters (its
458 * tool namespace limit), and its router rejects adding one over 100, so a
459 * thread id that does not fit is replaced by a digest of it.
460 */
461export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) {
462 const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
463 if (name.length <= 64) return name;
464 const crypto = yield* Crypto.Crypto;
465 const digest = yield* crypto
466 .digest("SHA-256", new TextEncoder().encode(threadId))
467 .pipe(Effect.orDie);
468 return `t3-code-${Hex.encode(digest).slice(0, 16)}`;
469});
470
471/**
472 * The rules that keep T3's MCP servers to their own thread, after the mode's:
473 * the last matching rule wins, so every thread's T3 server is denied and then
474 * this thread's own is allowed again, in every mode. A subagent's session
475 * inherits the thread's.
476 */
477const mcpRules = (mcpServerName: string | null): ReadonlyArray<Rule> =>
478 mcpServerName === null
479 ? []
480 : [
481 { action: "t3-code-*", resource: "*", effect: "deny" },
482 { action: `${mcpServerName}_*`, resource: "*", effect: "allow" },
483 ];
484
485const sessionRules = (
486 policy: RulesPolicy,
487 paths: ReadonlyArray<Rule>,
488 grants: ReadonlyArray<Rule>,
489 mcpServerName: string | null,
490): ReadonlyArray<Rule> => [
491 ...(policy.runtimeMode === "full-access"
492 ? [rule("*", "allow")]
493 : [
494 rule("shell", "ask"),
495 rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"),
496 rule("external_directory", "ask"),
497 ]),
498 ...grants,
499 // Plan mode writes only its plan, which `paths` allows again. Shell and read
500 // are never denied: the free tier refuses sessions whose rules deny them.
501 ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []),
502 ...paths,
503 ...mcpRules(mcpServerName),
504];
505
506const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) =>
507 left?.length === right.length &&
508 left.every(
509 (entry, index) =>
510 entry.action === right[index]?.action &&
511 entry.resource === right[index]?.resource &&
512 entry.effect === right[index]?.effect,
513 );
514
515/**
516 * Sent with a declined permission. A reject without a message is OpenCode's
517 * "stop": it ends the whole execution, which is Cancel.
518 */
519const DECLINED = "The user declined this request.";
520
521/**
522 * Steered into the session before a decline: a declined shell call reaches the
523 * model only as "Unable to execute command", which it retries.
524 */
525const declinedNote = (action: string, resources: ReadonlyArray<string>) =>
526 `The user declined the ${action} request${resources.length === 0 ? "" : ` (${resources.join(", ")})`}. Do not retry it; continue without it or ask the user how to proceed.`;
527
528/** The session-wide choice for a request whose `save` patterns OpenCode would remember. */
529const sessionGrantLabel = (action: string, save: ReadonlyArray<string>) =>
530 save.every((pattern) => pattern === "*")
531 ? `Allow every ${action} request this session`
532 : `Allow ${save.join(", ")} this session`;
533
534const text = (value: string | undefined, fallback: string) => value?.trim() || fallback;
535
536/**
537 * A form's fields as T3 questions, or why T3 cannot ask them: a link to open,
538 * a field shown only for another answer, a hidden field, or a number or yes/no
539 * value. OpenCode's question tool only asks text and multi-select fields.
540 */
541const formQuestions = (
542 form: NativeForm,
543):
544 | { readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion> }
545 | { readonly unsupported: string } => {
546 const questions: Array<OrchestrationV2UserInputQuestion> = [];
547 for (const [index, field] of form.fields.entries()) {
548 if (field.type === "external") return { unsupported: "a link to open" };
549 if (field.type !== "string" && field.type !== "multiselect") {
550 return { unsupported: `a ${field.type} value` };
551 }
552 if (field.hidden === true) return { unsupported: "a hidden field" };
553 if ((field.when?.length ?? 0) > 0) return { unsupported: "a field that depends on another" };
554 const header = text(field.title, `Question ${index + 1}`);
555 const options = (field.options ?? []).map((option) => {
556 const label = text(option.label, text(option.value, "Option"));
557 return { label, description: text(option.description, label), value: option.value };
558 });
559 questions.push({
560 id: field.key,
561 header,
562 question: text(field.description, header),
563 options,
564 multiSelect: field.type === "multiselect",
565 allowCustomAnswer: field.custom === true || options.length === 0,
566 });
567 }
568 return { questions };
569};
570
571/** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */
572const formAnswer = (form: NativeForm, answers: Readonly<Record<string, unknown>>) => {
573 const answer: Record<string, string | ReadonlyArray<string>> = {};
574 for (const field of form.fields) {
575 const raw = answers[field.key];
576 const values = (Array.isArray(raw) ? raw : [raw]).filter(
577 (value): value is string => typeof value === "string" && value.trim().length > 0,
578 );
579 if (values.length > 0) {
580 answer[field.key] = field.type === "multiselect" ? values : values.join(", ");
581 }
582 }
583 return answer;
584};
585
586const approves = (decision: ProviderApprovalDecision) =>
587 decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways";
588
589/**
590 * An answer to a request OpenCode already dropped (its execution ended, or
591 * its session is gone): nothing waits on it, so it counts as delivered.
592 */
593const permissionGone = {
594 PermissionNotFoundError: () => Effect.void,
595 SessionNotFoundError: () => Effect.void,
596};
597const formGone = {
598 FormNotFoundError: () => Effect.void,
599 FormAlreadySettledError: () => Effect.void,
600 SessionNotFoundError: () => Effect.void,
601};
602
603/** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */
604const agentPaths = (rules: ReadonlyArray<Rule>) =>
605 rules.filter(
606 (entry) =>
607 entry.effect === "allow" &&
608 entry.resource !== "*" &&
609 (entry.action === "edit" || entry.action === "external_directory"),
610 );
611
612const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({
613 driver: OPENCODE_PROVIDER,
614 nativeId,
615 strength,
616});
617
618const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => {
619 const nativeId = providerThread.nativeThreadRef?.nativeId;
620 return nativeId === undefined || nativeId === null
621 ? Effect.fail(
622 new ProviderAdapter.ProviderAdapterProtocolError({
623 driver: OPENCODE_PROVIDER,
624 detail: `Provider thread ${providerThread.id} has no OpenCode session`,
625 }),
626 )
627 : Effect.succeed(nativeId);
628};
629
630const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) =>
631 content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n");
632
633const stringField = (record: Readonly<Record<string, unknown>> | undefined, key: string) => {
634 const value = record?.[key];
635 return typeof value === "string" && value.trim().length > 0 ? value : undefined;
636};
637
638/** How a background subagent ended, from the `state` of OpenCode's report to its parent. */
639const reportOutcome = (state: string | undefined) =>
640 state === "completed"
641 ? ("completed" as const)
642 : state === "error"
643 ? ("failed" as const)
644 : state === "cancelled"
645 ? ("cancelled" as const)
646 : ("unknown" as const);
647
648/** A subagent's answer, without the `<subagent …>` wrapper OpenCode gives the model. */
649const subagentOutput = (text: string) =>
650 /^<subagent\b[^>]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text;
651
652const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) =>
653 turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider";
654
655/** The native turn id of a continuation turn, which answers a wake and has no prompt. */
656const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`;
657const isWakeTurn = (turn: OrchestrationV2ProviderTurn) =>
658 turn.nativeTurnRef?.nativeId?.includes(":wake:") === true;
659
660const INTERRUPT_TIMEOUT = "10 seconds";
661/** The session instructions entry T3 writes its per-turn system prompt to. */
662const INSTRUCTIONS_KEY = "t3-code";
663/** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */
664const RECONNECT_ATTEMPTS = 5;
665const RECONNECT_DELAY = "2 seconds";
666const RECONCILE_TIMEOUT = "15 seconds";
667/** How long a new turn waits for a reconnect in progress. */
668const RECONNECT_WAIT = "30 seconds";
669/** A background subagent's result when its end was lost with the event stream. */
670const LOST_BACKGROUND =
671 "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown.";
672/** How long a turn waits on the directory's commands or skills before sending the text as is. */
673const INVENTORY_TIMEOUT = "5 seconds";
674const ACTIVE_CHECK_TIMEOUT = "5 seconds";
675/** Answers that mean the server refused a prompt; any other failure may have been accepted. */
676const CLEAR_PROMPT_REJECTIONS: ReadonlySet<string> = new Set([
677 "InvalidRequestError",
678 "ConflictError",
679 "UnauthorizedError",
680 "CommandNotFoundError",
681 "SkillNotFoundError",
682]);
683
684export const OPENCODE_2_STILL_STOPPING =
685 "OpenCode is still stopping the previous turn. Send the message again in a moment.";
686const REQUEST_REPLY_TIMEOUT = "10 seconds";
687
688/** Whether an answer to a paused request reached the server, trying twice. */
689const deliver = <E>(answer: Effect.Effect<void, E>) =>
690 answer.pipe(
691 Effect.retry({ times: 1 }),
692 Effect.timeout(REQUEST_REPLY_TIMEOUT),
693 Effect.exit,
694 Effect.map(Exit.isSuccess),
695 );
696
697type ModelRef = ReturnType<typeof Model.Ref.make>;
698
699/**
700 * The user message ids T3 prompts under. OpenCode takes a client id (it must
701 * start with `msg_`) and answers a repeat of one in the same session with the
702 * item it already has, so a retried request never queues a second message,
703 * and a turn knows the message fork and rollback cut at before OpenCode
704 * answers. The id is unique across the whole server, which refuses it in any
705 * other session (409), so it names the session: another T3 database or
706 * environment on the same server repeats thread ids and run ordinals, never
707 * session ids. A turn keeps its id in `nativeTurnRef`.
708 */
709const turnPromptId = (sessionId: string, attemptId: string) =>
710 SessionMessage.ID.make(`msg_t3_turn_${sessionId}:${attemptId}`);
711const steerPromptId = (sessionId: string, messageId: string) =>
712 SessionMessage.ID.make(`msg_t3_steer_${sessionId}:${messageId}`);
713
714/**
715 * The user message a turn prompted with. Turns from before T3 chose prompt ids
716 * recorded `<session>:attempt:<id>`, which is no message.
717 */
718const promptOf = (turn: OrchestrationV2ProviderTurn) => {
719 const nativeId = turn.nativeTurnRef?.nativeId;
720 return nativeId?.startsWith("msg_") === true ? nativeId : undefined;
721};
722
723/**
724 * Where fork and rollback cut to keep the session's turns up to `kept` (all of
725 * them before the first turn when undefined): before the message that starts
726 * the next turn that reached OpenCode, which is in `prompts`. That is a turn's
727 * prompt, or for a continuation the report it answers. A turn refused before
728 * it prompted is not in the session and is passed over, and so is a
729 * continuation that took no report. `null` means nothing follows, so there is
730 * no cut; a later turn from before T3 chose prompt ids has no known message,
731 * so no cut is safe.
732 */
733const boundaryAfter = (
734 turns: ReadonlyArray<OrchestrationV2ProviderTurn>,
735 providerThreadId: OrchestrationV2ProviderThread["id"],
736 kept: OrchestrationV2ProviderTurn | undefined,
737 prompts: ReadonlySet<string>,
738) => {
739 const later = turns
740 .filter(
741 (turn) =>
742 turn.providerThreadId === providerThreadId &&
743 (kept === undefined || turn.ordinal > kept.ordinal),
744 )
745 .toSorted((left, right) => left.ordinal - right.ordinal);
746 if (later.some((turn) => promptOf(turn) === undefined && !isWakeTurn(turn))) {
747 return Effect.fail(
748 new ProviderAdapter.ProviderAdapterProtocolError({
749 driver: OPENCODE_PROVIDER,
750 detail:
751 "This OpenCode conversation has turns from an earlier T3 Code version, so it can't be cut there.",
752 }),
753 );
754 }
755 return Effect.succeed(
756 later.map(promptOf).find((messageId) => messageId !== undefined && prompts.has(messageId)) ??
757 null,
758 );
759};
760
761// Errors already in the adapter channel keep their tag; only lower-level ones are wrapped.
762const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error);
763
764/**
765 * The model OpenCode should run for a `provider/model` slug and its reasoning
766 * variant, or undefined for any other slug: sending none would run OpenCode's
767 * default while T3 records the requested model.
768 */
769const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => {
770 const parsed = parseOpenCodeModelSlug(selection.model);
771 if (parsed === null) return undefined;
772 const variant = getModelSelectionStringOptionValue(selection, "variant");
773 return Model.Ref.make({
774 providerID: Provider.ID.make(parsed.providerID),
775 id: Model.ID.make(parsed.modelID),
776 ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
777 });
778};
779const malformedModel = (model: string) =>
780 `OpenCode model '${model}' must use provider/model format`;
781const sameModel = (left: ModelRef, right: ModelRef | undefined) =>
782 left.providerID === right?.providerID &&
783 left.id === right?.id &&
784 (left.variant ?? "default") === (right?.variant ?? "default");
785
786/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */
787const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) =>
788 input.runtimePolicy.interactionMode === "plan" ? "plan" : "build";
789
790/** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */
791const commandOf = (text: string) => {
792 const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
793 return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" };
794};
795
796/** Whether a prompt names any skill at all, before the directory's skills are read. */
797const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u");
798
799/** The workspace skills a prompt names with the composer's `$skill` tokens. */
800const skillsNamed = (text: string, known: ReadonlySet<string>) => [
801 ...new Set(
802 [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) =>
803 known.has(match[2] ?? "") ? [match[2]!] : [],
804 ),
805 ),
806];
807
808/**
809 * The turn's own tokens: steps add up, and the last step's input is the live
810 * context size. A subagent's tokens are its own session's, never these.
811 */
812const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) =>
813 turn.steps === 0
814 ? {
815 usageScope: "main_agent" as const,
816 usageStatus: "unavailable" as const,
817 hasSubagents: turn.usedSubagents,
818 }
819 : {
820 usageScope: "main_agent" as const,
821 usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const),
822 inputTokens: turn.usage.input,
823 cachedInputTokens: turn.usage.cached,
824 cacheCreationTokens: turn.usage.cacheWrite,
825 outputTokens: turn.usage.output,
826 reasoningTokens: turn.usage.reasoning,
827 hasSubagents: turn.usedSubagents,
828 };
829
830/**
831 * The adapter for one provider instance. It talks to the instance's
832 * {@link OpenCode2Server.OpenCode2Server}, which the driver builds from the instance's settings.
833 */
834export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) {
835 const server = yield* OpenCode2Server.OpenCode2Server;
836 const idAllocator = yield* IdAllocator.IdAllocatorV2;
837 const serverConfig = yield* ServerConfig.ServerConfig;
838 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
839 const crypto = yield* Crypto.Crypto;
840 const driver = OPENCODE_PROVIDER;
841 const mcpServerNameFor = (threadId: string) =>
842 t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto));
843
844 /**
845 * Lends the instance's server to a session until its scope closes. A spawned
846 * server that died is started again on the next borrow.
847 */
848 const borrow = Effect.gen(function* () {
849 const lent = yield* Deferred.make<OpenCode2Server.OpenCode2Connection, OpenCodeRuntimeError>();
850 yield* server
851 .withConnection((connection) =>
852 Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)),
853 )
854 .pipe(
855 Effect.catch((error) => Deferred.fail(lent, error)),
856 Effect.forkScoped,
857 );
858 return yield* Deferred.await(lent);
859 });
860
861 const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* (
862 input: Parameters<ProviderAdapter.ProviderAdapterV2Shape["openSession"]>[0],
863 initial: {
864 readonly connection: OpenCode2Server.OpenCode2Connection;
865 readonly scope: Scope.Closeable;
866 },
867 ) {
868 let connection = initial.connection;
869 // Replaced when the session reconnects to a restarted server.
870 let client = connection.client;
871 const sessionScope = yield* Effect.scope;
872 // Context windows by directory, then `provider/model`: a project's own
873 // OpenCode config can change a model's limits, and this one runtime serves
874 // the instance's threads in every directory.
875 const contextWindows = new Map<string, Map<string, number>>();
876 /** A thread without a worktree runs where T3 does, as its session is created. */
877 const directoryOf = (cwd: string | null | undefined) => cwd ?? serverConfig.cwd;
878 const windowOf = (cwd: string | null | undefined, model: string) =>
879 contextWindows.get(directoryOf(cwd))?.get(model);
880 const now = yield* DateTime.now;
881 let session: OrchestrationV2ProviderSession = {
882 id: input.providerSessionId,
883 driver,
884 providerInstanceId: instanceId,
885 status: "ready",
886 cwd: input.runtimePolicy.cwd ?? serverConfig.cwd,
887 model: input.modelSelection.model,
888 capabilities: OpenCode2ProviderCapabilities,
889 createdAt: now,
890 updatedAt: now,
891 lastError: null,
892 };
893 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>();
894 // Thread sessions, and the sessions of their subagents.
895 const threads = new Map<string, ThreadState>();
896 // A subagent's session, by its id, to the thread whose session started it.
897 const childOwners = new Map<string, ThreadState>();
898 // What OpenCode announced about a subagent's session, until its call names it.
899 const announced = new Map<string, EventOf<"session.created">["data"]>();
900 // Sessions of these threads with an execution running, seen on the stream.
901 const busy = new Set<string>();
902 const pending = new Map<RuntimeRequestId, PendingRequest>();
903 // Events and a wake's replay into its turn are handled one at a time, in order.
904 const lock = yield* Semaphore.make(1);
905 // Sessions whose `revert.clear` has not had its empty execution yet.
906 const clearing = new Map<string, Deferred.Deferred<void>>();
907 // Sessions a failed rollback may have left with a staged revert.
908 const stagedReverts = new Set<string>();
909 // Starting a turn and cutting the history take turns on a session: each
910 // checks that the other is not running before its own requests yield.
911 const sessionGates = yield* KeyedLock.make<string>();
912 const exclusive =
913 (providerThread: OrchestrationV2ProviderThread) =>
914 <A, E, R>(effect: Effect.Effect<A, E, R>) => {
915 const sessionId = providerThread.nativeThreadRef?.nativeId;
916 if (sessionId == null) return effect;
917 return sessionGates.withLock(sessionId, effect);
918 };
919 const emit = (event: ProviderAdapter.ProviderAdapterV2Event) =>
920 Queue.offer(events, event).pipe(Effect.asVoid);
921 const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId);
922
923 const newThreadState = (
924 sessionId: string,
925 providerThread: OrchestrationV2ProviderThread,
926 directory: string,
927 subagent: ThreadState["subagent"],
928 ): ThreadState => ({
929 sessionId,
930 providerThread,
931 providerTurns: new Map(),
932 active: undefined,
933 model: undefined,
934 unsettled: false,
935 directory,
936 agent: "build",
937 rules: undefined,
938 policy: input.runtimePolicy,
939 grants: [],
940 calls: new Map(),
941 subagent,
942 wakes: [],
943 reports: new Map(),
944 stoppedChildren: new Set(),
945 strandedSteers: new Set(),
946 mcp: undefined,
947 instructions: undefined,
948 });
949
950 /** The thread whose session started this one, through any nesting. */
951 const rootOf = (state: ThreadState): ThreadState => {
952 let current = state;
953 while (current.subagent !== undefined) current = current.subagent.call.state;
954 return current;
955 };
956
957 /** A thread's session and its subagents' sessions, through any nesting. */
958 const sessionsOf = (thread: ThreadState): ReadonlyArray<ThreadState> => [
959 thread,
960 ...[...threads.values()].filter((state) => {
961 for (let above = state.subagent?.call.state; above; above = above.subagent?.call.state) {
962 if (above === thread) return true;
963 }
964 return false;
965 }),
966 ];
967
968 /**
969 * A thread's `subagent` calls still running, its subagents' included: a
970 * subagent's background call runs on after the call that made it ended.
971 */
972 const runningCalls = (thread: ThreadState): ReadonlyArray<SubagentCall> =>
973 sessionsOf(thread).flatMap((state) => [...state.calls.values()]);
974
975 /** The `subagent` calls that lead to a session, from its own up to the thread's. */
976 const callsAbove = (sessionId: string) => {
977 const calls: Array<SubagentCall> = [];
978 let current = threads.get(sessionId);
979 while (current?.subagent !== undefined) {
980 calls.push(current.subagent.call);
981 current = current.subagent.call.state;
982 }
983 return calls;
984 };
985
986 /**
987 * The turn a request from `sessionId` is shown under. A background
988 * subagent's goes to the turn that started it, whose run waits on the
989 * subagent; anything else goes to the thread's running turn.
990 */
991 const requestTurn = (sessionId: string) => {
992 const state = ownerOf(sessionId);
993 if (state === undefined) return undefined;
994 const background = callsAbove(sessionId).findLast((call) => call.background);
995 if (background !== undefined) {
996 return isOrchestrationV2WorkActive(background.status)
997 ? { state, turn: background.turn }
998 : undefined;
999 }
1000 return state.active === undefined ? undefined : { state, turn: state.active };
1001 };
1002
1003 /**
1004 * What a session still owes its thread once the thread's turn ended:
1005 * background subagents, held executions, reports OpenCode queued for the
1006 * follow-up it will start, and anything a subagent's session still runs,
1007 * such as its own follow-up to such a report.
1008 */
1009 const owesWork = (state: ThreadState) =>
1010 state.wakes.length > 0 ||
1011 state.reports.size > 0 ||
1012 (state.subagent !== undefined && busy.has(state.sessionId)) ||
1013 [...state.calls.values()].some((call) => call.background);
1014
1015 /** Work that outlives the thread's turn, on its own session or its subagents'. */
1016 const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork);
1017
1018 const setSessionStatus = (
1019 status: OrchestrationV2ProviderSession["status"],
1020 lastError: string | null,
1021 ) =>
1022 Effect.gen(function* () {
1023 session = { ...session, status, lastError, updatedAt: yield* DateTime.now };
1024 yield* emit({ type: "provider_session.updated", driver, providerSession: session });
1025 });
1026
1027 const ordinalOf = (turn: ActiveTurn, nativeId: string) => {
1028 const known = turn.ordinals.get(nativeId);
1029 if (known !== undefined) return known;
1030 const next = turn.nextOrdinal++;
1031 turn.ordinals.set(nativeId, next);
1032 return next;
1033 };
1034
1035 const itemBase = (
1036 state: ThreadState,
1037 turn: ActiveTurn,
1038 nativeId: string,
1039 status: OrchestrationV2TurnItem["status"],
1040 startedAt: DateTime.Utc,
1041 completedAt: DateTime.Utc | null,
1042 updatedAt: DateTime.Utc,
1043 ) => ({
1044 id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: nativeId }),
1045 threadId: turn.input.threadId,
1046 runId: turn.input.runId,
1047 nodeId: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }),
1048 providerThreadId: state.providerThread.id,
1049 providerTurnId: turn.providerTurn.id,
1050 nativeItemRef: ref(nativeId),
1051 parentItemId: null,
1052 ordinal: ordinalOf(turn, nativeId),
1053 status,
1054 title: null,
1055 startedAt,
1056 completedAt,
1057 updatedAt,
1058 });
1059
1060 const emitNode = (
1061 state: ThreadState,
1062 turn: ActiveTurn,
1063 nativeId: string,
1064 kind: OrchestrationV2ExecutionNode["kind"],
1065 status: OrchestrationV2ExecutionNode["status"],
1066 startedAt: DateTime.Utc,
1067 completedAt: DateTime.Utc | null,
1068 ) =>
1069 emit({
1070 type: "node.updated",
1071 driver,
1072 node: {
1073 id: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }),
1074 threadId: turn.input.threadId,
1075 runId: turn.input.runId,
1076 parentNodeId: turn.input.rootNodeId,
1077 rootNodeId: turn.input.rootNodeId,
1078 kind,
1079 status,
1080 countsForRun: false,
1081 providerThreadId: state.providerThread.id,
1082 providerTurnId: turn.providerTurn.id,
1083 nativeItemRef: ref(nativeId),
1084 runtimeRequestId: null,
1085 checkpointScopeId: null,
1086 startedAt,
1087 completedAt,
1088 },
1089 });
1090
1091 /** One text or reasoning block, re-emitted with its accumulated text on every change. */
1092 const emitText = Effect.fnUntraced(function* (
1093 state: ThreadState,
1094 turn: ActiveTurn,
1095 data: {
1096 readonly assistantMessageID: string;
1097 readonly ordinal: number;
1098 readonly text?: string;
1099 },
1100 kind: "text" | "reasoning",
1101 update: (current: string) => string,
1102 completed = "text" in data,
1103 ) {
1104 const nativeId = `${data.assistantMessageID}:${kind}:${data.ordinal}`;
1105 const updatedAt = yield* DateTime.now;
1106 const entry = turn.texts.get(nativeId) ?? {
1107 block: data,
1108 kind,
1109 startedAt: updatedAt,
1110 text: "",
1111 };
1112 entry.text = update(entry.text);
1113 if (completed) turn.texts.delete(nativeId);
1114 else turn.texts.set(nativeId, entry);
1115 if (entry.text.length === 0) return;
1116 const status = completed ? "completed" : "running";
1117 const completedAt = completed ? updatedAt : null;
1118 const nodeKind = kind === "text" ? "assistant_message" : "reasoning";
1119 yield* emitNode(state, turn, nativeId, nodeKind, status, entry.startedAt, completedAt);
1120 const base = itemBase(state, turn, nativeId, status, entry.startedAt, completedAt, updatedAt);
1121 if (kind === "reasoning") {
1122 yield* emit({
1123 type: "turn_item.updated",
1124 driver,
1125 turnItem: { ...base, type: "reasoning", text: entry.text, streaming: !completed },
1126 });
1127 return;
1128 }
1129 const messageId = idAllocator.derive.messageFromProviderItem({
1130 driver,
1131 nativeItemId: nativeId,
1132 });
1133 const message: OrchestrationV2ConversationMessage = {
1134 createdBy: "agent",
1135 creationSource: "provider",
1136 id: messageId,
1137 threadId: turn.input.threadId,
1138 runId: turn.input.runId,
1139 nodeId: base.nodeId,
1140 role: "assistant",
1141 text: entry.text,
1142 attachments: [],
1143 streaming: !completed,
1144 createdAt: entry.startedAt,
1145 updatedAt,
1146 };
1147 yield* emit({ type: "message.updated", driver, message });
1148 yield* emit({
1149 type: "turn_item.updated",
1150 driver,
1151 turnItem: {
1152 ...base,
1153 type: "assistant_message",
1154 messageId,
1155 text: entry.text,
1156 streaming: !completed,
1157 },
1158 });
1159 });
1160
1161 const emitTool = Effect.fnUntraced(function* (
1162 state: ThreadState,
1163 turn: ActiveTurn,
1164 id: string,
1165 status: "running" | "completed" | "failed" | "interrupted",
1166 result?: { readonly output: string | undefined; readonly metadata: unknown },
1167 ) {
1168 const tool = turn.tools.get(id);
1169 if (tool === undefined) return;
1170 const updatedAt = yield* DateTime.now;
1171 const startedAt = turn.startedAt.get(id) ?? updatedAt;
1172 const completedAt = status === "running" ? null : updatedAt;
1173 const nativeId = `${turn.scope}${id}`;
1174 yield* emitNode(state, turn, nativeId, "tool_call", status, startedAt, completedAt);
1175 yield* emit({
1176 type: "turn_item.updated",
1177 driver,
1178 turnItem: openCodeToolTurnItem(
1179 itemBase(state, turn, nativeId, status, startedAt, completedAt, updatedAt),
1180 {
1181 name: tool.name,
1182 input: tool.input,
1183 output: result?.output,
1184 completedMetadata: status === "completed" ? result?.metadata : undefined,
1185 },
1186 ),
1187 });
1188 });
1189
1190 /** The turn's compaction item; its summary is the text OpenCode carries forward. */
1191 const emitCompaction = Effect.fnUntraced(function* (
1192 state: ThreadState,
1193 turn: ActiveTurn,
1194 status: "running" | "completed" | "failed" | "interrupted",
1195 summary?: string,
1196 ) {
1197 const compaction = turn.compaction;
1198 if (compaction === undefined) return;
1199 const updatedAt = yield* DateTime.now;
1200 const completedAt = status === "running" ? null : updatedAt;
1201 yield* emit({
1202 type: "turn_item.updated",
1203 driver,
1204 turnItem: {
1205 ...itemBase(
1206 state,
1207 turn,
1208 compaction.nativeId,
1209 status,
1210 compaction.startedAt,
1211 completedAt,
1212 updatedAt,
1213 ),
1214 nodeId: turn.input.rootNodeId,
1215 type: "compaction",
1216 driver,
1217 title:
1218 status === "running"
1219 ? "Compacting context"
1220 : status === "completed"
1221 ? "Context compacted"
1222 : status === "failed"
1223 ? "Compaction failed"
1224 : "Compaction interrupted",
1225 ...(summary === undefined || summary.length === 0 ? {} : { summary }),
1226 },
1227 });
1228 if (status !== "running") turn.compaction = undefined;
1229 });
1230
1231 const emitProviderTurn = (
1232 state: ThreadState,
1233 turn: ActiveTurn,
1234 providerTurn: OrchestrationV2ProviderTurn,
1235 ) => {
1236 state.providerTurns.set(String(providerTurn.id), providerTurn);
1237 return emit({
1238 type: "provider_turn.updated",
1239 driver,
1240 threadId: turn.input.threadId,
1241 providerTurn,
1242 });
1243 };
1244
1245 const makeTurn = (
1246 owner: TurnOwner,
1247 providerTurn: OrchestrationV2ProviderTurn,
1248 options: { readonly scope: string; readonly awaitingStart: boolean },
1249 ): ActiveTurn => ({
1250 input: owner,
1251 providerTurn,
1252 texts: new Map(),
1253 tools: new Map(),
1254 startedAt: new Map(),
1255 ordinals: new Map(),
1256 nextOrdinal: providerTurn.ordinal * 100 + 1,
1257 usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 },
1258 steps: 0,
1259 lastStep: undefined,
1260 before: undefined,
1261 compaction: undefined,
1262 compactions: 0,
1263 interrupted: false,
1264 unsent: false,
1265 steers: new Set(),
1266 settledInbox: new Set(),
1267 heldEnd: undefined,
1268 awaitingStart: options.awaitingStart,
1269 scope: options.scope,
1270 usedSubagents: false,
1271 });
1272
1273 /** A subagent call as its parent turn's item, node and subagent row. */
1274 const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) {
1275 const updatedAt = yield* DateTime.now;
1276 const { turn, state } = call;
1277 const nodeId = idAllocator.derive.nodeFromProviderItem({
1278 driver,
1279 nativeItemId: call.nativeId,
1280 });
1281 const completedAt = isOrchestrationV2WorkActive(call.status)
1282 ? null
1283 : (call.completedAt ?? updatedAt);
1284 const childProviderThreadId = call.child?.providerThread.id ?? null;
1285 const childThreadId = call.child?.subagent?.appThread.id ?? null;
1286 yield* emit({
1287 type: "node.updated",
1288 driver,
1289 node: {
1290 id: nodeId,
1291 threadId: turn.input.threadId,
1292 runId: turn.input.runId,
1293 parentNodeId: turn.input.rootNodeId,
1294 rootNodeId: turn.input.rootNodeId,
1295 kind: "subagent",
1296 status: call.status,
1297 countsForRun: false,
1298 providerThreadId: childProviderThreadId ?? state.providerThread.id,
1299 providerTurnId: turn.providerTurn.id,
1300 nativeItemRef: ref(call.nativeId),
1301 runtimeRequestId: null,
1302 checkpointScopeId: null,
1303 startedAt: call.startedAt,
1304 completedAt,
1305 },
1306 });
1307 yield* emit({
1308 type: "subagent.updated",
1309 driver,
1310 subagent: {
1311 id: nodeId,
1312 threadId: turn.input.threadId,
1313 runId: turn.input.runId,
1314 parentNodeId: turn.input.rootNodeId,
1315 origin: "provider_native",
1316 createdBy: "agent",
1317 driver,
1318 providerInstanceId: instanceId,
1319 providerThreadId: childProviderThreadId,
1320 childThreadId,
1321 nativeTaskRef: ref(call.nativeId),
1322 prompt: call.prompt,
1323 title: call.title,
1324 model: call.model,
1325 status: call.status,
1326 result: call.result,
1327 startedAt: call.startedAt,
1328 completedAt,
1329 updatedAt,
1330 },
1331 });
1332 yield* emit({
1333 type: "turn_item.updated",
1334 driver,
1335 turnItem: {
1336 id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: call.nativeId }),
1337 threadId: turn.input.threadId,
1338 runId: turn.input.runId,
1339 nodeId,
1340 providerThreadId: state.providerThread.id,
1341 providerTurnId: turn.providerTurn.id,
1342 nativeItemRef: ref(call.nativeId),
1343 parentItemId: null,
1344 ordinal: call.ordinal,
1345 status: call.status,
1346 title: call.title,
1347 startedAt: call.startedAt,
1348 completedAt,
1349 updatedAt,
1350 type: "subagent",
1351 subagentId: nodeId,
1352 origin: "provider_native",
1353 driver,
1354 providerInstanceId: instanceId,
1355 childThreadId,
1356 prompt: call.prompt,
1357 result: call.result,
1358 },
1359 });
1360 yield* syncRoster(rootOf(call.state));
1361 });
1362
1363 /**
1364 * Lists a thread's background subagents on its provider thread, as typed
1365 * `subagent` work: the thread shows it waits on them after its turn ends,
1366 * and a Stop reaches them. Emitted only when the list changes.
1367 */
1368 const syncRoster = Effect.fnUntraced(function* (state: ThreadState) {
1369 const roster = runningCalls(state)
1370 .filter((call) => call.background)
1371 .map((call) => ({
1372 taskId: call.nativeId,
1373 kind: "subagent" as const,
1374 ...(call.title?.trim() ? { description: call.title.trim() } : {}),
1375 ...(call.child?.subagent === undefined
1376 ? {}
1377 : { childThreadId: call.child.subagent.appThread.id }),
1378 }));
1379 const current = state.providerThread.pendingBackgroundTasks ?? [];
1380 if (
1381 current.length === roster.length &&
1382 current.every(
1383 (task, index) =>
1384 task.taskId === roster[index]?.taskId &&
1385 task.description === roster[index]?.description &&
1386 (task.kind === "subagent" ? task.childThreadId : undefined) ===
1387 roster[index]?.childThreadId,
1388 )
1389 ) {
1390 return;
1391 }
1392 state.providerThread = {
1393 ...state.providerThread,
1394 pendingBackgroundTasks: roster,
1395 updatedAt: yield* DateTime.now,
1396 };
1397 yield* emit({
1398 type: "provider_thread.updated",
1399 driver,
1400 providerThread: state.providerThread,
1401 });
1402 });
1403
1404 const withReportedModel = (
1405 providerThread: OrchestrationV2ProviderThread,
1406 model: ModelRef | undefined,
1407 ): OrchestrationV2ProviderThread => {
1408 if (model === undefined) return providerThread;
1409 const modelSelection: ModelSelection = {
1410 instanceId,
1411 model: `${model.providerID}/${model.id}`,
1412 options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }],
1413 };
1414 if (
1415 providerThread.nativeMetadata?.modelSelection !== undefined &&
1416 modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection)
1417 )
1418 return providerThread;
1419 return {
1420 ...providerThread,
1421 nativeMetadata: { ...providerThread.nativeMetadata, modelSelection },
1422 };
1423 };
1424
1425 /**
1426 * Gives a subagent call its session once both are known: OpenCode names
1427 * the session on the call's progress, and announces it just before. The
1428 * session becomes a child thread under the call's item.
1429 */
1430 const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) {
1431 if (call.child !== undefined) return;
1432 const info = announced.get(childId);
1433 announced.delete(childId);
1434 const now = yield* DateTime.now;
1435 const nodeId = idAllocator.derive.nodeFromProviderItem({
1436 driver,
1437 nativeItemId: call.nativeId,
1438 });
1439 const childThreadId = idAllocator.derive.threadFromProviderThread({
1440 driver,
1441 nativeThreadId: childId,
1442 providerInstanceId: instanceId,
1443 });
1444 const providerThreadId = idAllocator.derive.providerThread({
1445 driver,
1446 nativeThreadId: childId,
1447 providerInstanceId: instanceId,
1448 });
1449 if (info?.model !== undefined) call.model = `${info.model.providerID}/${info.model.id}`;
1450 call.title = call.title ?? info?.title ?? null;
1451 const parentThread = call.turn.input.appThread;
1452 const appThread = makeSubagentChildThread({
1453 parentThread,
1454 childThreadId,
1455 parentNodeId: nodeId,
1456 activeProviderThreadId: providerThreadId,
1457 providerInstanceId: instanceId,
1458 modelSelection: {
1459 instanceId,
1460 model: call.model ?? call.turn.input.modelSelection.model,
1461 },
1462 title: subagentThreadTitle({
1463 parentTitle: parentThread.title,
1464 title: call.title,
1465 prompt: call.prompt,
1466 ordinal: call.ordinal,
1467 }),
1468 now,
1469 createdBy: "agent",
1470 creationSource: "provider",
1471 });
1472 const providerThread: OrchestrationV2ProviderThread = {
1473 id: providerThreadId,
1474 driver,
1475 providerInstanceId: instanceId,
1476 providerSessionId: input.providerSessionId,
1477 appThreadId: childThreadId,
1478 ownerNodeId: nodeId,
1479 nativeThreadRef: ref(childId),
1480 nativeConversationHeadRef: null,
1481 status: "active",
1482 firstRunOrdinal: null,
1483 lastRunOrdinal: null,
1484 handoffIds: [],
1485 forkedFrom: null,
1486 createdAt: now,
1487 updatedAt: now,
1488 };
1489 // A subagent called again keeps its session's state: how many turns it
1490 // ran (each one's native id is its own), its grants and rules, and the
1491 // background work an earlier call left running, which Stop must reach.
1492 const previous = threads.get(childId);
1493 const subagent = {
1494 call,
1495 appThread,
1496 turns: previous?.subagent?.turns ?? 0,
1497 prompt: call.prompt,
1498 queued: undefined,
1499 };
1500 const child =
1501 previous ?? newThreadState(childId, providerThread, call.state.directory, subagent);
1502 child.subagent = subagent;
1503 child.model = info?.model ?? child.model;
1504 child.providerThread = withReportedModel(providerThread, child.model);
1505 child.directory = call.state.directory;
1506 child.agent = info?.agent ?? call.agent ?? child.agent;
1507 // OpenCode gives a new session its parent's rules, which are the thread's.
1508 child.rules = info?.permissions ?? child.rules;
1509 threads.set(childId, child);
1510 childOwners.set(childId, rootOf(call.state));
1511 call.child = child;
1512 yield* emit({ type: "app_thread.created", driver, appThread });
1513 yield* emit({
1514 type: "provider_thread.updated",
1515 driver,
1516 providerThread: child.providerThread,
1517 });
1518 yield* emitSubagent(call);
1519 // A session called again was not made now, so it may hold the rules of
1520 // a mode the thread has left. OpenCode applies a rules change to the
1521 // asks after it, so it gets the thread's as soon as its call names it.
1522 if (info === undefined) {
1523 yield* writeRules(child, rootOf(call.state).policy).pipe(
1524 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1525 Effect.ignore({ log: true }),
1526 );
1527 }
1528 });
1529
1530 /**
1531 * Ends a subagent call and every call under it that is still running. A
1532 * background one outlives the call that made it, as it outlives a turn,
1533 * unless that call failed.
1534 */
1535 const settleCall: (
1536 call: SubagentCall,
1537 status: OrchestrationV2Subagent["status"],
1538 result?: string,
1539 ) => Effect.Effect<void> = Effect.fnUntraced(function* (call, status, result) {
1540 if (!isOrchestrationV2WorkActive(call.status)) return;
1541 call.status = status;
1542 call.completedAt = yield* DateTime.now;
1543 if (result !== undefined) call.result = result;
1544 call.state.calls.delete(call.toolId);
1545 const child = call.child;
1546 if (child !== undefined) {
1547 // A snapshot: settling a call removes it from the map.
1548 for (const nested of Array.from(child.calls.values())) {
1549 if (nested.background && status !== "failed") continue;
1550 yield* settleCall(nested, status === "completed" ? "interrupted" : status);
1551 }
1552 if (child.active !== undefined && status !== "completed") {
1553 yield* finishTurn(child, { status: "interrupted" });
1554 }
1555 child.providerThread = {
1556 ...child.providerThread,
1557 status: "idle",
1558 updatedAt: call.completedAt,
1559 };
1560 yield* emit({
1561 type: "provider_thread.updated",
1562 driver,
1563 providerThread: child.providerThread,
1564 });
1565 }
1566 yield* emitSubagent(call);
1567 });
1568
1569 /**
1570 * Opens the runless turn a subagent's session runs each execution in, on
1571 * its child thread, with the prompt it answers as the turn's user message.
1572 */
1573 const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) {
1574 const subagent = child.subagent;
1575 if (subagent === undefined || child.active !== undefined) return;
1576 subagent.turns += 1;
1577 const startedAt = yield* DateTime.now;
1578 const nativeTurnId = `${child.sessionId}:turn:${subagent.turns}`;
1579 const rootNodeId = idAllocator.derive.nodeFromProviderItem({
1580 driver,
1581 nativeItemId: `${nativeTurnId}:root`,
1582 });
1583 const providerTurn: OrchestrationV2ProviderTurn = {
1584 id: idAllocator.derive.providerTurn({ driver, nativeTurnId }),
1585 providerThreadId: child.providerThread.id,
1586 nodeId: rootNodeId,
1587 runAttemptId: null,
1588 nativeTurnRef: ref(nativeTurnId, "weak"),
1589 ordinal: subagent.turns,
1590 status: "running",
1591 startedAt,
1592 completedAt: null,
1593 };
1594 const turn = makeTurn(
1595 {
1596 appThread: subagent.appThread,
1597 threadId: subagent.appThread.id,
1598 runId: null,
1599 runOrdinal: subagent.call.turn.input.runOrdinal,
1600 rootNodeId,
1601 modelSelection: subagent.appThread.modelSelection,
1602 runtimePolicy: subagent.call.turn.input.runtimePolicy,
1603 },
1604 providerTurn,
1605 { scope: `${child.sessionId}:`, awaitingStart: false },
1606 );
1607 turn.before = subagent.queued;
1608 subagent.queued = undefined;
1609 child.active = turn;
1610 yield* emit({
1611 type: "node.updated",
1612 driver,
1613 node: {
1614 id: rootNodeId,
1615 threadId: turn.input.threadId,
1616 runId: null,
1617 parentNodeId: null,
1618 rootNodeId,
1619 kind: "root_turn",
1620 status: "running",
1621 countsForRun: false,
1622 providerThreadId: child.providerThread.id,
1623 providerTurnId: providerTurn.id,
1624 nativeItemRef: ref(nativeTurnId, "weak"),
1625 runtimeRequestId: null,
1626 checkpointScopeId: null,
1627 startedAt,
1628 completedAt: null,
1629 },
1630 });
1631 yield* emitProviderTurn(child, turn, providerTurn);
1632 const prompt = subagent.prompt;
1633 subagent.prompt = undefined;
1634 if (prompt !== undefined && prompt.length > 0) {
1635 const nativeId = `${nativeTurnId}:prompt`;
1636 const artifacts = makeSubagentConversationArtifacts({
1637 messageId: idAllocator.derive.messageFromProviderItem({ driver, nativeItemId: nativeId }),
1638 senderThreadId: subagent.call.turn.input.threadId,
1639 turnItemId: idAllocator.derive.turnItemFromProviderItem({
1640 driver,
1641 nativeItemId: nativeId,
1642 }),
1643 threadId: turn.input.threadId,
1644 rootNodeId,
1645 providerThreadId: child.providerThread.id,
1646 providerTurnId: providerTurn.id,
1647 nativeItemRef: ref(nativeId, "weak"),
1648 role: "user",
1649 text: prompt,
1650 ordinal: ordinalOf(turn, nativeId),
1651 now: startedAt,
1652 });
1653 yield* emit({ type: "message.updated", driver, message: artifacts.message });
1654 yield* emit({ type: "turn_item.updated", driver, turnItem: artifacts.turnItem });
1655 }
1656 });
1657
1658 /**
1659 * Asks the orchestrator for the continuation turn a held wake needs. It
1660 * names the subagents whose end woke the thread. A wake a Stop or an
1661 * earlier turn already took needs no turn, so its offer is dropped.
1662 */
1663 const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) {
1664 const route = state.providerThread.appThreadId;
1665 if (route === null) return;
1666 const notification = backgroundWorkNotification(wake.reports);
1667 yield* continuationRequests.offer({
1668 threadId: route,
1669 providerThreadId: state.providerThread.id,
1670 driver,
1671 detail: wake.detail,
1672 ...(notification === null ? {} : { notification }),
1673 dispatchIfCurrent: (dispatch) =>
1674 wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some),
1675 });
1676 });
1677
1678 const finishTurn = Effect.fnUntraced(function* (
1679 state: ThreadState,
1680 terminal: TurnTerminal,
1681 threadDisposition: "reusable" | "broken" = "reusable",
1682 ) {
1683 const turn = state.active;
1684 if (turn === undefined) return;
1685 state.active = undefined;
1686 for (const inboxID of turn.steers) state.strandedSteers.add(inboxID);
1687 // OpenCode drops a request when the asking session's execution ends. A
1688 // subagent's request shown on this turn ends with it too, unless the
1689 // subagent runs in the background past a turn that did not fail.
1690 for (const entry of pending.values()) {
1691 const background = callsAbove(entry.sessionId).some((call) => call.background);
1692 if (
1693 entry.sessionId === state.sessionId ||
1694 (entry.turn === turn && !(background && terminal.status !== "failed"))
1695 ) {
1696 yield* settleRequest(entry, "cancelled");
1697 }
1698 }
1699 const completedAt = yield* DateTime.now;
1700 // Blocks still open when the execution ends are final as they stand.
1701 for (const open of turn.texts.values()) {
1702 yield* emitText(state, turn, open.block, open.kind, (text) => text, true);
1703 }
1704 for (const id of turn.tools.keys()) {
1705 yield* emitTool(
1706 state,
1707 turn,
1708 id,
1709 terminal.status === "completed" ? "completed" : "interrupted",
1710 );
1711 }
1712 // A foreground subagent ends with the turn that waits on it. A background
1713 // one outlives a finished or interrupted turn (a user Stop has already
1714 // stopped it), and a failed turn stops it.
1715 // A snapshot: settling a call removes it from the map.
1716 for (const call of Array.from(state.calls.values())) {
1717 if (call.turn !== turn) continue;
1718 if (call.background && terminal.status !== "failed") continue;
1719 yield* settleCall(call, terminal.status === "completed" ? "completed" : terminal.status);
1720 }
1721 if (state.subagent !== undefined) {
1722 yield* emit({
1723 type: "node.updated",
1724 driver,
1725 node: {
1726 id: turn.input.rootNodeId,
1727 threadId: turn.input.threadId,
1728 runId: null,
1729 parentNodeId: null,
1730 rootNodeId: turn.input.rootNodeId,
1731 kind: "root_turn",
1732 status: terminal.status,
1733 countsForRun: false,
1734 providerThreadId: state.providerThread.id,
1735 providerTurnId: turn.providerTurn.id,
1736 nativeItemRef: turn.providerTurn.nativeTurnRef,
1737 runtimeRequestId: null,
1738 checkpointScopeId: null,
1739 startedAt: turn.providerTurn.startedAt,
1740 completedAt,
1741 },
1742 });
1743 }
1744 yield* emitCompaction(state, turn, terminal.status === "failed" ? "failed" : "interrupted");
1745 const window = windowOf(turn.input.runtimePolicy.cwd, turn.input.modelSelection.model);
1746 const lastStep = turn.lastStep;
1747 yield* emitProviderTurn(state, turn, {
1748 ...turn.providerTurn,
1749 status: terminal.status,
1750 completedAt,
1751 turnTokenUsage: turnTokenUsage(turn, terminal.status),
1752 ...(lastStep === undefined
1753 ? {}
1754 : {
1755 tokenUsage: {
1756 usedTokens:
1757 lastStep.input + lastStep.cache.read + lastStep.cache.write + lastStep.output,
1758 maxTokens: window ?? null,
1759 inputTokens: lastStep.input + lastStep.cache.read + lastStep.cache.write,
1760 cachedInputTokens: lastStep.cache.read,
1761 outputTokens: lastStep.output,
1762 reasoningOutputTokens: lastStep.reasoning,
1763 updatedAt: DateTime.formatIso(completedAt),
1764 },
1765 }),
1766 });
1767 // A subagent's session is its call's to settle: a turn there is runless,
1768 // and T3 has no terminal to wait on.
1769 if (state.subagent !== undefined) {
1770 const call = state.subagent.call;
1771 if (!call.background && terminal.status !== "completed") {
1772 yield* settleCall(call, terminal.status);
1773 }
1774 return;
1775 }
1776 state.providerThread = {
1777 ...state.providerThread,
1778 status: threadDisposition === "broken" ? "error" : "idle",
1779 updatedAt: completedAt,
1780 };
1781 yield* emit({
1782 type: "provider_thread.updated",
1783 driver,
1784 providerThread: state.providerThread,
1785 });
1786 const anyActive = [...threads.values()].some(
1787 (candidate) => candidate.subagent === undefined && candidate.active !== undefined,
1788 );
1789 yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null);
1790 const base = {
1791 type: "turn.terminal" as const,
1792 driver,
1793 // The provider thread the turn started on. After a native fork the
1794 // session's own thread has a fresh id, and the terminal must name the
1795 // thread the start was recorded against.
1796 providerThreadId: turn.providerTurn.providerThreadId,
1797 providerTurnId: turn.providerTurn.id,
1798 runOrdinal: turn.input.runOrdinal,
1799 threadDisposition,
1800 };
1801 yield* emit(
1802 terminal.status === "failed"
1803 ? {
1804 ...base,
1805 status: "failed",
1806 failure: terminal.failure,
1807 failureItemOrdinal: ordinalOf(turn, `terminal-failure:${turn.providerTurn.id}`),
1808 }
1809 : { ...base, status: terminal.status, failure: null },
1810 );
1811 // A spawned server lists its models lazily, so a window still unknown is
1812 // read again for the next turn, off this stream so it never delays one.
1813 if (window === undefined) {
1814 yield* Effect.forkIn(readModels(directoryOf(turn.input.runtimePolicy.cwd)), sessionScope);
1815 }
1816 });
1817
1818 /**
1819 * Ends the turn a request T3 could not answer would block, and stops the
1820 * session: OpenCode waits on an unanswered request forever. The stopped
1821 * run's end is its own, not the next turn's.
1822 */
1823 const abandonRequest = Effect.fnUntraced(function* (state: ThreadState, reason: string) {
1824 yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", {
1825 reason,
1826 });
1827 if (state.active === undefined) return;
1828 state.unsettled = true;
1829 yield* finishTurn(state, {
1830 status: "failed",
1831 failure: makeProviderFailure({
1832 message: "OpenCode is waiting on a request T3 Code couldn't answer.",
1833 class: "provider_error",
1834 }),
1835 });
1836 yield* client.session
1837 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
1838 .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true }));
1839 });
1840
1841 /** Stops tracking a request; the session is waiting only while any remain. */
1842 const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) {
1843 if (!pending.delete(entry.request.id)) return false;
1844 if (pending.size === 0 && session.status === "waiting") {
1845 yield* setSessionStatus("running", null);
1846 }
1847 return true;
1848 });
1849
1850 /**
1851 * Settles a request OpenCode answered or dropped on its own. T3's own
1852 * answers are only forgotten: the orchestrator already recorded them.
1853 */
1854 const settleRequest = Effect.fnUntraced(function* (
1855 entry: PendingRequest,
1856 status: "resolved" | "cancelled",
1857 ) {
1858 if (!(yield* forgetRequest(entry)) || entry.answering) return;
1859 const now = yield* DateTime.now;
1860 const itemStatus = status === "resolved" ? "completed" : "cancelled";
1861 yield* emit({
1862 type: "runtime_request.updated",
1863 driver,
1864 threadId: entry.turn.input.threadId,
1865 runtimeRequest: { ...entry.request, status, resolvedAt: now },
1866 });
1867 yield* emit({
1868 type: "node.updated",
1869 driver,
1870 node: { ...entry.node, status: itemStatus, completedAt: now },
1871 });
1872 yield* emit({
1873 type: "turn_item.updated",
1874 driver,
1875 turnItem: { ...entry.item, status: itemStatus, completedAt: now, updatedAt: now },
1876 });
1877 });
1878
1879 /**
1880 * Stops a run no turn of T3's waits on (one a Stop left running, or one
1881 * found asking after a reconnect): a reject without a message and a
1882 * cancelled form both end OpenCode's execution. Its end is not a turn's.
1883 */
1884 const stopStaleRequest = (
1885 sessionId: string,
1886 native: { readonly type: "permission" | "form"; readonly id: string },
1887 ): Effect.Effect<void> =>
1888 native.type === "permission"
1889 ? client.permission
1890 .reply({
1891 sessionID: Session.ID.make(sessionId),
1892 requestID: Permission.ID.make(native.id),
1893 decision: "reject",
1894 })
1895 .pipe(
1896 Effect.catchTags(permissionGone),
1897 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1898 Effect.ignore({ log: true }),
1899 )
1900 : client.session.form
1901 .cancel({ sessionID: sessionId, formID: Form.ID.make(native.id) })
1902 .pipe(
1903 Effect.catchTags(formGone),
1904 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1905 Effect.ignore({ log: true }),
1906 );
1907
1908 /**
1909 * Stops what a session still waits on when this runtime first loads it:
1910 * T3 shows none of those requests (a restart or a closed session expired
1911 * them), and OpenCode would wait on them forever.
1912 */
1913 const stopLeftoverRequests = Effect.fnUntraced(function* (state: ThreadState) {
1914 const sessionID = Session.ID.make(state.sessionId);
1915 const listed = yield* Effect.all([
1916 client.permission.list({ sessionID }),
1917 client.session.form.list({ sessionID }),
1918 ]).pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT), Effect.option);
1919 if (listed._tag === "None") {
1920 return yield* Effect.logWarning("Could not list an OpenCode session's open requests.");
1921 }
1922 const [permissions, forms] = listed.value;
1923 if (permissions.length === 0 && forms.length === 0) return;
1924 // The stopped run's end is not the next turn's.
1925 state.unsettled = true;
1926 for (const request of permissions) {
1927 yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id });
1928 }
1929 for (const form of forms) {
1930 yield* stopStaleRequest(state.sessionId, { type: "form", id: form.id });
1931 }
1932 });
1933
1934 /**
1935 * Shows a permission ask or question form on the thread whose session (or
1936 * subagent session) asked, under that thread's running turn. A request no
1937 * turn of T3's is waiting on is left for OpenCode's own clients.
1938 */
1939 const showRequest = Effect.fnUntraced(function* (
1940 sessionId: string,
1941 native: PendingRequest["native"],
1942 body:
1943 | {
1944 readonly type: "approval_request";
1945 readonly requestKind: Extract<
1946 OrchestrationV2TurnItem,
1947 { type: "approval_request" }
1948 >["requestKind"];
1949 readonly prompt: string;
1950 readonly options: Extract<
1951 OrchestrationV2TurnItem,
1952 { type: "approval_request" }
1953 >["options"];
1954 }
1955 | {
1956 readonly type: "user_input_request";
1957 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
1958 },
1959 ) {
1960 const target = requestTurn(sessionId);
1961 if (target === undefined) return;
1962 const { state, turn } = target;
1963 if ([...pending.values()].some((entry) => entry.native.id === native.id)) return;
1964 const now = yield* DateTime.now;
1965 const requestId = yield* idAllocator.allocate.runtimeRequest({
1966 driver,
1967 providerTurnId: turn.providerTurn.id,
1968 nativeRequestId: native.id,
1969 });
1970 const nodeId = idAllocator.derive.approvalNode({ requestId });
1971 const request: OrchestrationV2RuntimeRequest = {
1972 id: requestId,
1973 nodeId,
1974 providerTurnId: turn.providerTurn.id,
1975 nativeRequestRef: ref(native.id),
1976 kind: body.type === "approval_request" ? body.requestKind : "user_input",
1977 status: "pending",
1978 responseCapability: { type: "live", providerSessionId: input.providerSessionId },
1979 createdAt: now,
1980 resolvedAt: null,
1981 };
1982 const node: OrchestrationV2ExecutionNode = {
1983 id: nodeId,
1984 threadId: turn.input.threadId,
1985 runId: turn.input.runId,
1986 parentNodeId: turn.input.rootNodeId,
1987 rootNodeId: turn.input.rootNodeId,
1988 kind: body.type,
1989 status: "waiting",
1990 countsForRun: false,
1991 providerThreadId: state.providerThread.id,
1992 providerTurnId: turn.providerTurn.id,
1993 nativeItemRef: ref(native.id),
1994 runtimeRequestId: requestId,
1995 checkpointScopeId: null,
1996 startedAt: now,
1997 completedAt: null,
1998 };
1999 const base = {
2000 id: idAllocator.derive.approvalTurnItem({ requestId }),
2001 threadId: turn.input.threadId,
2002 runId: turn.input.runId,
2003 nodeId,
2004 providerThreadId: state.providerThread.id,
2005 providerTurnId: turn.providerTurn.id,
2006 nativeItemRef: ref(native.id),
2007 parentItemId: null,
2008 ordinal: ordinalOf(turn, native.id),
2009 status: "waiting" as const,
2010 startedAt: now,
2011 completedAt: null,
2012 updatedAt: now,
2013 };
2014 const item: OrchestrationV2TurnItem =
2015 body.type === "approval_request"
2016 ? {
2017 ...base,
2018 title: native.type === "permission" ? native.action : null,
2019 type: "approval_request",
2020 requestId,
2021 requestKind: body.requestKind,
2022 prompt: body.prompt,
2023 ...(body.options === undefined ? {} : { options: body.options }),
2024 }
2025 : {
2026 ...base,
2027 title: "User input",
2028 type: "user_input_request",
2029 requestId,
2030 questions: body.questions,
2031 };
2032 pending.set(requestId, {
2033 request,
2034 item,
2035 node,
2036 state,
2037 turn,
2038 sessionId,
2039 answering: false,
2040 native,
2041 });
2042 yield* emit({ type: "node.updated", driver, node });
2043 yield* emit({
2044 type: "runtime_request.updated",
2045 driver,
2046 threadId: turn.input.threadId,
2047 runtimeRequest: request,
2048 });
2049 yield* emit({ type: "turn_item.updated", driver, turnItem: item });
2050 yield* setSessionStatus("waiting", null);
2051 });
2052
2053 const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) {
2054 // The asking session's own turn knows the tool: a subagent's session runs its own.
2055 const turn = threads.get(data.sessionID)?.active;
2056 const toolName =
2057 data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name;
2058 const save = data.save ?? [];
2059 yield* showRequest(
2060 data.sessionID,
2061 { type: "permission", id: data.id, action: data.action, resources: data.resources, save },
2062 {
2063 type: "approval_request",
2064 requestKind: openCodePermissionRequestKind(data.action, toolName),
2065 prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"),
2066 // "Always" in OpenCode saves a grant for the whole project, so the
2067 // session-wide choice is T3's own rule on this session instead.
2068 options: [
2069 { decision: "cancel", label: "Cancel" },
2070 { decision: "decline", label: "Decline" },
2071 ...(save.length > 0
2072 ? [
2073 {
2074 decision: "acceptForSession" as const,
2075 label: sessionGrantLabel(data.action, save),
2076 },
2077 ]
2078 : []),
2079 { decision: "accept", label: "Approve" },
2080 ],
2081 },
2082 );
2083 });
2084
2085 const onFormCreated = Effect.fnUntraced(function* (form: NativeForm) {
2086 const target = requestTurn(form.sessionID);
2087 if (target === undefined) return;
2088 const { state } = target;
2089 const mapped = formQuestions(form);
2090 if ("questions" in mapped) {
2091 return yield* showRequest(
2092 form.sessionID,
2093 { type: "form", id: form.id, form },
2094 { type: "user_input_request", questions: mapped.questions },
2095 );
2096 }
2097 // Cancelling ends OpenCode's execution as a user stop, so the turn is
2098 // failed here with the reason and that stop's end is skipped.
2099 yield* Effect.logWarning("Declined an OpenCode form T3 Code cannot show.", {
2100 reason: mapped.unsupported,
2101 });
2102 const cancelled = yield* deliver(
2103 client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }),
2104 );
2105 // The session that asked is the one blocked on the form and stopped by
2106 // its cancel: a subagent's own, whose parent reads its failed call and
2107 // goes on. A subagent's session has no Stop end to skip.
2108 const asker = threads.get(form.sessionID) ?? state;
2109 if (!cancelled) return yield* abandonRequest(asker, "form cancel failed");
2110 if (asker.subagent === undefined) asker.unsettled = true;
2111 yield* finishTurn(asker, {
2112 status: "failed",
2113 failure: makeProviderFailure({
2114 message: `OpenCode asked for ${mapped.unsupported}, which T3 Code can't show. The question was declined.`,
2115 class: "provider_error",
2116 }),
2117 });
2118 });
2119
2120 /**
2121 * A permission ask or question form, from the stream or read back after a
2122 * reconnect: shown under the turn it blocks, or stopped when it is the run
2123 * a Stop left behind, which nothing answers.
2124 */
2125 const onAsked = Effect.fnUntraced(function* (
2126 asked:
2127 | { readonly type: "permission"; readonly request: Permission.Request }
2128 | { readonly type: "form"; readonly form: NativeForm },
2129 ) {
2130 const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID;
2131 const state = ownerOf(asking);
2132 if (state === undefined) return;
2133 const target = requestTurn(asking);
2134 if (target !== undefined && !target.turn.awaitingStart) {
2135 if (asked.type === "permission") return yield* onPermissionAsked(asked.request);
2136 return yield* onFormCreated(asked.form);
2137 }
2138 if (state.unsettled || state.active?.awaitingStart === true) {
2139 yield* stopStaleRequest(
2140 asking,
2141 asked.type === "permission"
2142 ? { type: "permission", id: asked.request.id }
2143 : { type: "form", id: asked.form.id },
2144 );
2145 }
2146 });
2147
2148 const sessionOfEvent = (event: OpenCode2StreamEvent) =>
2149 event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started"
2150 ? event.sessionID
2151 : "sessionID" in event.data && typeof event.data.sessionID === "string"
2152 ? event.data.sessionID
2153 : undefined;
2154
2155 const executionEnd = (type: string) =>
2156 type === "session.execution.succeeded" ||
2157 type === "session.execution.failed" ||
2158 type === "session.execution.interrupted";
2159
2160 /** A `subagent` call's item, made when the model starts it. */
2161 const startCall = Effect.fnUntraced(function* (
2162 state: ThreadState,
2163 turn: ActiveTurn,
2164 toolId: string,
2165 ) {
2166 const nativeId = `${turn.scope}${toolId}`;
2167 const call: SubagentCall = {
2168 toolId,
2169 nativeId,
2170 state,
2171 turn,
2172 ordinal: ordinalOf(turn, nativeId),
2173 startedAt: yield* DateTime.now,
2174 prompt: "",
2175 title: null,
2176 agent: undefined,
2177 model: null,
2178 background: false,
2179 child: undefined,
2180 status: "running",
2181 result: null,
2182 completedAt: null,
2183 };
2184 turn.usedSubagents = true;
2185 state.calls.set(toolId, call);
2186 yield* emitSubagent(call);
2187 });
2188
2189 /**
2190 * One event for a session's running turn: its text, tools, steps and end.
2191 * A `subagent` call is its item and child thread rather than a tool.
2192 */
2193 const onTurnEvent = Effect.fnUntraced(function* (
2194 state: ThreadState,
2195 turn: ActiveTurn,
2196 event: OpenCode2StreamEvent,
2197 ) {
2198 switch (event.type) {
2199 case "session.text.started":
2200 case "session.reasoning.started":
2201 case "session.text.delta":
2202 case "session.reasoning.delta":
2203 case "session.text.ended":
2204 case "session.reasoning.ended": {
2205 const data = event.data;
2206 const kind = event.type.startsWith("session.text.") ? "text" : "reasoning";
2207 return yield* emitText(state, turn, data, kind, (text) =>
2208 "delta" in data ? text + data.delta : "text" in data ? data.text : text,
2209 );
2210 }
2211 case "session.tool.input.started":
2212 // Its form is the item the user answers; the tool call would repeat it.
2213 if (event.data.name === "question") return;
2214 if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id);
2215 turn.tools.set(event.data.id, { name: event.data.name, input: {} });
2216 turn.startedAt.set(event.data.id, yield* DateTime.now);
2217 return yield* emitTool(state, turn, event.data.id, "running");
2218 case "session.tool.called": {
2219 const call = state.calls.get(event.data.id);
2220 if (call !== undefined) {
2221 const { input } = event.data;
2222 call.prompt = stringField(input, "prompt") ?? call.prompt;
2223 call.title = stringField(input, "description") ?? call.title;
2224 call.agent = stringField(input, "agent");
2225 call.model = stringField(input, "model") ?? call.model;
2226 call.background = input["background"] === true;
2227 return yield* emitSubagent(call);
2228 }
2229 const tool = turn.tools.get(event.data.id);
2230 if (tool !== undefined) tool.input = event.data.input;
2231 return yield* emitTool(state, turn, event.data.id, "running");
2232 }
2233 case "session.tool.progress": {
2234 const call = state.calls.get(event.data.id);
2235 const childId = stringField(event.data.metadata, "sessionID");
2236 if (call !== undefined && childId !== undefined) yield* attachChild(call, childId);
2237 return;
2238 }
2239 case "session.tool.success": {
2240 const call = state.calls.get(event.data.id);
2241 if (call !== undefined) {
2242 const childId = stringField(event.data.metadata, "sessionID");
2243 if (childId !== undefined) yield* attachChild(call, childId);
2244 // A background call returns at launch; its report settles it.
2245 if (event.data.metadata?.["status"] === "running") return;
2246 return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content)));
2247 }
2248 const output = textOf(event.data.content);
2249 yield* emitTool(state, turn, event.data.id, "completed", {
2250 output,
2251 metadata: event.data.metadata,
2252 });
2253 turn.tools.delete(event.data.id);
2254 return;
2255 }
2256 case "session.tool.failed": {
2257 const aborted = event.data.error.type === "aborted";
2258 const call = state.calls.get(event.data.id);
2259 // A refused call (unknown agent, the nesting limit) is a failed subagent.
2260 if (call !== undefined) {
2261 return yield* settleCall(
2262 call,
2263 aborted ? "interrupted" : "failed",
2264 event.data.error.message,
2265 );
2266 }
2267 yield* emitTool(state, turn, event.data.id, aborted ? "interrupted" : "failed", {
2268 output: event.data.error.message,
2269 metadata: event.data.metadata,
2270 });
2271 turn.tools.delete(event.data.id);
2272 return;
2273 }
2274 // `/compact` and OpenCode's own compaction when the context fills
2275 // (`reason: "auto"`) both run inside a turn's execution.
2276 case "session.compaction.started":
2277 turn.compaction = {
2278 nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`,
2279 startedAt: yield* DateTime.now,
2280 };
2281 return yield* emitCompaction(state, turn, "running");
2282 case "session.compaction.ended":
2283 return yield* emitCompaction(state, turn, "completed", event.data.text);
2284 case "session.compaction.failed":
2285 return yield* emitCompaction(state, turn, "failed");
2286 case "session.step.ended":
2287 case "session.step.failed": {
2288 const tokens = event.data.tokens;
2289 if (tokens === undefined) return;
2290 turn.steps += 1;
2291 turn.lastStep = tokens;
2292 turn.usage.input += tokens.input + tokens.cache.read + tokens.cache.write;
2293 turn.usage.cached += tokens.cache.read;
2294 turn.usage.cacheWrite += tokens.cache.write;
2295 turn.usage.output += tokens.output + tokens.reasoning;
2296 turn.usage.reasoning += tokens.reasoning;
2297 return;
2298 }
2299 case "session.execution.started":
2300 // The execution that delivers steers the last one ended without.
2301 turn.heldEnd = undefined;
2302 return;
2303 case "session.execution.succeeded":
2304 return yield* endExecution(state, turn, {
2305 status: turn.interrupted ? "interrupted" : "completed",
2306 });
2307 case "session.execution.interrupted":
2308 return yield* endExecution(state, turn, { status: "interrupted" });
2309 case "session.execution.failed":
2310 return yield* endExecution(state, turn, {
2311 status: "failed",
2312 failure: makeProviderFailure({
2313 message: event.data.error.message,
2314 code: event.data.error.type,
2315 class: "provider_error",
2316 }),
2317 });
2318 default:
2319 return;
2320 }
2321 });
2322
2323 /**
2324 * An execution OpenCode started on a thread's session with no turn of T3's
2325 * running: the parent's answer to a background subagent's report. It is
2326 * held for the continuation turn it asks for, or stopped when it only
2327 * reports subagents a Stop ended.
2328 */
2329 /**
2330 * Takes the reports an execution OpenCode started on its own delivers. When
2331 * they all report subagents a Stop ended, that execution only answers the
2332 * Stop, so it is stopped and `stopped` is true.
2333 */
2334 const takeReports = Effect.fnUntraced(function* (state: ThreadState) {
2335 const delivered = [...state.reports.values()];
2336 state.reports.clear();
2337 const stopped =
2338 delivered.length > 0 &&
2339 delivered.every((entry) => state.stoppedChildren.has(entry.childId));
2340 for (const entry of delivered) state.stoppedChildren.delete(entry.childId);
2341 if (stopped) {
2342 state.unsettled = true;
2343 yield* client.session
2344 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
2345 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
2346 }
2347 return { delivered, stopped };
2348 });
2349
2350 const onWake = Effect.fnUntraced(function* (state: ThreadState) {
2351 const { delivered, stopped } = yield* takeReports(state);
2352 if (stopped) return;
2353 const wake: Wake = {
2354 events: [],
2355 running: true,
2356 reports: delivered.map((entry) => entry.report),
2357 detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"),
2358 dropped: false,
2359 after: delivered.at(-1)?.inboxId,
2360 first: delivered[0]?.inboxId,
2361 };
2362 state.wakes.push(wake);
2363 yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", {
2364 providerThreadId: state.providerThread.id,
2365 });
2366 yield* offerWake(state, wake);
2367 });
2368
2369 /** A background subagent's end, as OpenCode queues it for its parent. */
2370 const onReport = Effect.fnUntraced(function* (
2371 state: ThreadState,
2372 inboxId: string,
2373 payload: {
2374 readonly text: string;
2375 readonly metadata?: Readonly<Record<string, unknown>> | undefined;
2376 },
2377 ) {
2378 const childId = stringField(payload.metadata, "childID");
2379 if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return;
2380 const call = [...state.calls.values()].find(
2381 (candidate) => candidate.child?.sessionId === childId,
2382 );
2383 const outcome = reportOutcome(stringField(payload.metadata, "state"));
2384 state.reports.set(inboxId, {
2385 inboxId,
2386 childId,
2387 text: payload.text,
2388 report: {
2389 kind: "subagent",
2390 label: call?.title ?? stringField(payload.metadata, "description"),
2391 childThreadId: call?.child?.subagent?.appThread.id,
2392 outcome,
2393 },
2394 });
2395 if (call === undefined) return;
2396 yield* settleCall(
2397 call,
2398 state.stoppedChildren.has(childId)
2399 ? "interrupted"
2400 : outcome === "failed"
2401 ? "failed"
2402 : outcome === "cancelled"
2403 ? "cancelled"
2404 : "completed",
2405 subagentOutput(payload.text),
2406 );
2407 });
2408
2409 /**
2410 * The thread whose held execution an event belongs to: the thread's own
2411 * session, or a subagent session its held execution started.
2412 */
2413 const holderOf = (sessionId: string) => {
2414 const own = threads.get(sessionId);
2415 if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined;
2416 // A subagent a held execution started is named only when that execution replays.
2417 const parent = announced.get(sessionId)?.parentID;
2418 const holder = parent === undefined ? undefined : threads.get(parent);
2419 return holder !== undefined && holder.active === undefined && holder.wakes.length > 0
2420 ? holder
2421 : undefined;
2422 };
2423
2424 /**
2425 * Ends the turn with its execution, unless a steer sent into it is still
2426 * undelivered: OpenCode then starts another execution for it, which the
2427 * turn owns too. A Stop leaves steers undelivered and starts nothing.
2428 */
2429 const endExecution = Effect.fnUntraced(function* (
2430 state: ThreadState,
2431 turn: ActiveTurn,
2432 end: TurnTerminal,
2433 ) {
2434 if (end.status === "completed" && turn.steers.size > 0) {
2435 turn.heldEnd = end;
2436 return;
2437 }
2438 yield* finishTurn(state, end);
2439 });
2440
2441 /** Ends a turn held for its steers once none is left to deliver. */
2442 const endIfSettled = (state: ThreadState, turn: ActiveTurn) =>
2443 turn.heldEnd !== undefined && turn.steers.size === 0
2444 ? finishTurn(state, turn.heldEnd)
2445 : Effect.void;
2446
2447 const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) {
2448 const sessionId = sessionOfEvent(event);
2449 // `revert.clear` wakes the session into an empty execution of its own
2450 // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of
2451 // T3's, and no follow-up to a subagent either.
2452 const cleared = sessionId === undefined ? undefined : clearing.get(sessionId);
2453 if (cleared !== undefined) {
2454 if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) {
2455 return yield* Deferred.succeed(cleared, undefined);
2456 }
2457 if (
2458 event.type === "session.execution.started" ||
2459 event.type === "unreadable.execution.started"
2460 ) {
2461 return;
2462 }
2463 }
2464 if (sessionId !== undefined) {
2465 if (
2466 event.type === "session.execution.started" ||
2467 event.type === "unreadable.execution.started"
2468 ) {
2469 busy.add(sessionId);
2470 } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) {
2471 busy.delete(sessionId);
2472 }
2473 }
2474 // A subagent's session: named on its parent's `subagent` call just after.
2475 if (event.type === "session.created" && event.data.parentID !== undefined) {
2476 const owner = ownerOf(event.data.parentID);
2477 if (owner !== undefined) {
2478 childOwners.set(event.data.sessionID, owner);
2479 announced.set(event.data.sessionID, event.data);
2480 }
2481 return;
2482 }
2483 // What a held execution does, and what the subagents it starts do,
2484 // waits for the turn that takes it.
2485 const holder = sessionId === undefined ? undefined : holderOf(sessionId);
2486 const held = holder?.wakes.at(-1);
2487 if (holder !== undefined && held !== undefined) {
2488 held.events.push(event);
2489 if (
2490 sessionId === holder.sessionId &&
2491 (event.type === "unreadable.execution.ended" || executionEnd(event.type))
2492 ) {
2493 held.running = false;
2494 }
2495 return;
2496 }
2497 return yield* route(event, sessionId);
2498 });
2499
2500 /** Handles one event for its session, as the turn running there sees it. */
2501 const route = Effect.fnUntraced(function* (
2502 event: OpenCode2StreamEvent,
2503 sessionId: string | undefined,
2504 ) {
2505 // The end of the run a timed-out Stop left behind; no turn is its own.
2506 const ended =
2507 event.type === "unreadable.execution.ended" || executionEnd(event.type)
2508 ? threads.get(sessionId ?? "")
2509 : undefined;
2510 if (ended?.unsettled === true) {
2511 ended.unsettled = false;
2512 return;
2513 }
2514 // Marks where a running turn's own execution begins; it never ends one.
2515 // With no turn running it is a subagent's or a follow-up's start, below.
2516 if (event.type === "unreadable.execution.started") {
2517 const turn = threads.get(event.sessionID)?.active;
2518 if (turn !== undefined) {
2519 turn.awaitingStart = false;
2520 turn.heldEnd = undefined;
2521 return;
2522 }
2523 }
2524 if (event.type === "unreadable.execution.ended") {
2525 const state = threads.get(event.sessionID);
2526 const turn = state?.active;
2527 if (state === undefined || turn === undefined || turn.awaitingStart) return;
2528 return yield* endExecution(
2529 state,
2530 turn,
2531 event.executionType === "session.execution.succeeded"
2532 ? { status: turn.interrupted ? "interrupted" : "completed" }
2533 : event.executionType === "session.execution.interrupted"
2534 ? { status: "interrupted" }
2535 : {
2536 status: "failed",
2537 failure: makeProviderFailure({
2538 message: "OpenCode ended the turn with an error this version cannot read.",
2539 class: "provider_error",
2540 }),
2541 },
2542 );
2543 }
2544 if (event.type === "permission.asked") {
2545 return yield* onAsked({ type: "permission", request: event.data });
2546 }
2547 if (event.type === "form.created")
2548 return yield* onAsked({ type: "form", form: event.data.form });
2549 // Answered in another OpenCode client, or dropped by OpenCode: a reject
2550 // it sends on its own (a Stop, or another reject in the same session)
2551 // cancels the request. T3's own answers are settled where they are sent.
2552 if (
2553 event.type === "permission.replied" ||
2554 event.type === "form.replied" ||
2555 event.type === "form.cancelled"
2556 ) {
2557 const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id;
2558 const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId);
2559 if (entry === undefined || entry.answering) return;
2560 const answered =
2561 event.type === "form.replied" ||
2562 (event.type === "permission.replied" && event.data.reply !== "reject");
2563 return yield* settleRequest(entry, answered ? "resolved" : "cancelled");
2564 }
2565 if (sessionId === undefined) return;
2566 const state = threads.get(sessionId);
2567 if (state === undefined) return;
2568 if (event.type === "session.model.selected" || event.type === "session.step.started") {
2569 if (event.type === "session.model.selected") state.model = event.data.model;
2570 const providerThread = withReportedModel(state.providerThread, event.data.model);
2571 if (providerThread !== state.providerThread) {
2572 state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now };
2573 yield* emit({
2574 type: "provider_thread.updated",
2575 driver,
2576 providerThread: state.providerThread,
2577 });
2578 }
2579 }
2580 if (
2581 event.type === "session.inbox.enqueued" &&
2582 state.subagent !== undefined &&
2583 state.active === undefined
2584 ) {
2585 state.subagent.queued ??= event.data.inboxID;
2586 }
2587 if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") {
2588 return yield* onReport(state, event.data.inboxID, event.data.item.payload);
2589 }
2590 // Cancelled, or delivered into a turn already running: no wake reports it.
2591 // A steer of the running turn's is settled the same way.
2592 if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") {
2593 state.reports.delete(event.data.inboxID);
2594 const turn = state.active;
2595 if (turn !== undefined && turn.steers.delete(event.data.inboxID)) {
2596 turn.settledInbox.add(event.data.inboxID);
2597 // A cancelled steer may leave a held turn nothing to wait for.
2598 if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn);
2599 }
2600 return;
2601 }
2602 // A subagent's later prompts (a resumed subagent) open its next turn's message.
2603 if (
2604 event.type === "session.inbox.enqueued" &&
2605 event.data.item.type === "user" &&
2606 state.subagent !== undefined &&
2607 state.subagent.prompt === undefined &&
2608 state.active === undefined
2609 ) {
2610 state.subagent.prompt = event.data.item.payload.text;
2611 return;
2612 }
2613 const started =
2614 event.type === "session.execution.started" || event.type === "unreadable.execution.started";
2615 // Each execution of a subagent's session is a turn on its child thread,
2616 // unless it only answers the reports of nested subagents a Stop ended.
2617 if (state.subagent !== undefined && state.active === undefined && started) {
2618 if ((yield* takeReports(state)).stopped) {
2619 state.subagent.queued = undefined;
2620 return;
2621 }
2622 return yield* startChildTurn(state);
2623 }
2624 const turn = state.active;
2625 if (turn === undefined) {
2626 // OpenCode started the thread's session on its own.
2627 if (started && state.subagent === undefined) return yield* onWake(state);
2628 return;
2629 }
2630 // A session runs one execution at a time, and each opens with `started`
2631 // on this ordered stream, so what comes before it is the stopped run's.
2632 if (turn.awaitingStart) {
2633 if (event.type === "session.execution.started") turn.awaitingStart = false;
2634 return;
2635 }
2636 return yield* onTurnEvent(state, turn, event);
2637 });
2638
2639 // The stream is the only terminal signal, and it is volatile: events sent
2640 // while it is down are gone, and a restarted server never ends the
2641 // execution it lost. So a lost stream reconnects, then reconciles each
2642 // running turn from the server's own state (see `reconcile`). Only when
2643 // reconnecting keeps failing are the turns failed and the session broken,
2644 // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses.
2645 let streamFailure: string | undefined;
2646 let reconnected = yield* Deferred.make<void>();
2647 const failAll = Effect.fnUntraced(function* (message: string) {
2648 streamFailure = message;
2649 yield* Deferred.succeed(reconnected, undefined);
2650 for (const state of threads.values()) {
2651 const failure = makeProviderFailure({ message, class: "transport_error" });
2652 yield* finishTurn(state, { status: "failed", failure }, "broken");
2653 }
2654 // Nothing can report a background subagent's end any more.
2655 for (const state of threads.values()) {
2656 for (const call of runningCalls(state)) yield* settleCall(call, "failed");
2657 for (const wake of state.wakes.splice(0)) wake.dropped = true;
2658 state.reports.clear();
2659 }
2660 yield* setSessionStatus("error", message);
2661 yield* Queue.end(events);
2662 });
2663 /**
2664 * Emits what a running turn missed while the stream was down, from the
2665 * session's history since the turn began: text, reasoning and tools, each
2666 * under the same native id its live events would have used, so nothing
2667 * already shown is duplicated. A turn begins at its prompt, whose id T3
2668 * chose (`nativeTurnRef`), or after `before` for a `/name` command or a
2669 * continuation.
2670 * Returns how that history says the turn's execution ended: the `idle` item
2671 * OpenCode appends after each one, or undefined when there is none after
2672 * the turn's start.
2673 */
2674 const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) {
2675 const turn = state.active;
2676 if (turn === undefined) return undefined;
2677 const promptId = promptOf(turn.providerTurn);
2678 const { before } = turn;
2679 if (promptId === undefined && before === undefined) return undefined;
2680 const start = before !== undefined ? before : promptId;
2681 const read = yield* paginate(
2682 { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 },
2683 client.message.list,
2684 ).pipe(
2685 Stream.takeUntil((message) => message.id === start),
2686 Stream.runCollect,
2687 );
2688 // Without its start the history holds only earlier turns (the stream
2689 // dropped before the prompt landed): none of it is this turn's. A null
2690 // `before` is a command that started on an empty history.
2691 const found = start === null || read.at(-1)?.id === start;
2692 if (!found) return undefined;
2693 const recent = before !== undefined && before !== null ? read.slice(0, -1) : read;
2694 const idle = recent.find((message) => message.type === "idle");
2695 for (const message of recent.toReversed()) {
2696 if (message.type !== "assistant" || state.active !== turn) continue;
2697 const ordinals = { text: 0, reasoning: 0 };
2698 for (const part of message.content) {
2699 if (part.type === "text" || part.type === "reasoning") {
2700 const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ };
2701 yield* emitText(state, turn, block, part.type, () => part.text, true);
2702 continue;
2703 }
2704 if (part.type !== "tool") continue;
2705 if (!turn.tools.has(part.id)) {
2706 turn.tools.set(part.id, { name: part.name, input: {} });
2707 turn.startedAt.set(part.id, yield* DateTime.now);
2708 }
2709 const tool = turn.tools.get(part.id)!;
2710 if (typeof part.state.input === "object" && part.state.input !== null) {
2711 tool.input = part.state.input as Record<string, unknown>;
2712 }
2713 if (part.state.status === "completed") {
2714 yield* emitTool(state, turn, part.id, "completed", {
2715 output: textOf(part.state.content),
2716 metadata: part.state.metadata,
2717 });
2718 turn.tools.delete(part.id);
2719 } else if (part.state.status === "error") {
2720 yield* emitTool(state, turn, part.id, "failed", {
2721 output: part.state.error.message,
2722 metadata: part.state.metadata,
2723 });
2724 turn.tools.delete(part.id);
2725 } else {
2726 yield* emitTool(state, turn, part.id, "running");
2727 }
2728 }
2729 }
2730 return idle === undefined ? undefined : { outcome: idle.outcome };
2731 });
2732
2733 /**
2734 * Settles each thread turn that was running when the stream dropped, from
2735 * the server: a session still running keeps its turn (its next events
2736 * arrive on the new stream); one that stopped ends the turn with the
2737 * outcome of the `idle` item after the turn's start. A restarted server
2738 * writes no such item for the execution it lost, and `session.outcome` is
2739 * still the previous execution's, so no `idle` means the turn was
2740 * interrupted. A continuation turn is settled the same way, from the report
2741 * its execution answers. A turn still holding for an undelivered steer only
2742 * ends once the session is idle. Permission asks and question forms are
2743 * read back from every session still running, so a run that asked during
2744 * the gap can be answered.
2745 *
2746 * What the stream carried outside a turn is not read back: a background
2747 * subagent's report, a follow-up OpenCode started on its own, or one held
2748 * for its continuation turn. So background work is settled, not resumed: a
2749 * subagent whose session stopped ends as interrupted, and a thread's held
2750 * follow-ups and queued reports are dropped, so nothing waits on an event
2751 * that already went by.
2752 */
2753 const reconcile = Effect.gen(function* () {
2754 // A subagent's turn ends before the turn that waits on it, so the
2755 // deepest sessions are read back first.
2756 const running = [...threads]
2757 .filter(([, state]) => state.active !== undefined)
2758 .toSorted(([left], [right]) => callsAbove(right).length - callsAbove(left).length);
2759 const background = [...threads.values()].filter(
2760 (state) => state.subagent === undefined && hasBackground(state),
2761 );
2762 if (running.length === 0 && background.length === 0 && busy.size === 0) return;
2763 const active = yield* client.session.active();
2764 // An execution that ended while the stream was down never said so.
2765 for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId);
2766 for (const [sessionId, state] of running) {
2767 const turn = state.active;
2768 if (turn === undefined) continue;
2769 const ended = yield* backfill(sessionId, state);
2770 if (sessionId in active || state.active !== turn) continue;
2771 yield* finishTurn(
2772 state,
2773 ended?.outcome === "succeeded"
2774 ? { status: turn.interrupted ? "interrupted" : "completed" }
2775 : ended?.outcome === "failed"
2776 ? {
2777 status: "failed",
2778 failure: makeProviderFailure({
2779 message:
2780 "OpenCode ended the turn with an error while T3 Code was reconnecting.",
2781 class: "provider_error",
2782 }),
2783 }
2784 : { status: "interrupted" },
2785 );
2786 }
2787 for (const state of background) {
2788 // A subagent still running keeps its call; its end arrives on the new stream.
2789 for (const call of runningCalls(state)) {
2790 const child = call.child?.sessionId;
2791 if (child === undefined || !(child in active)) {
2792 yield* settleCall(call, "interrupted", LOST_BACKGROUND);
2793 }
2794 }
2795 // A subagent's session holds reports of its own background subagents.
2796 for (const session of sessionsOf(state)) {
2797 for (const wake of session.wakes.splice(0)) wake.dropped = true;
2798 session.reports.clear();
2799 }
2800 }
2801 // A request asked while the stream was down was never shown, and the
2802 // run waits on it; one T3 shows that OpenCode no longer lists was
2803 // answered elsewhere or dropped with its execution. A session that
2804 // stopped dropped its requests.
2805 const listed = new Map<string, Set<string>>();
2806 for (const sessionId of Object.keys(active)) {
2807 if (ownerOf(sessionId) === undefined) continue;
2808 const sessionID = Session.ID.make(sessionId);
2809 const [permissions, forms] = yield* Effect.all([
2810 client.permission.list({ sessionID }),
2811 client.session.form.list({ sessionID }),
2812 ]);
2813 listed.set(sessionId, new Set([...permissions, ...forms].map((request) => request.id)));
2814 const shown = new Set([...pending.values()].map((entry) => entry.native.id));
2815 for (const request of permissions) {
2816 if (!shown.has(request.id)) yield* onAsked({ type: "permission", request });
2817 }
2818 for (const form of forms) {
2819 if (!shown.has(form.id)) yield* onAsked({ type: "form", form });
2820 }
2821 }
2822 // A snapshot: settling a request removes it from the map.
2823 for (const entry of Array.from(pending.values())) {
2824 if (listed.get(entry.sessionId)?.has(entry.native.id) !== true) {
2825 yield* settleRequest(entry, "cancelled");
2826 }
2827 }
2828 });
2829
2830 /**
2831 * Subscribes again, on a restarted server if the old one is gone, and
2832 * reconciles. Retried a few times; the caller fails everything after that.
2833 */
2834 const reconnect = Effect.gen(function* () {
2835 // Returned on any failure, including the interrupt a closing session
2836 // sends a reconnect still in flight, so a spawned server can idle-stop.
2837 const scope = yield* Scope.make();
2838 const { next, stream } = yield* Effect.gen(function* () {
2839 const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope));
2840 return { next, stream: yield* next.events };
2841 }).pipe(Effect.onError(() => Scope.close(scope, Exit.void)));
2842 const previous = currentScope;
2843 connection = next;
2844 client = next.client;
2845 currentScope = scope;
2846 yield* Scope.close(previous, Exit.void);
2847 // A restarted server forgot T3's MCP servers; the next turn adds them again.
2848 for (const state of threads.values()) state.mcp = undefined;
2849 yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT));
2850 return stream;
2851 }).pipe(
2852 Effect.retry({ times: RECONNECT_ATTEMPTS - 1, schedule: Schedule.spaced(RECONNECT_DELAY) }),
2853 );
2854
2855 let currentScope = initial.scope;
2856 // The borrow in use when the session closes is returned with it, so a
2857 // spawned server can still reach its idle shutdown.
2858 yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void));
2859 const follow = (stream: Stream.Stream<OpenCode2StreamEvent, unknown>): Effect.Effect<void> =>
2860 stream.pipe(
2861 Stream.runForEach((event) => lock.withPermit(handleEvent(event))),
2862 Effect.exit,
2863 Effect.flatMap(() =>
2864 Effect.gen(function* () {
2865 streamFailure = "The OpenCode event stream was lost. Reconnecting.";
2866 yield* Effect.logWarning("Lost the OpenCode event stream; reconnecting.");
2867 // The last failure is kept for the log: it can carry the server's
2868 // URL or response text, so only its tag is annotated and the full
2869 // failure stays in the log's cause.
2870 const next = yield* reconnect.pipe(
2871 Effect.tapCause((cause) =>
2872 Effect.logWarning(
2873 "Could not reconnect to the OpenCode event stream; failing the session.",
2874 cause,
2875 ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })),
2876 ),
2877 Effect.option,
2878 );
2879 if (next._tag === "None") {
2880 return yield* failAll("The OpenCode event stream was lost and could not reconnect.");
2881 }
2882 streamFailure = undefined;
2883 const done = reconnected;
2884 reconnected = yield* Deferred.make<void>();
2885 yield* Deferred.succeed(done, undefined);
2886 return yield* follow(next.value);
2887 }),
2888 ),
2889 );
2890 // Subscribed before any session or prompt call, so no event of theirs is missed.
2891 yield* follow(yield* connection.events).pipe(Effect.forkScoped);
2892
2893 // A server T3 did not start keeps running after T3 stops, so stop the turns
2894 // it would otherwise finish unseen. A spawned server stops with its owner.
2895 if (connection.external) {
2896 yield* Effect.addFinalizer(() =>
2897 Effect.forEach(
2898 [...threads].filter(([, state]) => state.active !== undefined),
2899 ([sessionId]) =>
2900 client.session
2901 .interrupt({ sessionID: Session.ID.make(sessionId) })
2902 .pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })),
2903 { concurrency: 8, discard: true },
2904 ),
2905 );
2906 }
2907
2908 // Context windows come from the server's model list for a directory, read
2909 // when the session opens, before a thread first runs in another directory,
2910 // and again after a turn whose model had none yet. A directory counts as
2911 // known once its read starts, so a failed read is retried only after a turn.
2912 const readModels = (directory: string) =>
2913 Effect.suspend(() => {
2914 const windows = contextWindows.get(directory) ?? new Map<string, number>();
2915 contextWindows.set(directory, windows);
2916 return client.model.list({ location: { directory } }).pipe(
2917 Effect.timeout("5 seconds"),
2918 Effect.tap((models) =>
2919 Effect.sync(() => {
2920 for (const model of models.data) {
2921 // A model's input limit, when it has one, is its real headroom.
2922 windows.set(
2923 `${model.providerID}/${model.id}`,
2924 model.limit.input ?? model.limit.context,
2925 );
2926 }
2927 }),
2928 ),
2929 Effect.ignore({ log: true }),
2930 );
2931 });
2932 const readModelsOnce = (cwd: string | null | undefined) =>
2933 contextWindows.has(directoryOf(cwd)) ? Effect.void : readModels(directoryOf(cwd));
2934 yield* readModels(session.cwd);
2935
2936 // Each agent's own path allows, by the directory they were listed for.
2937 const agentRules = new Map<string, ReadonlyMap<string, ReadonlyArray<Rule>>>();
2938 const pathsFor = Effect.fnUntraced(function* (
2939 directory: string,
2940 agents: ReadonlyArray<string>,
2941 ) {
2942 let known = agentRules.get(directory);
2943 if (known === undefined) {
2944 // A fresh server lists no agents for its first moments, like its models.
2945 const listed = yield* client.agent.list({ location: { directory } }).pipe(
2946 Effect.repeat({
2947 until: (list) => list.data.length > 0,
2948 schedule: Schedule.spaced("250 millis"),
2949 }),
2950 Effect.timeout("5 seconds"),
2951 // The failure can carry the server's URL or response text, so only its
2952 // tag is annotated; the full failure stays in the log's cause.
2953 Effect.tapCause((cause) =>
2954 Effect.logWarning(
2955 "Could not list OpenCode agents; their path rules are skipped.",
2956 cause,
2957 ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })),
2958 ),
2959 Effect.option,
2960 );
2961 if (listed._tag === "None") return [];
2962 known = new Map(
2963 listed.value.data.map((agent) => [agent.id, agentPaths(agent.permissions)]),
2964 );
2965 agentRules.set(directory, known);
2966 }
2967 const paths = new Map<string, Rule>();
2968 for (const agent of agents) {
2969 for (const entry of known.get(agent) ?? []) {
2970 paths.set(`${entry.action}\u0000${entry.resource}`, entry);
2971 }
2972 }
2973 return [...paths.values()];
2974 });
2975
2976 /**
2977 * The rules a thread's session runs `policy` with. Full access allows
2978 * every path already, so the agent's own path rules are read only for the
2979 * modes that narrow it.
2980 */
2981 const rulesFor = Effect.fnUntraced(function* (
2982 thread: Pick<ThreadState, "directory" | "agent" | "grants">,
2983 policy: RulesPolicy,
2984 appThreadId: string | null,
2985 ) {
2986 const plan = policy.interactionMode === "plan";
2987 const paths =
2988 policy.runtimeMode === "full-access" && !plan
2989 ? []
2990 : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]);
2991 return sessionRules(
2992 policy,
2993 paths,
2994 policy.runtimeMode === "full-access" ? [] : thread.grants,
2995 appThreadId === null ? null : yield* mcpServerNameFor(appThreadId),
2996 );
2997 });
2998
2999 /** Writes the session's rules for `policy` when they differ from what it has. */
3000 const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) {
3001 // A subagent's session may use its thread's T3 server, the root's.
3002 const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId);
3003 if (!sameRules(state.rules, rules)) {
3004 yield* client.session.update({
3005 sessionID: Session.ID.make(state.sessionId),
3006 permissions: rules,
3007 });
3008 state.rules = rules;
3009 }
3010 state.policy = policy;
3011 });
3012
3013 const register = (
3014 providerThread: OrchestrationV2ProviderThread,
3015 native: {
3016 readonly id: string;
3017 readonly model?: ModelRef | undefined;
3018 readonly agent?: string | undefined;
3019 readonly permissions?: ReadonlyArray<Rule> | undefined;
3020 },
3021 directory: string,
3022 ) => {
3023 providerThread = withReportedModel(providerThread, native.model);
3024 const existing = threads.get(native.id);
3025 if (existing !== undefined) {
3026 existing.providerThread = providerThread;
3027 existing.model = native.model;
3028 existing.directory = directory;
3029 existing.agent = native.agent ?? existing.agent;
3030 existing.rules = native.permissions;
3031 return existing;
3032 }
3033 const state = newThreadState(native.id, providerThread, directory, undefined);
3034 state.model = native.model;
3035 state.agent = native.agent ?? state.agent;
3036 state.rules = native.permissions;
3037 threads.set(native.id, state);
3038 return state;
3039 };
3040
3041 /**
3042 * Refuses to cut or copy a session's history while something writes to
3043 * it: a turn of T3's, an execution seen running on the stream (a held
3044 * follow-up), or a run on the server this runtime does not own: one a
3045 * timed-out Stop or an unanswered request left behind, or any run on a
3046 * session loaded after the server outlived T3. Only that last case asks
3047 * the server. A rollback also waits for background subagents, whose
3048 * reports wake the session into the history it would cut.
3049 */
3050 const ensureQuiet = Effect.fnUntraced(function* (
3051 sessionId: string,
3052 verb: "roll back" | "fork",
3053 ) {
3054 const state = threads.get(sessionId);
3055 const busyHere =
3056 state !== undefined &&
3057 (state.active !== undefined ||
3058 busy.has(sessionId) ||
3059 (verb === "roll back" && hasBackground(state)));
3060 const busyThere =
3061 !busyHere &&
3062 (state === undefined || state.unsettled) &&
3063 sessionId in (yield* client.session.active().pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT)));
3064 if (busyHere || busyThere) {
3065 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3066 driver: OPENCODE_PROVIDER,
3067 detail: `Cannot ${verb} OpenCode session ${sessionId} while it is still working`,
3068 });
3069 }
3070 });
3071
3072 /**
3073 * Takes back steers a finished turn left in OpenCode's inbox. One whose
3074 * cancel fails may still be there, so it is tried again before the next prompt.
3075 */
3076 const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) {
3077 const sessionID = Session.ID.make(state.sessionId);
3078 // A snapshot: a cancelled steer leaves the set.
3079 for (const inboxID of Array.from(state.strandedSteers)) {
3080 const cancelled = yield* client.session.inbox
3081 .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) })
3082 .pipe(
3083 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3084 Effect.tapCause((cause) =>
3085 Effect.logWarning("Could not take back an OpenCode steer.", cause),
3086 ),
3087 Effect.exit,
3088 Effect.map(Exit.isSuccess),
3089 );
3090 if (cancelled) state.strandedSteers.delete(inboxID);
3091 }
3092 });
3093
3094 /**
3095 * Clears a staged revert and waits out the empty execution `clear` runs,
3096 * which is no turn of T3's. 2.0.18 wakes the session after every clear,
3097 * with or without a stage (seen live), so that execution always comes.
3098 */
3099 const clearRevert = Effect.fnUntraced(function* (sessionId: string) {
3100 const settled = yield* Deferred.make<void>();
3101 clearing.set(sessionId, settled);
3102 yield* client.session.revert
3103 .clear({ sessionID: Session.ID.make(sessionId) })
3104 .pipe(
3105 Effect.andThen(Deferred.await(settled)),
3106 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3107 Effect.ensuring(Effect.sync(() => clearing.delete(sessionId))),
3108 );
3109 stagedReverts.delete(sessionId);
3110 });
3111
3112 /**
3113 * Truncates the session's history before `boundary`: OpenCode stages the
3114 * revert, then commits it. If either fails or is interrupted the stage is
3115 * cleared, since OpenCode commits a staged revert on the next prompt; a
3116 * clear that fails too is tried again before that prompt. Files stay where
3117 * they are: T3 restores its own checkpoint when the user asked for files,
3118 * and a rewind without files must not touch them.
3119 */
3120 const rollBackTo = Effect.fnUntraced(function* (
3121 sessionId: string,
3122 boundary: SessionMessage.ID,
3123 ) {
3124 const sessionID = Session.ID.make(sessionId);
3125 // Each step is bounded: a server that never answers would otherwise
3126 // hold the session's gate, and the next turn with it.
3127 yield* client.session.revert.stage({ sessionID, messageID: boundary, files: false }).pipe(
3128 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3129 Effect.andThen(
3130 client.session.revert.commit({ sessionID }).pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT)),
3131 ),
3132 Effect.onError(() =>
3133 clearRevert(sessionId).pipe(
3134 Effect.tapError(() => Effect.sync(() => stagedReverts.add(sessionId))),
3135 Effect.ignore({ log: true }),
3136 ),
3137 ),
3138 );
3139 });
3140
3141 /**
3142 * The ids fork and rollback can cut before: the session's prompts, and the
3143 * reports continuations answer. The list takes one type, so it reads all.
3144 */
3145 const userMessages = (sessionId: string) =>
3146 paginate({ sessionID: Session.ID.make(sessionId), limit: 100 }, client.message.list).pipe(
3147 Stream.runCollect,
3148 Effect.map(
3149 (messages) =>
3150 new Set(
3151 messages.flatMap((message) =>
3152 message.type === "user" || message.type === "synthetic" ? [String(message.id)] : [],
3153 ),
3154 ),
3155 ),
3156 );
3157
3158 /** The session's history as V2 messages, read after any change T3 made to it. */
3159 const snapshotOf = Effect.fnUntraced(function* (
3160 providerThread: OrchestrationV2ProviderThread,
3161 sessionId: string,
3162 ) {
3163 const history = yield* paginate(
3164 { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 },
3165 client.message.list,
3166 ).pipe(Stream.runCollect);
3167 const snapshotAt = yield* DateTime.now;
3168 const messages = history.flatMap((message): Array<OrchestrationV2ConversationMessage> => {
3169 const text =
3170 message.type === "user"
3171 ? message.text
3172 : message.type === "assistant"
3173 ? textOf(message.content)
3174 : "";
3175 if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) {
3176 return [];
3177 }
3178 const createdAt = message.time.created;
3179 return [
3180 {
3181 createdBy: message.type === "user" ? "user" : "agent",
3182 creationSource: "provider",
3183 id: idAllocator.derive.messageFromProviderItem({
3184 driver,
3185 nativeItemId: message.id,
3186 }),
3187 threadId: providerThread.appThreadId ?? input.threadId,
3188 runId: null,
3189 nodeId: null,
3190 role: message.type,
3191 text,
3192 attachments: [],
3193 streaming: false,
3194 createdAt,
3195 updatedAt: createdAt,
3196 },
3197 ];
3198 });
3199 const lastUser = history.findLast((message) => message.type === "user")?.id;
3200 const state = threads.get(sessionId);
3201 return {
3202 providerThread: {
3203 ...providerThread,
3204 providerSessionId: input.providerSessionId,
3205 nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"),
3206 status: "idle" as const,
3207 updatedAt: snapshotAt,
3208 },
3209 providerTurns: state === undefined ? [] : [...state.providerTurns.values()],
3210 messages,
3211 runtimeRequests: [],
3212 };
3213 });
3214
3215 const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) =>
3216 providerMessageTextWithAttachmentPaths({
3217 text: turnInput.message.text,
3218 attachments: turnInput.message.attachments,
3219 resolveAttachmentPath: (attachment) =>
3220 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
3221 }).trim();
3222
3223 const removeMcp = (mcp: { readonly name: string; readonly directory: string }) =>
3224 Effect.suspend(() =>
3225 client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }),
3226 ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true }));
3227
3228 // T3's MCP registrations outlive a session only on an external server; a
3229 // spawned one forgets them when it stops.
3230 yield* Effect.addFinalizer(() =>
3231 Effect.forEach(
3232 [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])),
3233 removeMcp,
3234 { concurrency: 8, discard: true },
3235 ),
3236 );
3237
3238 /**
3239 * T3's MCP server for this thread, and the per-turn instructions. The MCP
3240 * server is registered for the session's directory under the thread's own
3241 * name and credential (the session rules allow only it), and removed when
3242 * the thread unloads or the session closes. OpenCode 2 has no per-prompt
3243 * system field, so the instructions are a session instructions entry,
3244 * which applies from the next step; it is only rewritten when it changes.
3245 */
3246 const prepareTurn = Effect.fnUntraced(function* (
3247 sessionId: string,
3248 state: ThreadState,
3249 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3250 ) {
3251 const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId);
3252 const directory = turnInput.runtimePolicy.cwd ?? serverConfig.cwd;
3253 const name = yield* mcpServerNameFor(turnInput.threadId);
3254 // An external server may not reach T3's MCP endpoint, as with 1.x.
3255 const wanted =
3256 mcpSession === undefined || connection.external
3257 ? undefined
3258 : { name, directory, credential: mcpSession.authorizationHeader };
3259 if (
3260 state.mcp !== undefined &&
3261 (wanted === undefined ||
3262 state.mcp.directory !== wanted.directory ||
3263 state.mcp.credential !== wanted.credential)
3264 ) {
3265 yield* removeMcp(state.mcp);
3266 state.mcp = undefined;
3267 }
3268 // T3's tools are an addition: a server that cannot add them still runs the turn.
3269 if (wanted !== undefined && state.mcp === undefined) {
3270 const added = yield* client.mcp
3271 .add({
3272 server: name,
3273 location: { directory },
3274 config: new Mcp.RemoteConfig({
3275 type: "remote",
3276 url: mcpSession!.endpoint,
3277 headers: { Authorization: wanted.credential },
3278 oauth: false,
3279 }),
3280 })
3281 .pipe(
3282 Effect.timeout(INVENTORY_TIMEOUT),
3283 Effect.as(true),
3284 Effect.catchCause((cause) =>
3285 Effect.logWarning("Could not add T3 Code's MCP server to OpenCode.", cause).pipe(
3286 Effect.as(false),
3287 ),
3288 ),
3289 );
3290 if (added) state.mcp = wanted;
3291 }
3292 const instructions = [
3293 buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }),
3294 t3OrchestrationSystemPrompt(state.mcp !== undefined),
3295 ]
3296 .filter((part) => part !== undefined && part.length > 0)
3297 .join("\n\n");
3298 if (instructions !== state.instructions) {
3299 yield* client.session.instructions.entry.put({
3300 sessionID: Session.ID.make(sessionId),
3301 key: INSTRUCTIONS_KEY,
3302 value: instructions,
3303 });
3304 state.instructions = instructions;
3305 }
3306 });
3307
3308 const markBefore = (sessionId: string, before: string | null) =>
3309 Effect.sync(() => {
3310 const turn = threads.get(sessionId)?.active;
3311 if (turn !== undefined) turn.before = before;
3312 });
3313
3314 /** Installs a turn T3 started; every path after it ends the turn with a terminal. */
3315 const beginTurn = (
3316 state: ThreadState,
3317 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3318 awaitingStart: boolean,
3319 ) =>
3320 Effect.gen(function* () {
3321 const startedAt = yield* DateTime.now;
3322 const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`;
3323 const providerTurn: OrchestrationV2ProviderTurn = {
3324 id: idAllocator.derive.providerTurn({ driver, nativeTurnId }),
3325 providerThreadId: turnInput.providerThread.id,
3326 nodeId: turnInput.rootNodeId,
3327 runAttemptId: turnInput.attemptId,
3328 // The user message it prompts with, where fork and rollback cut. A
3329 // continuation prompts nothing; it records the report it answers
3330 // once it takes its execution.
3331 nativeTurnRef: ref(
3332 isContinuation(turnInput)
3333 ? wakeTurnId(state.sessionId, turnInput.attemptId)
3334 : turnPromptId(state.sessionId, turnInput.attemptId),
3335 "weak",
3336 ),
3337 ordinal: turnInput.providerTurnOrdinal,
3338 status: "running",
3339 startedAt,
3340 completedAt: null,
3341 };
3342 const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart });
3343 // No stream is left to end this turn, so it must not start.
3344 if (streamFailure !== undefined) {
3345 return yield* new ProviderAdapter.ProviderAdapterEventStreamError({
3346 driver,
3347 providerSessionId: input.providerSessionId,
3348 cause: streamFailure,
3349 });
3350 }
3351 state.active = turn;
3352 yield* emitProviderTurn(state, turn, providerTurn);
3353 state.providerThread = {
3354 ...state.providerThread,
3355 status: "active",
3356 firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal,
3357 lastRunOrdinal: turnInput.runOrdinal,
3358 updatedAt: startedAt,
3359 };
3360 yield* emit({
3361 type: "provider_thread.updated",
3362 driver,
3363 providerThread: state.providerThread,
3364 });
3365 yield* setSessionStatus("running", null);
3366 return turn;
3367 });
3368
3369 /** Feeds a held execution's events to the turn now running on its session. */
3370 const replay = Effect.fnUntraced(function* (wake: Wake) {
3371 wake.dropped = true;
3372 for (const event of wake.events) yield* route(event, sessionOfEvent(event));
3373 });
3374
3375 /**
3376 * A user turn that starts while OpenCode runs an execution on its own: the
3377 * prompt joins that execution, so the turn takes it, and its continuation
3378 * turn is no longer needed. Executions that already ended stay for theirs.
3379 */
3380 const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) {
3381 const wake = state.wakes.at(-1);
3382 if (wake?.running !== true) return;
3383 state.wakes.pop();
3384 yield* replay(wake);
3385 });
3386
3387 /**
3388 * The continuation turn for an execution OpenCode started on its own: it
3389 * takes the oldest one held and ends with it. One already taken (a user
3390 * turn joined it) leaves nothing to run, so the turn ends at once.
3391 */
3392 const runWake = Effect.fnUntraced(function* (
3393 state: ThreadState,
3394 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3395 ) {
3396 yield* beginTurn(state, turnInput, false);
3397 yield* lock.withPermit(
3398 Effect.gen(function* () {
3399 const wake = state.wakes.shift();
3400 if (wake === undefined) return yield* finishTurn(state, { status: "completed" });
3401 const turn = state.active;
3402 if (turn !== undefined && wake.after !== undefined) turn.before = wake.after;
3403 // Its first report is where its history begins, so fork and rollback cut there.
3404 if (turn !== undefined && wake.first !== undefined) {
3405 turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") };
3406 }
3407 yield* replay(wake);
3408 }),
3409 );
3410 });
3411
3412 /**
3413 * Stops a thread's background subagents and any execution OpenCode is
3414 * running on its own. Their sessions end as interrupted; the reports
3415 * OpenCode then queues wake the parent into an execution that is stopped
3416 * as well, so no turn takes it. A subagent the Stop did not reach runs on,
3417 * so its call stays tracked: the session is not released under it, and
3418 * the next Stop tries it again.
3419 */
3420 const stopBackground = Effect.fnUntraced(function* (state: ThreadState) {
3421 const calls = runningCalls(state).filter((call) => call.background);
3422 // Every session under the thread, which a stopped subagent may report to.
3423 const callers = sessionsOf(state);
3424 // A subagent whose call ended runs again when OpenCode wakes it to
3425 // answer a report of its own background subagent.
3426 const followUps = callers.filter(
3427 (caller) =>
3428 caller.subagent !== undefined &&
3429 busy.has(caller.sessionId) &&
3430 !isOrchestrationV2WorkActive(caller.subagent.call.status),
3431 );
3432 // OpenCode announces a child's session before the call's progress names
3433 // it, so a call without a child yet is stopped through its caller's
3434 // announced children.
3435 const announcedTo = (caller: ThreadState) =>
3436 [...announced.values()].flatMap((info) =>
3437 info.parentID === caller.sessionId ? [info.sessionID] : [],
3438 );
3439 const childrenOf = (call: SubagentCall) =>
3440 call.child === undefined ? announcedTo(call.state) : [call.child.sessionId];
3441 // Each child reports to the session that called it, so its marker goes
3442 // on that caller's state: the thread's own, or a subagent's for a nested one.
3443 const children = new Map(
3444 calls.flatMap((call) => childrenOf(call).map((childId) => [childId, call.state] as const)),
3445 );
3446 const unreached = new Set<string>();
3447 for (const [childId, caller] of children) {
3448 caller.stoppedChildren.add(childId);
3449 const reached = yield* client.session
3450 .interrupt({ sessionID: Session.ID.make(childId) })
3451 .pipe(
3452 // A session that is gone runs nothing.
3453 Effect.catchTags({ SessionNotFoundError: () => Effect.void }),
3454 Effect.timeout(INTERRUPT_TIMEOUT),
3455 Effect.tapCause((cause) =>
3456 Effect.logWarning("Could not stop an OpenCode subagent.", cause),
3457 ),
3458 Effect.exit,
3459 Effect.map(Exit.isSuccess),
3460 );
3461 if (!reached) unreached.add(childId);
3462 }
3463 // Its end closes its turn; one the Stop did not reach stays pending work.
3464 for (const followUp of followUps) {
3465 yield* client.session
3466 .interrupt({ sessionID: Session.ID.make(followUp.sessionId) })
3467 .pipe(
3468 Effect.catchTags({ SessionNotFoundError: () => Effect.void }),
3469 Effect.timeout(INTERRUPT_TIMEOUT),
3470 Effect.ignore({ log: true }),
3471 );
3472 }
3473 yield* lock.withPermit(
3474 Effect.gen(function* () {
3475 for (const call of calls) {
3476 if (childrenOf(call).some((childId) => unreached.has(childId))) continue;
3477 yield* settleCall(call, "interrupted");
3478 }
3479 // A report already queued starts a follow-up the Stop must end too.
3480 for (const caller of callers) {
3481 for (const report of caller.reports.values()) {
3482 caller.stoppedChildren.add(report.childId);
3483 }
3484 }
3485 // A held wake no turn will take: its execution is stopped, not replayed.
3486 const running = state.wakes.some((wake) => wake.running);
3487 for (const wake of state.wakes.splice(0)) wake.dropped = true;
3488 if (running) {
3489 state.unsettled = true;
3490 yield* client.session
3491 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
3492 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
3493 }
3494 }),
3495 );
3496 });
3497
3498 /**
3499 * What a turn sends: `/compact` compacts, `/name args` naming a workspace
3500 * command runs it, and anything else is a prompt with the `$skill`s it
3501 * names attached. Commands and skills are read from the session's
3502 * directory only when the text could use them. A turn stopped meanwhile
3503 * sends nothing.
3504 */
3505 const submit = Effect.fnUntraced(function* (
3506 sessionId: string,
3507 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3508 state: ThreadState,
3509 turn: ActiveTurn,
3510 ) {
3511 const sessionID = Session.ID.make(sessionId);
3512 /** Whether the turn still sends; past this point a Stop goes to the server. */
3513 const sending = () => {
3514 if (state.active !== turn || turn.interrupted) return false;
3515 turn.unsent = false;
3516 return true;
3517 };
3518 const text = turnInput.message.text.trim();
3519 const bare = turnInput.message.attachments.length === 0;
3520 // The turn's own id, so fork and rollback cut before this turn's item.
3521 const id = turnPromptId(sessionId, turnInput.attemptId);
3522 if (bare && text === "/compact") {
3523 if (!sending()) return;
3524 return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid);
3525 }
3526 const location = { directory: turnInput.runtimePolicy.cwd ?? serverConfig.cwd };
3527 const command = bare ? commandOf(text) : undefined;
3528 if (command !== undefined) {
3529 const commands = yield* client.command.list({ location }).pipe(
3530 Effect.timeout(INVENTORY_TIMEOUT),
3531 Effect.map((list) => list.data),
3532 Effect.orElseSucceed(() => []),
3533 );
3534 if (commands.some((entry) => entry.name === command.name)) {
3535 // `session.command` takes no id of T3's and its answer carries none,
3536 // so the turn remembers where the history stood before it.
3537 const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 });
3538 yield* markBefore(sessionId, newest.data[0]?.id ?? null);
3539 if (!sending()) return;
3540 return yield* client.session.command({ sessionID, ...command });
3541 }
3542 }
3543 const skills = SKILL_MENTION.test(text)
3544 ? skillsNamed(
3545 text,
3546 yield* client.skill.list({ location }).pipe(
3547 Effect.timeout(INVENTORY_TIMEOUT),
3548 Effect.map((list) => new Set(list.data.map((skill) => skill.id))),
3549 Effect.orElseSucceed(() => new Set<string>()),
3550 ),
3551 )
3552 : [];
3553 if (!sending()) return;
3554 return yield* client.session
3555 .prompt({
3556 sessionID,
3557 id,
3558 text: promptText(turnInput),
3559 ...(skills.length === 0
3560 ? {}
3561 : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }),
3562 })
3563 .pipe(Effect.asVoid);
3564 });
3565
3566 const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
3567 instanceId,
3568 driver,
3569 providerSessionId: input.providerSessionId,
3570 get providerSession() {
3571 return session;
3572 },
3573 events: Stream.fromQueue(events),
3574 // A background subagent keeps its session busy after its parent's turn,
3575 // and a held wake still needs its turn: idle release must wait for both.
3576 hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)),
3577 hasPendingBackgroundWorkForThread: (providerThread) =>
3578 Effect.sync(() => {
3579 const nativeId = providerThread.nativeThreadRef?.nativeId;
3580 const state = nativeId == null ? undefined : threads.get(nativeId);
3581 return state !== undefined && hasBackground(state);
3582 }),
3583 // A caller that names no directory gets the one this session opened in.
3584 getModelContextWindow: (selection, cwd) =>
3585 selection.instanceId === instanceId
3586 ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model)
3587 : undefined,
3588 ensureThread: (threadInput) =>
3589 Effect.gen(function* () {
3590 if (threadInput.existingProviderThread?.nativeThreadRef != null) {
3591 return yield* runtime.resumeThread({
3592 providerThread: threadInput.existingProviderThread,
3593 threadId: threadInput.threadId,
3594 modelSelection: threadInput.modelSelection,
3595 runtimePolicy: threadInput.runtimePolicy,
3596 });
3597 }
3598 yield* readModelsOnce(threadInput.runtimePolicy.cwd);
3599 const model = modelRef(threadInput.modelSelection);
3600 if (model === undefined) {
3601 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3602 driver: OPENCODE_PROVIDER,
3603 detail: malformedModel(threadInput.modelSelection.model),
3604 });
3605 }
3606 const directory = threadInput.runtimePolicy.cwd ?? serverConfig.cwd;
3607 const policy = threadInput.runtimePolicy;
3608 // A new session runs OpenCode's default agent.
3609 const permissions = yield* rulesFor(
3610 { directory, agent: "build", grants: [] },
3611 policy,
3612 threadInput.threadId,
3613 );
3614 const created = yield* client.session.create({
3615 location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }),
3616 model,
3617 permissions,
3618 });
3619 const createdAt = yield* DateTime.now;
3620 const providerThread: OrchestrationV2ProviderThread = {
3621 ...(threadInput.existingProviderThread ?? {
3622 id: idAllocator.derive.providerThread({ driver, nativeThreadId: created.id }),
3623 driver,
3624 providerInstanceId: instanceId,
3625 appThreadId: threadInput.threadId,
3626 ownerNodeId: null,
3627 firstRunOrdinal: null,
3628 lastRunOrdinal: null,
3629 handoffIds: [],
3630 forkedFrom: null,
3631 createdAt,
3632 }),
3633 providerSessionId: input.providerSessionId,
3634 nativeThreadRef: ref(created.id),
3635 nativeConversationHeadRef: null,
3636 status: "idle",
3637 updatedAt: createdAt,
3638 };
3639 const state = register(
3640 providerThread,
3641 { id: created.id, model: created.model, agent: created.agent, permissions },
3642 directory,
3643 );
3644 state.policy = policy;
3645 return state.providerThread;
3646 }).pipe(
3647 Effect.mapError((cause) =>
3648 isProviderAdapterError(cause)
3649 ? cause
3650 : new ProviderAdapter.ProviderAdapterEnsureThreadError({
3651 driver,
3652 threadId: threadInput.threadId,
3653 cause,
3654 }),
3655 ),
3656 ),
3657 resumeThread: (threadInput) =>
3658 Effect.gen(function* () {
3659 const sessionId = yield* sessionIdOf(threadInput.providerThread);
3660 if (threadInput.runtimePolicy !== undefined) {
3661 yield* readModelsOnce(threadInput.runtimePolicy.cwd);
3662 }
3663 // 1.x session ids survive the upgrade; a server without this session
3664 // fails the resume, so T3 recreates the thread with a handoff.
3665 const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) });
3666 const providerThread: OrchestrationV2ProviderThread = {
3667 ...threadInput.providerThread,
3668 providerSessionId: input.providerSessionId,
3669 status: "idle",
3670 updatedAt: yield* DateTime.now,
3671 };
3672 const cwd = threadInput.runtimePolicy?.cwd;
3673 const loaded = threads.has(sessionId);
3674 const state = register(providerThread, native, cwd ?? native.location.directory);
3675 // OpenCode keeps no request across its own restart, but a server that
3676 // outlived T3 may still wait on one T3 no longer shows.
3677 if (!loaded) yield* stopLeftoverRequests(state);
3678 // The session gets the rules for this thread's mode: it may have run
3679 // another mode, or been made by 1.x or an earlier build.
3680 yield* writeRules(state, threadInput.runtimePolicy ?? state.policy);
3681 // A thread moved to another worktree takes its session with it.
3682 if (cwd != null && native.location.directory !== cwd) {
3683 yield* client.session.move({
3684 sessionID: Session.ID.make(sessionId),
3685 directory: AbsolutePath.make(cwd),
3686 });
3687 }
3688 return state.providerThread;
3689 }).pipe(
3690 Effect.mapError((cause) =>
3691 isProviderAdapterError(cause)
3692 ? cause
3693 : new ProviderAdapter.ProviderAdapterResumeThreadError({
3694 driver,
3695 providerSessionId: input.providerSessionId,
3696 providerThreadId: threadInput.providerThread.id,
3697 cause,
3698 }),
3699 ),
3700 ),
3701 // `/compact` is its own turn, which `startTurn` sends as `session.compact`.
3702 compactThread: (turnInput) =>
3703 runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }),
3704 startTurn: (turnInput) =>
3705 Effect.gen(function* () {
3706 // A lost stream is reconnecting: wait for it rather than prompt into
3707 // a dead stream, and refuse the turn before any request if it gave up.
3708 // Waited out before the session's gate, so a rollback or fork is not
3709 // held behind a reconnect.
3710 if (streamFailure !== undefined) {
3711 yield* Deferred.await(reconnected).pipe(Effect.timeout(RECONNECT_WAIT), Effect.ignore);
3712 }
3713 if (streamFailure !== undefined) {
3714 return yield* new ProviderAdapter.ProviderAdapterEventStreamError({
3715 driver,
3716 providerSessionId: input.providerSessionId,
3717 cause: streamFailure,
3718 });
3719 }
3720 // Its checks and setup take the session's gate, so a rollback or
3721 // fork sees either no turn or this one installed; the prompt after
3722 // it does not hold the gate.
3723 const started = yield* exclusive(turnInput.providerThread)(
3724 Effect.gen(function* () {
3725 const sessionId = yield* sessionIdOf(turnInput.providerThread);
3726 const state = threads.get(sessionId);
3727 if (state === undefined) {
3728 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3729 driver: OPENCODE_PROVIDER,
3730 detail: `OpenCode session ${sessionId} is not registered`,
3731 });
3732 }
3733 if (state.active !== undefined) {
3734 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3735 driver: OPENCODE_PROVIDER,
3736 detail: `OpenCode session ${sessionId} already has an active turn`,
3737 });
3738 }
3739 // OpenCode already ran this turn on its own; it prompts nothing.
3740 if (isContinuation(turnInput)) return yield* runWake(state, turnInput);
3741 // After a timed-out Stop the server says whether that run is gone. A
3742 // run still going is stopped again and this turn fails so it can be
3743 // sent again; a run that is gone may still have its end on the
3744 // stream, which the turn skips.
3745 const afterUnsettled = state.unsettled;
3746 let stillStopping = false;
3747 if (state.unsettled) {
3748 const active = yield* client.session
3749 .active()
3750 .pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT));
3751 stillStopping = sessionId in active;
3752 if (stillStopping) {
3753 yield* client.session
3754 .interrupt({ sessionID: Session.ID.make(sessionId) })
3755 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
3756 } else {
3757 state.unsettled = false;
3758 }
3759 }
3760 // Installs the turn; every path after it ends the turn with a terminal.
3761 const begin = beginTurn(state, turnInput, afterUnsettled);
3762 if (stillStopping) {
3763 yield* begin;
3764 return yield* finishTurn(state, {
3765 status: "failed",
3766 failure: makeProviderFailure({
3767 message: OPENCODE_2_STILL_STOPPING,
3768 class: "provider_error",
3769 }),
3770 });
3771 }
3772 // A turn T3 will not run still starts and fails, so the refusal is what
3773 // the user reads.
3774 const model = modelRef(turnInput.modelSelection);
3775 if (model === undefined) {
3776 yield* begin;
3777 return yield* finishTurn(state, {
3778 status: "failed",
3779 failure: makeProviderFailure({
3780 message: malformedModel(turnInput.modelSelection.model),
3781 class: "validation_error",
3782 }),
3783 });
3784 }
3785 // Plan mode is OpenCode's `plan` agent, switched before the rules are
3786 // written so they keep that agent's own path allows. Switching to or
3787 // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt.
3788 const agent = agentFor(turnInput);
3789 if (agent !== state.agent) {
3790 yield* client.session.switchAgent({
3791 sessionID: Session.ID.make(sessionId),
3792 agent: Agent.ID.make(agent),
3793 });
3794 state.agent = agent;
3795 }
3796 // The thread's mode may have changed since the session was loaded,
3797 // and its subagents still running hold the rules they started with.
3798 // Those run on whether or not this turn starts, so theirs are best effort.
3799 yield* writeRules(state, turnInput.runtimePolicy);
3800 for (const call of runningCalls(state)) {
3801 if (call.child === undefined) continue;
3802 yield* writeRules(call.child, turnInput.runtimePolicy).pipe(
3803 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3804 Effect.ignore({ log: true }),
3805 );
3806 }
3807 // A selection changed since the last turn applies now; OpenCode keeps
3808 // the session's model otherwise.
3809 if (!sameModel(model, state.model)) {
3810 yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model });
3811 state.model = model;
3812 }
3813 // Steers a stopped turn never delivered would reach the model first,
3814 // and a revert a failed rollback left staged would be committed.
3815 yield* cancelStrandedSteers(state);
3816 if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId);
3817 yield* prepareTurn(sessionId, state, turnInput);
3818 const turn = yield* begin;
3819 turn.unsent = true;
3820 // An execution OpenCode is running on its own takes this prompt at
3821 // its next step, so this turn is that execution from here on.
3822 yield* lock.withPermit(takeRunningWake(state));
3823 return { state, sessionId, turn };
3824 }),
3825 );
3826 if (started === undefined) return;
3827 const { state, sessionId, turn } = started;
3828 yield* submit(sessionId, turnInput, state, turn).pipe(
3829 // Deleted outside T3: the thread is broken, and forgetting it makes
3830 // the next turn resume, fail, and recreate it with a handoff.
3831 Effect.catchTags({
3832 SessionNotFoundError: () =>
3833 finishTurn(
3834 state,
3835 {
3836 status: "failed",
3837 failure: makeProviderFailure({
3838 message:
3839 "The OpenCode session no longer exists. Send the message again to continue in a new session.",
3840 class: "provider_error",
3841 }),
3842 },
3843 "broken",
3844 ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))),
3845 }),
3846 Effect.tapError((cause) =>
3847 state.active === turn
3848 ? Effect.gen(function* () {
3849 // Without a clear rejection the server may have taken the
3850 // prompt, so the next turn checks before it prompts again.
3851 if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.unsettled = true;
3852 yield* finishTurn(state, {
3853 status: "failed",
3854 failure: makeProviderFailure({ cause, class: "provider_error" }),
3855 });
3856 })
3857 : Effect.void,
3858 ),
3859 );
3860 }).pipe(
3861 Effect.mapError((cause) =>
3862 isProviderAdapterError(cause)
3863 ? cause
3864 : new ProviderAdapter.ProviderAdapterTurnStartError({
3865 driver,
3866 threadId: turnInput.threadId,
3867 providerThreadId: turnInput.providerThread.id,
3868 runId: turnInput.runId,
3869 cause,
3870 }),
3871 ),
3872 ),
3873 steerTurn: (steerInput) =>
3874 Effect.gen(function* () {
3875 const sessionId = yield* sessionIdOf(steerInput.providerThread);
3876 const state = threads.get(sessionId);
3877 const turn = state?.active;
3878 if (
3879 state === undefined ||
3880 turn === undefined ||
3881 turn.providerTurn.id !== steerInput.providerTurnId ||
3882 turn.interrupted
3883 ) {
3884 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3885 driver: OPENCODE_PROVIDER,
3886 detail: `OpenCode turn ${steerInput.providerTurnId} is not active`,
3887 });
3888 }
3889 const inboxID = steerPromptId(sessionId, steerInput.message.messageId);
3890 // A retried steer that already reached the model is not sent again.
3891 if (turn.settledInbox.has(inboxID)) return;
3892 turn.steers.add(inboxID);
3893 state.strandedSteers.delete(inboxID);
3894 yield* client.session
3895 .prompt({
3896 sessionID: Session.ID.make(sessionId),
3897 id: inboxID,
3898 text: providerMessageTextWithAttachmentPaths({
3899 text: steerInput.message.text,
3900 attachments: steerInput.message.attachments,
3901 resolveAttachmentPath: (attachment) =>
3902 resolveAttachmentPath({
3903 attachmentsDir: serverConfig.attachmentsDir,
3904 attachment,
3905 }),
3906 }).trim(),
3907 delivery: "steer",
3908 })
3909 .pipe(
3910 Effect.tapError((cause) =>
3911 lock.withPermit(
3912 Effect.gen(function* () {
3913 turn.steers.delete(inboxID);
3914 // Without a clear refusal OpenCode may hold it; the next prompt takes it back.
3915 if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID);
3916 if (state.active === turn) yield* endIfSettled(state, turn);
3917 }),
3918 ),
3919 ),
3920 );
3921 }).pipe(
3922 Effect.mapError((cause) =>
3923 isProviderAdapterError(cause)
3924 ? cause
3925 : new ProviderAdapter.ProviderAdapterSteerRunError({
3926 driver,
3927 providerThreadId: steerInput.providerThread.id,
3928 providerTurnId: steerInput.providerTurnId,
3929 cause,
3930 }),
3931 ),
3932 ),
3933 interruptTurn: (interruptInput) =>
3934 Effect.gen(function* () {
3935 const sessionId = yield* sessionIdOf(interruptInput.providerThread);
3936 const state = threads.get(sessionId);
3937 if (state === undefined) return;
3938 const turn = state.active;
3939 // OpenCode stops a foreground subagent with its parent, but not a
3940 // background one: a user Stop (`requestRuntimeRestart`) stops those
3941 // too, and the execution OpenCode starts to report them. A turn
3942 // interrupted to restart it with new input leaves them running.
3943 if (interruptInput.requestRuntimeRestart === true) yield* stopBackground(state);
3944 if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) {
3945 return;
3946 }
3947 // The session answers with `session.execution.interrupted`, which ends
3948 // the turn. A server that does not answer in time is stuck, so the turn
3949 // ends here instead of waiting on it.
3950 turn.interrupted = true;
3951 const reply = yield* client.session
3952 .interrupt({ sessionID: Session.ID.make(sessionId) })
3953 .pipe(
3954 Effect.timeoutOption(INTERRUPT_TIMEOUT),
3955 // A Stop that never reached the server stopped nothing.
3956 Effect.tapError(() =>
3957 Effect.sync(() => {
3958 turn.interrupted = false;
3959 }),
3960 ),
3961 );
3962 if (reply._tag === "None") {
3963 state.unsettled = true;
3964 return yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
3965 }
3966 // Between the executions a late steer spans nothing runs yet. The
3967 // steers are taken back, and a run they already started is skipped.
3968 if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) {
3969 state.unsettled = true;
3970 yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
3971 return yield* cancelStrandedSteers(state);
3972 }
3973 // Nothing was running because the turn has not sent its request yet
3974 // (it is reading the workspace's commands or skills): it ends here,
3975 // and never sends it.
3976 if (!reply.value.interrupted && state.active === turn && turn.unsent) {
3977 return yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
3978 }
3979 // Nothing was running. Unless the execution already ended (its event
3980 // is on the way), the turn is still open and nothing stopped.
3981 if (!reply.value.interrupted && state.active === turn) {
3982 turn.interrupted = false;
3983 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3984 driver: OPENCODE_PROVIDER,
3985 detail: `OpenCode session ${sessionId} had nothing running to stop`,
3986 });
3987 }
3988 }).pipe(
3989 Effect.mapError((cause) =>
3990 isProviderAdapterError(cause)
3991 ? cause
3992 : new ProviderAdapter.ProviderAdapterInterruptError({
3993 driver,
3994 providerThreadId: interruptInput.providerThread.id,
3995 providerTurnId: interruptInput.providerTurnId,
3996 cause,
3997 }),
3998 ),
3999 ),
4000 unloadThread: ({ providerThread }) =>
4001 Effect.gen(function* () {
4002 const nativeId = providerThread.nativeThreadRef?.nativeId;
4003 const state = nativeId == null ? undefined : threads.get(nativeId);
4004 if (
4005 nativeId == null ||
4006 state === undefined ||
4007 state.active !== undefined ||
4008 hasBackground(state)
4009 ) {
4010 return;
4011 }
4012 threads.delete(nativeId);
4013 for (const [child, owner] of childOwners) {
4014 if (owner !== state) continue;
4015 childOwners.delete(child);
4016 threads.delete(child);
4017 }
4018 if (state.mcp !== undefined) yield* removeMcp(state.mcp);
4019 }),
4020 respondToRuntimeRequest: (requestInput) =>
4021 Effect.gen(function* () {
4022 const entry = pending.get(requestInput.requestId);
4023 if (entry === undefined || entry.answering) {
4024 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4025 driver,
4026 detail: `No pending OpenCode request ${requestInput.requestId}`,
4027 });
4028 }
4029 const { decision, answers } = requestInput;
4030 const { native } = entry;
4031 if (native.type === "permission" && decision === undefined) {
4032 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4033 driver,
4034 detail: `OpenCode approval request ${requestInput.requestId} requires a decision`,
4035 });
4036 }
4037 entry.answering = true;
4038 // OpenCode's own "always" saves a grant for the whole project, so a
4039 // session-wide answer is a rule on this session instead. The grant
4040 // is best effort: this request is answered either way.
4041 // The session that asked waits on the answer and holds the grant: a
4042 // subagent's own, not its parent's.
4043 const asker = threads.get(entry.sessionId) ?? entry.state;
4044 if (
4045 native.type === "permission" &&
4046 (decision === "acceptForSession" || decision === "acceptAlways")
4047 ) {
4048 const state = asker;
4049 for (const resource of native.save) {
4050 if (
4051 !state.grants.some(
4052 (grant) => grant.action === native.action && grant.resource === resource,
4053 )
4054 ) {
4055 state.grants.push({ action: native.action, resource, effect: "allow" });
4056 }
4057 }
4058 // A subagent runs under its thread's mode.
4059 yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true }));
4060 }
4061 const sessionID = Session.ID.make(entry.sessionId);
4062 // Best effort: the decline stands without the note.
4063 if (native.type === "permission" && decision === "decline") {
4064 yield* client.session
4065 .synthetic({
4066 sessionID,
4067 text: declinedNote(native.action, native.resources),
4068 delivery: "steer",
4069 resume: false,
4070 })
4071 .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true }));
4072 }
4073 const delivered = yield* native.type === "permission"
4074 ? deliver(
4075 client.permission
4076 .reply({
4077 sessionID,
4078 requestID: Permission.ID.make(native.id),
4079 decision: decision !== undefined && approves(decision) ? "once" : "reject",
4080 // Without a message OpenCode ends the whole run, which is Cancel.
4081 ...(decision === "decline" ? { message: DECLINED } : {}),
4082 })
4083 .pipe(Effect.catchTags(permissionGone)),
4084 )
4085 : deliver(
4086 (answers === undefined || decision === "decline" || decision === "cancel"
4087 ? client.session.form.cancel({
4088 sessionID: entry.sessionId,
4089 formID: Form.ID.make(native.id),
4090 })
4091 : client.session.form.reply({
4092 sessionID: entry.sessionId,
4093 formID: Form.ID.make(native.id),
4094 answer: formAnswer(native.form, answers),
4095 })
4096 ).pipe(Effect.catchTags(formGone)),
4097 );
4098 yield* forgetRequest(entry);
4099 if (!delivered) yield* abandonRequest(asker, "answer not delivered");
4100 }).pipe(
4101 Effect.mapError((cause) =>
4102 isProviderAdapterError(cause)
4103 ? cause
4104 : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
4105 driver,
4106 requestId: requestInput.requestId,
4107 cause,
4108 }),
4109 ),
4110 ),
4111 readThreadSnapshot: ({ providerThread }) =>
4112 Effect.gen(function* () {
4113 const sessionId = yield* sessionIdOf(providerThread);
4114 return yield* snapshotOf(providerThread, sessionId);
4115 }).pipe(
4116 Effect.mapError((cause) =>
4117 isProviderAdapterError(cause)
4118 ? cause
4119 : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
4120 driver,
4121 providerThreadId: providerThread.id,
4122 cause,
4123 }),
4124 ),
4125 ),
4126 rollbackThread: (rollbackInput) =>
4127 Effect.gen(function* () {
4128 const { providerThread, target } = rollbackInput;
4129 const sessionId = yield* sessionIdOf(providerThread);
4130 // A stopped run's end may still be on the stream, so `unsettled`
4131 // stays for the next turn to skip it.
4132 yield* ensureQuiet(sessionId, "roll back");
4133 // A session this runtime has not loaded yet is loaded the way a turn
4134 // would load it, which stops anything it still waits on. Requests it
4135 // stops end their run, so the server is asked again after that.
4136 if (!threads.has(sessionId)) {
4137 yield* runtime.resumeThread({ providerThread });
4138 yield* ensureQuiet(sessionId, "roll back");
4139 }
4140 const boundary = yield* boundaryAfter(
4141 rollbackInput.providerThreadTurns,
4142 providerThread.id,
4143 target.type === "provider_turn" ? target.providerTurn : undefined,
4144 yield* userMessages(sessionId),
4145 );
4146 if (boundary !== null) {
4147 yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary));
4148 }
4149 // The turns after the target left the history with their messages.
4150 // The snapshot lists the ones kept as T3 recorded them, which a
4151 // runtime that loaded the session after they ran never saw.
4152 const keptOrdinal = target.type === "provider_turn" ? target.providerTurn.ordinal : 0;
4153 const loaded = threads.get(sessionId);
4154 for (const [id, turn] of loaded?.providerTurns ?? []) {
4155 if (turn.ordinal > keptOrdinal) loaded?.providerTurns.delete(id);
4156 }
4157 const snapshot = yield* snapshotOf(providerThread, sessionId);
4158 const kept = new Map(
4159 rollbackInput.providerThreadTurns
4160 .filter(
4161 (turn) =>
4162 turn.providerThreadId === providerThread.id && turn.ordinal <= keptOrdinal,
4163 )
4164 .map((turn) => [turn.id, turn] as const),
4165 );
4166 for (const turn of snapshot.providerTurns) kept.set(turn.id, turn);
4167 // The snapshot's head is the last user message OpenCode kept.
4168 return { ...snapshot, providerTurns: [...kept.values()] };
4169 }).pipe(
4170 exclusive(rollbackInput.providerThread),
4171 Effect.mapError((cause) =>
4172 isProviderAdapterError(cause)
4173 ? cause
4174 : new ProviderAdapter.ProviderAdapterRollbackThreadError({
4175 driver,
4176 providerThreadId: rollbackInput.providerThread.id,
4177 checkpointId: rollbackInput.target.checkpointId,
4178 cause,
4179 }),
4180 ),
4181 ),
4182 forkThread: (forkInput) =>
4183 Effect.gen(function* () {
4184 const source = forkInput.sourceProviderThread;
4185 const sourceId = yield* sessionIdOf(source);
4186 // OpenCode forks whatever history the source has, so a fork taken
4187 // while anything writes to it would copy a run half done.
4188 yield* ensureQuiet(sourceId, "fork");
4189 const selected =
4190 forkInput.providerTurnId === undefined
4191 ? undefined
4192 : forkInput.sourceProviderTurns?.find((turn) => turn.id === forkInput.providerTurnId);
4193 if (forkInput.providerTurnId !== undefined && selected === undefined) {
4194 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4195 driver: OPENCODE_PROVIDER,
4196 detail: `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`,
4197 });
4198 }
4199 const before =
4200 selected === undefined
4201 ? null
4202 : yield* boundaryAfter(
4203 forkInput.sourceProviderTurns ?? [],
4204 source.id,
4205 selected,
4206 yield* userMessages(sourceId),
4207 );
4208 // A fork copies the source's history, model, location and rules; its
4209 // message ids are new, so it has no turns T3 could cut at yet.
4210 const forked = yield* client.session.fork({
4211 sessionID: Session.ID.make(sourceId),
4212 ...(before === null ? {} : { before: SessionMessage.ID.make(before) }),
4213 });
4214 const createdAt = yield* DateTime.now;
4215 const providerThread: OrchestrationV2ProviderThread = {
4216 id: idAllocator.derive.providerThread({ driver, nativeThreadId: forked.id }),
4217 driver,
4218 providerInstanceId: instanceId,
4219 providerSessionId: input.providerSessionId,
4220 appThreadId: forkInput.targetThreadId,
4221 ownerNodeId: forkInput.ownerNodeId ?? null,
4222 nativeThreadRef: ref(forked.id),
4223 nativeConversationHeadRef: null,
4224 status: "idle",
4225 firstRunOrdinal: null,
4226 lastRunOrdinal: null,
4227 handoffIds: [],
4228 forkedFrom: {
4229 providerThreadId: source.id,
4230 ...(forkInput.providerTurnId === undefined
4231 ? {}
4232 : { providerTurnId: forkInput.providerTurnId }),
4233 },
4234 createdAt,
4235 updatedAt: createdAt,
4236 };
4237 // A fork keeps the source's location. The target thread usually
4238 // inherits that worktree too, but its first turn starts without a
4239 // resume, so a target in another worktree takes the session there now.
4240 const cwd = forkInput.runtimePolicy?.cwd;
4241 const state = register(providerThread, forked, cwd ?? forked.location.directory);
4242 // The fork runs the target thread's mode, not whatever the source ran.
4243 yield* writeRules(state, forkInput.runtimePolicy ?? state.policy);
4244 if (cwd != null && forked.location.directory !== cwd) {
4245 yield* client.session.move({
4246 sessionID: Session.ID.make(forked.id),
4247 directory: AbsolutePath.make(cwd),
4248 });
4249 }
4250 return state.providerThread;
4251 }).pipe(
4252 exclusive(forkInput.sourceProviderThread),
4253 Effect.mapError((cause) =>
4254 isProviderAdapterError(cause)
4255 ? cause
4256 : new ProviderAdapter.ProviderAdapterForkThreadError({
4257 driver,
4258 providerThreadId: forkInput.sourceProviderThread.id,
4259 cause,
4260 }),
4261 ),
4262 ),
4263 };
4264 return runtime;
4265 });
4266
4267 return ProviderAdapter.ProviderAdapterV2.of({
4268 instanceId,
4269 driver,
4270 getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilities),
4271 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
4272 // The session borrows the instance's server for as long as it is open, so a
4273 // spawned server is not idle-stopped under a long tool call.
4274 openSession: (input) =>
4275 Effect.gen(function* () {
4276 const scope = yield* Scope.make();
4277 yield* Effect.addFinalizer((exit) => Scope.close(scope, exit));
4278 const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope));
4279 return yield* openSession(input, { connection, scope });
4280 }).pipe(
4281 Effect.mapError(
4282 (cause) =>
4283 new ProviderAdapter.ProviderAdapterOpenSessionError({
4284 driver,
4285 providerSessionId: input.providerSessionId,
4286 cause,
4287 }),
4288 ),
4289 ),
4290 });
4291});
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts deleted-2731
...@@ -1,2731 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2";
4import {
5 CheckpointId,
6 NodeId,
7 OpenCodeSettings,
8 ProjectId,
9 ProviderInstanceId,
10 ProviderSessionId,
11 ProviderThreadId,
12 ProviderTurnId,
13 RunAttemptId,
14 RunId,
15 MessageId,
16 ThreadId,
17 type OrchestrationV2ProviderThread,
18 type OrchestrationV2ProviderTurn,
19} from "@t3tools/contracts";
20import * as Effect from "effect/Effect";
21import * as Clock from "effect/Clock";
22import * as DateTime from "effect/DateTime";
23import * as Deferred from "effect/Deferred";
24import * as Fiber from "effect/Fiber";
25import * as Exit from "effect/Exit";
26import * as Queue from "effect/Queue";
27import * as Layer from "effect/Layer";
28import * as Option from "effect/Option";
29import * as Schema from "effect/Schema";
30import * as Scope from "effect/Scope";
31import * as Stream from "effect/Stream";
32import * as TestClock from "effect/testing/TestClock";
33
34import * as ServerConfig from "../../config.ts";
35import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
36import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts";
37import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
38
39import {
40 advanceOpenCodePromptAdmission,
41 cancelOpenCodePromptAdmission,
42 openCodeBoundaryAfterProviderTurn,
43 openCodeChildPermissionRules,
44 openCodePermissionRules,
45 openCodePermissionRequestKind,
46 openCodeToolProjectionKind,
47 makeOpenCodeProtocolLogger,
48 makeOpenCodeAdapterV2,
49 OPENCODE_PROVIDER,
50 reconcileOpenCodePromptAdmissionStatus,
51} from "./OpenCodeAdapterV2.ts";
52import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";
53
54const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
55const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
56 serverUrl: "http://test.invalid",
57});
58
59function promiseGate<A>() {
60 let resolve!: (value: A) => void;
61 const promise = new Promise<A>((done) => {
62 resolve = done;
63 });
64 return { promise, resolve };
65}
66
67function asyncEventStream() {
68 const values: Array<{ value: unknown; handled: ReturnType<typeof promiseGate<void>> }> = [];
69 const waiters: Array<(value: IteratorResult<unknown>) => void> = [];
70 let previousHandled: ReturnType<typeof promiseGate<void>> | undefined;
71 let closed = false;
72 return {
73 close() {
74 closed = true;
75 for (const waiter of waiters.splice(0)) waiter({ done: true, value: undefined });
76 },
77 push(value: unknown) {
78 const handled = promiseGate<void>();
79 const waiter = waiters.shift();
80 if (waiter) {
81 previousHandled = handled;
82 waiter({ done: false, value });
83 } else values.push({ value, handled });
84 return handled.promise;
85 },
86 stream: {
87 [Symbol.asyncIterator]() {
88 return {
89 next: () => {
90 previousHandled?.resolve();
91 if (closed) return Promise.resolve({ done: true as const, value: undefined });
92 const entry = values.shift();
93 if (entry !== undefined) {
94 previousHandled = entry.handled;
95 return Promise.resolve({ done: false as const, value: entry.value });
96 }
97 return new Promise<IteratorResult<unknown>>((resolve) => waiters.push(resolve));
98 },
99 };
100 },
101 },
102 };
103}
104
105const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({});
106
107function runtimePolicy(
108 runtimeMode: ProviderAdapterV2RuntimePolicy["runtimeMode"],
109 override: Partial<ProviderAdapterV2RuntimePolicy> = {},
110): ProviderAdapterV2RuntimePolicy {
111 return ProviderAdapterV2RuntimePolicy.make({
112 runtimeMode,
113 interactionMode: "default",
114 cwd: null,
115 ...override,
116 });
117}
118
119function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) {
120 return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission)
121 ?.action;
122}
123
124function providerTurn(input: {
125 readonly id: string;
126 readonly ordinal: number;
127 readonly nativeId: string | null;
128}): OrchestrationV2ProviderTurn {
129 return {
130 id: ProviderTurnId.make(input.id),
131 providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"),
132 nodeId: NodeId.make(`node:${input.id}`),
133 runAttemptId: null,
134 nativeTurnRef:
135 input.nativeId === null
136 ? null
137 : { driver: OPENCODE_PROVIDER, nativeId: input.nativeId, strength: "weak" },
138 ordinal: input.ordinal,
139 status: "completed",
140 startedAt: null,
141 completedAt: null,
142 };
143}
144
145const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(function* (
146 suffix: string,
147 nativeSessionId: string,
148 client: object,
149) {
150 const idAllocator = yield* IdAllocator.IdAllocatorV2;
151 const instanceId = ProviderInstanceId.make(`opencode-${suffix}`);
152 const threadId = ThreadId.make(`thread-opencode-${suffix}`);
153 const modelSelection = {
154 instanceId,
155 model: "anthropic/claude-sonnet",
156 options: [],
157 };
158 const policy = runtimePolicy("full-access", { cwd: "/workspace" });
159 const adapter = makeOpenCodeAdapterV2({
160 instanceId,
161 settings: OPEN_CODE_TEST_SETTINGS,
162 environment: {},
163 runtime: {
164 connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }),
165 createOpenCodeSdkClient: () => client,
166 } as unknown as OpenCodeRuntimeShape,
167 idAllocator,
168 serverConfig: {
169 cwd: "/workspace",
170 attachmentsDir: "/tmp/attachments",
171 } as ServerConfig.ServerConfig["Service"],
172 });
173 const runtime = yield* adapter.openSession({
174 threadId,
175 providerSessionId: ProviderSessionId.make(`session-opencode-${suffix}`),
176 modelSelection,
177 runtimePolicy: policy,
178 });
179 const providerThread = yield* runtime.ensureThread({
180 threadId,
181 modelSelection,
182 runtimePolicy: policy,
183 });
184 const now = yield* DateTime.now;
185 const startTurn = (
186 text = "hello",
187 startProviderThread: OrchestrationV2ProviderThread = providerThread,
188 ) =>
189 runtime.startTurn({
190 appThread: {
191 id: threadId,
192 projectId: ProjectId.make(`project-opencode-${suffix}`),
193 title: suffix,
194 providerInstanceId: modelSelection.instanceId,
195 modelSelection,
196 runtimeMode: "full-access",
197 interactionMode: "default",
198 branch: null,
199 worktreePath: null,
200 activeProviderThreadId: providerThread.id,
201 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
202 forkedFrom: null,
203 createdBy: "user",
204 creationSource: "web",
205 createdAt: now,
206 updatedAt: now,
207 archivedAt: null,
208 settledOverride: null,
209 settledAt: null,
210 lastVisitedAt: null,
211 deletedAt: null,
212 },
213 threadId,
214 runId: RunId.make(`run-opencode-${suffix}`),
215 runOrdinal: 1,
216 providerTurnOrdinal: 1,
217 attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`),
218 rootNodeId: NodeId.make(`node-opencode-${suffix}`),
219 providerThread: startProviderThread,
220 message: {
221 createdBy: "user",
222 creationSource: "web",
223 messageId: MessageId.make(`message-opencode-${suffix}`),
224 text,
225 attachments: [],
226 },
227 modelSelection,
228 runtimePolicy: policy,
229 });
230 return {
231 nativeSessionId,
232 now,
233 policy,
234 providerThread,
235 runId: RunId.make(`run-opencode-${suffix}`),
236 runtime,
237 startTurn,
238 threadId,
239 };
240});
241
242describe("OpenCodeAdapterV2", () => {
243 it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)(
244 "normalizes OpenCode step usage for %s turns",
245 (ending) =>
246 Effect.gen(function* () {
247 const nativeEvents = asyncEventStream();
248 let promptId = "";
249 const harness = yield* makeOpenCodeRuntimeHarness(`usage-${ending}`, "root", {
250 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
251 session: {
252 create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
253 promptAsync: async (input: { messageID: string }) => {
254 promptId = input.messageID;
255 return { data: true };
256 },
257 abort: async () => ({ data: true }),
258 children: async () => ({ data: [] }),
259 },
260 });
261 yield* harness.startTurn();
262 const received = yield* harness.runtime.events.pipe(
263 Stream.takeUntil((event) => event.type === "turn.terminal"),
264 Stream.runCollect,
265 Effect.forkScoped,
266 );
267 yield* Effect.promise(() =>
268 nativeEvents.push({
269 type: "message.updated",
270 properties: {
271 sessionID: "root",
272 info: { id: promptId, role: "user", time: { created: 1 } },
273 },
274 }),
275 );
276 const step = (id: string, messageID = "assistant") => ({
277 type: "message.part.updated",
278 properties: {
279 part: {
280 type: "step-finish",
281 id,
282 sessionID: "root",
283 messageID,
284 reason: "stop",
285 cost: 0.01,
286 tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 3, write: 4 } },
287 },
288 },
289 });
290 if (ending !== "unavailable") {
291 yield* Effect.promise(() => nativeEvents.push(step("one")));
292 yield* Effect.promise(() =>
293 nativeEvents.push({
294 type: "message.updated",
295 properties: {
296 sessionID: "root",
297 info: {
298 id: "assistant",
299 role: "assistant",
300 time: { created: 1 },
301 parentID: promptId,
302 },
303 },
304 }),
305 );
306 yield* Effect.promise(() => nativeEvents.push(step("one")));
307 yield* Effect.promise(() => nativeEvents.push(step("two")));
308 yield* Effect.promise(() =>
309 nativeEvents.push({
310 type: "message.part.removed",
311 properties: { sessionID: "root", messageID: "assistant", partID: "one" },
312 }),
313 );
314 yield* Effect.promise(() =>
315 nativeEvents.push({
316 type: "message.updated",
317 properties: {
318 sessionID: "root",
319 info: {
320 id: "old-assistant",
321 role: "assistant",
322 time: { created: 1 },
323 parentID: "old-prompt",
324 },
325 },
326 }),
327 );
328 yield* Effect.promise(() => nativeEvents.push(step("old", "old-assistant")));
329 }
330 if (ending === "unresolved")
331 yield* Effect.promise(() => nativeEvents.push(step("unknown", "unknown-assistant")));
332 if (ending === "reconnect") {
333 yield* Effect.promise(() =>
334 nativeEvents.push({ type: "server.connected", properties: {} }),
335 );
336 yield* Effect.promise(() =>
337 nativeEvents.push({ type: "server.connected", properties: {} }),
338 );
339 }
340 if (ending === "failed") {
341 yield* Effect.promise(() =>
342 nativeEvents.push({
343 type: "session.error",
344 properties: {
345 sessionID: "root",
346 error: { name: "UnknownError", data: { message: "failed" } },
347 },
348 }),
349 );
350 } else {
351 yield* Effect.promise(() =>
352 nativeEvents.push({
353 type: "session.status",
354 properties: { sessionID: "root", status: { type: "busy" } },
355 }),
356 );
357 yield* Effect.promise(() =>
358 nativeEvents.push({
359 type: "session.status",
360 properties: { sessionID: "root", status: { type: "idle" } },
361 }),
362 );
363 }
364 const events = yield* Fiber.join(received);
365 const completed = events.findLast((event) => event.type === "provider_turn.updated");
366 assert.deepEqual(
367 completed?.providerTurn.turnTokenUsage,
368 ending === "unavailable"
369 ? { usageStatus: "unavailable", usageScope: "main_agent", hasSubagents: false }
370 : {
371 usageStatus: ending === "completed" ? "complete" : "partial",
372 usageScope: "main_agent",
373 inputTokens: 34,
374 cachedInputTokens: 6,
375 cacheCreationTokens: 8,
376 outputTokens: 14,
377 reasoningTokens: 4,
378 hasSubagents: false,
379 },
380 );
381 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
382 );
383
384 it.effect.each(["permission", "question"] as const)(
385 "cancels an undelivered %s reply at its deadline",
386 (kind) =>
387 Effect.gen(function* () {
388 const nativeEvents = asyncEventStream();
389 const called = promiseGate<void>();
390 let signal: AbortSignal | undefined;
391 let deliver = false;
392 const harness = yield* makeOpenCodeRuntimeHarness(`reply-${kind}`, "root", {
393 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
394 session: {
395 create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
396 promptAsync: async () => ({ data: true }),
397 abort: async () => ({ data: true }),
398 children: async () => ({ data: [] }),
399 },
400 [kind]: {
401 reply: async (_input: unknown, options: { signal: AbortSignal }) => {
402 if (deliver) return { data: true };
403 signal = options.signal;
404 called.resolve();
405 return new Promise(() => {});
406 },
407 },
408 });
409 yield* harness.startTurn();
410 const received = yield* harness.runtime.events.pipe(
411 Stream.filter((event) => event.type === "runtime_request.updated"),
412 Stream.take(1),
413 Stream.runCollect,
414 Effect.forkScoped,
415 );
416 yield* Effect.promise(() =>
417 nativeEvents.push({
418 type: `${kind}.asked`,
419 properties:
420 kind === "permission"
421 ? {
422 id: "request",
423 sessionID: "root",
424 permission: "bash",
425 patterns: ["*"],
426 always: [],
427 metadata: {},
428 }
429 : {
430 id: "request",
431 sessionID: "root",
432 questions: [
433 {
434 header: "Choice",
435 question: "Which?",
436 options: [{ label: "Yes", description: "Proceed" }],
437 },
438 ],
439 },
440 }),
441 );
442 const request = (yield* Fiber.join(received))[0]!.runtimeRequest;
443 const response = {
444 requestId: request.id,
445 ...(kind === "permission"
446 ? { decision: "accept" as const }
447 : { answers: { Choice: "Yes" } }),
448 };
449 const reply = yield* harness.runtime
450 .respondToRuntimeRequest(response)
451 .pipe(Effect.exit, Effect.forkScoped);
452 yield* Effect.promise(() => called.promise);
453 yield* TestClock.adjust("10 seconds");
454 assert.isTrue(Exit.isFailure(yield* Fiber.join(reply)));
455 assert.isTrue(signal?.aborted);
456 deliver = true;
457 // Failed delivery leaves the request available for an explicit retry.
458 yield* harness.runtime.respondToRuntimeRequest(response);
459 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
460 );
461
462 it.effect("aborts external root and descendants before closing the event stream", () =>
463 Effect.gen(function* () {
464 const scope = yield* Scope.make();
465 const nativeEvents = asyncEventStream();
466 const calls: string[] = [];
467 yield* makeOpenCodeRuntimeHarness("release", "root", {
468 event: {
469 subscribe: async (_input: unknown, options: { signal: AbortSignal }) => {
470 options.signal.addEventListener("abort", () => {
471 calls.push("stream.close");
472 nativeEvents.close();
473 });
474 return { stream: nativeEvents.stream };
475 },
476 },
477 session: {
478 create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
479 abort: async ({ sessionID }: { sessionID: string }) => {
480 calls.push(`abort:${sessionID}`);
481 return { data: true };
482 },
483 children: async ({ sessionID }: { sessionID: string }) => {
484 calls.push(`children:${sessionID}`);
485 return { data: sessionID === "root" ? [{ id: "child" }] : [] };
486 },
487 },
488 }).pipe(Effect.provideService(Scope.Scope, scope));
489 yield* Scope.close(scope, Exit.void);
490 assert.deepEqual(calls, [
491 "abort:root",
492 "children:root",
493 "abort:child",
494 "children:child",
495 "stream.close",
496 ]);
497 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
498 );
499
500 it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)(
501 "reports descendant cleanup %s",
502 (failure) =>
503 Effect.gen(function* () {
504 const nativeEvents = asyncEventStream();
505 const called = promiseGate<void>();
506 let childSignal: AbortSignal | undefined;
507 const harness = yield* makeOpenCodeRuntimeHarness(`cleanup-${failure}`, "root", {
508 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
509 session: {
510 create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
511 promptAsync: async () => ({ data: true }),
512 get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
513 messages: async () => ({ data: [] }),
514 children: async ({ sessionID }: { sessionID: string }) => {
515 if (failure === "enumeration") throw new Error("cannot enumerate");
516 return { data: sessionID === "root" ? [{ id: "child" }] : [] };
517 },
518 abort: async (
519 { sessionID }: { sessionID: string },
520 options: { signal: AbortSignal },
521 ) => {
522 if (sessionID === "root") return { data: true };
523 if (failure === "timeout" && childSignal?.aborted) return { data: true };
524 childSignal = options.signal;
525 called.resolve();
526 if (failure === "timeout") {
527 return new Promise(() => {});
528 }
529 if (failure === "not-found") throw { status: 404 };
530 throw new Error("child abort failed");
531 },
532 },
533 });
534 yield* harness.startTurn();
535 const snapshot = yield* harness.runtime.readThreadSnapshot({
536 providerThread: harness.providerThread,
537 });
538 const stop = yield* harness.runtime
539 .interruptTurn({
540 providerThread: harness.providerThread,
541 providerTurnId: snapshot.providerTurns.at(-1)!.id,
542 })
543 .pipe(Effect.exit, Effect.forkScoped);
544 if (failure === "timeout") {
545 yield* Effect.promise(() => called.promise);
546 yield* TestClock.adjust("15 seconds");
547 }
548 const result = yield* Fiber.join(stop);
549 assert.equal(Exit.isSuccess(result), failure === "not-found");
550 if (failure === "timeout") assert.isTrue(childSignal?.aborted);
551 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
552 );
553
554 it.effect(
555 "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads",
556 () =>
557 Effect.gen(function* () {
558 const nativeEvents = asyncEventStream();
559 const nativeSessionId = "native-opencode-tool-lifecycle";
560 const harness = yield* makeOpenCodeRuntimeHarness("tool-lifecycle", nativeSessionId, {
561 event: {
562 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
563 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
564 return { stream: nativeEvents.stream };
565 },
566 },
567 session: {
568 create: async () => ({
569 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
570 }),
571 promptAsync: async () => ({ data: true }),
572 },
573 });
574 yield* harness.startTurn();
575 const received = yield* harness.runtime.events.pipe(
576 Stream.takeUntil(
577 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
578 ),
579 Stream.runCollect,
580 Effect.forkScoped,
581 );
582 const states = [
583 { status: "pending", input: { command: "pwd" }, raw: "" },
584 {
585 status: "running",
586 input: { command: "pwd" },
587 title: "Working directory",
588 time: { start: 1 },
589 },
590 {
591 status: "completed",
592 input: { command: "pwd" },
593 output: "/repo\n",
594 title: "Working directory",
595 metadata: {},
596 time: { start: 1, end: 2 },
597 },
598 {
599 status: "error",
600 input: { command: "pwd" },
601 error: "Command failed",
602 time: { start: 3, end: 4 },
603 },
604 ] satisfies ReadonlyArray<ToolPart["state"]>;
605 for (const state of states) {
606 yield* Effect.promise(() =>
607 nativeEvents.push({
608 type: "message.part.updated",
609 properties: {
610 sessionID: nativeSessionId,
611 part: {
612 id: state.status === "error" ? "part-failed" : "part-working",
613 sessionID: nativeSessionId,
614 messageID: "late-assistant",
615 type: "tool",
616 callID: state.status === "error" ? "call-failed" : "call-working",
617 tool: "bash",
618 state,
619 },
620 },
621 }),
622 );
623 }
624 yield* Effect.promise(() =>
625 nativeEvents.push({
626 type: "message.part.updated",
627 properties: {
628 sessionID: nativeSessionId,
629 part: {
630 id: "part-read",
631 sessionID: nativeSessionId,
632 messageID: "late-assistant",
633 type: "tool",
634 callID: "call-read",
635 tool: "read",
636 state: { status: "pending", input: { filePath: "/outside/file" }, raw: "" },
637 },
638 },
639 }),
640 );
641 yield* Effect.promise(() =>
642 nativeEvents.push({
643 type: "permission.asked",
644 properties: {
645 id: "read-permission",
646 sessionID: nativeSessionId,
647 permission: "external_directory",
648 patterns: ["/outside/*"],
649 always: [],
650 metadata: {},
651 tool: { messageID: "late-assistant", callID: "call-read" },
652 },
653 }),
654 );
655 yield* Effect.promise(() =>
656 nativeEvents.push({
657 type: "message.part.updated",
658 properties: {
659 sessionID: nativeSessionId,
660 part: {
661 id: "part-text",
662 sessionID: nativeSessionId,
663 messageID: "late-assistant",
664 type: "text",
665 text: "Tool results received",
666 time: { start: 4 },
667 },
668 },
669 }),
670 );
671 yield* Effect.promise(() =>
672 nativeEvents.push({
673 type: "message.updated",
674 properties: {
675 sessionID: nativeSessionId,
676 info: {
677 id: "late-assistant",
678 sessionID: nativeSessionId,
679 role: "assistant",
680 time: { created: 1, completed: 4 },
681 },
682 },
683 }),
684 );
685 yield* Effect.promise(() =>
686 nativeEvents.push({
687 type: "session.compacted",
688 properties: { sessionID: nativeSessionId },
689 }),
690 );
691 const events = yield* Fiber.join(received);
692 const items = events.flatMap((event) =>
693 event.type === "turn_item.updated" ? [event.turnItem] : [],
694 );
695 const commands = items.filter((item) => item.type === "command_execution");
696 assert.deepEqual(
697 commands.map((item) => item.status),
698 ["pending", "running", "completed", "failed"],
699 );
700 assert.deepEqual(
701 commands.map((item) => item.input),
702 ["pwd", "pwd", "pwd", "pwd"],
703 );
704 assert.equal(commands[2]?.output, "/repo\n");
705 assert.equal(commands[3]?.output, "Command failed");
706 assert.isTrue(
707 events.some(
708 (event) =>
709 event.type === "runtime_request.updated" && event.runtimeRequest.kind === "file-read",
710 ),
711 );
712 const assistant = items.filter((item) => item.type === "assistant_message");
713 assert.equal(assistant.at(-1)?.text, "Tool results received");
714 assert.equal(assistant.at(-1)?.status, "completed");
715 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
716 );
717
718 // Event order from a live OpenCode 1.18.32 run of a `task` call with
719 // background=true: the task part completes at launch, the root session
720 // settles, and the child session stays busy until its own work ends.
721 it.effect("reports a background task child as pending work after the root turn settles", () =>
722 Effect.gen(function* () {
723 const nativeEvents = asyncEventStream();
724 const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event));
725 const root = "ses_root";
726 const child = "ses_child";
727 let promptId = "";
728 const harness = yield* makeOpenCodeRuntimeHarness("background-child", root, {
729 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
730 session: {
731 create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }),
732 get: async () => ({
733 data: { id: child, parentID: root, permission: [], time: { created: 2, updated: 2 } },
734 }),
735 update: async () => ({ data: { id: child, parentID: root } }),
736 promptAsync: async (input: { messageID: string }) => {
737 promptId = input.messageID;
738 return { data: true };
739 },
740 abort: async () => ({ data: true }),
741 children: async () => ({ data: [] }),
742 },
743 });
744 const hasPendingBackgroundWork = harness.runtime.hasPendingBackgroundWork;
745 if (hasPendingBackgroundWork === undefined) {
746 return yield* Effect.die("OpenCode runtime must expose hasPendingBackgroundWork.");
747 }
748 yield* harness.startTurn();
749 const terminal = yield* harness.runtime.events.pipe(
750 Stream.filter((event) => event.type === "turn.terminal"),
751 Stream.runHead,
752 Effect.forkScoped,
753 );
754 const taskPart = (status: "running" | "completed") => ({
755 type: "message.part.updated",
756 properties: {
757 sessionID: root,
758 part: {
759 id: "prt_task",
760 sessionID: root,
761 messageID: "msg_root_assistant",
762 type: "tool",
763 tool: "task",
764 callID: "call_task",
765 state: {
766 status,
767 input: {
768 description: "Background sleep task",
769 prompt: "sleep",
770 subagent_type: "general",
771 },
772 title: "Background sleep task",
773 metadata: { parentSessionId: root, sessionId: child, background: true },
774 time: { start: 3, ...(status === "completed" ? { end: 3 } : {}) },
775 ...(status === "completed" ? { output: "Background task started" } : {}),
776 },
777 },
778 },
779 });
780 const status = (sessionID: string, type: "busy" | "idle") => ({
781 type: "session.status",
782 properties: { sessionID, status: { type } },
783 });
784
785 yield* push({
786 type: "message.updated",
787 properties: {
788 sessionID: root,
789 info: { id: promptId, sessionID: root, role: "user", time: { created: 1 } },
790 },
791 });
792 yield* push(status(root, "busy"));
793 yield* push(taskPart("running"));
794 yield* push({
795 type: "session.created",
796 properties: {
797 sessionID: child,
798 info: { id: child, parentID: root, time: { created: 2, updated: 2 } },
799 },
800 });
801 yield* push(status(child, "busy"));
802 yield* push(taskPart("completed"));
803 yield* push(status(root, "idle"));
804 assert.equal(Option.getOrUndefined(yield* Fiber.join(terminal))?.status, "completed");
805 assert.isTrue(yield* hasPendingBackgroundWork, "the running child must pin idle release");
806
807 yield* push(status(child, "idle"));
808 yield* push({ type: "session.idle", properties: { sessionID: child } });
809 assert.isFalse(yield* hasPendingBackgroundWork, "an idle child must not pin idle release");
810 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
811 );
812
813 it.effect("titles OpenCode reads and searches from their input", () =>
814 Effect.gen(function* () {
815 const nativeEvents = asyncEventStream();
816 const nativeSessionId = "native-opencode-search";
817 const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, {
818 event: {
819 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
820 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
821 return { stream: nativeEvents.stream };
822 },
823 },
824 session: {
825 create: async () => ({
826 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
827 }),
828 promptAsync: async () => ({ data: true }),
829 },
830 });
831 yield* harness.startTurn();
832 const received = yield* harness.runtime.events.pipe(
833 Stream.takeUntil(
834 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
835 ),
836 Stream.runCollect,
837 Effect.forkScoped,
838 );
839 for (const [tool, input, output] of [
840 ["read", { filePath: "src/env.ts" }, "---\nfile body"],
841 ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"],
842 ["websearch", { query: "OpenCode documentation" }, "---\nfile body"],
843 ["glob", { pattern: "missing", path: "apps/web" }, ""],
844 ["codesearch", {}, " \n\t"],
845 ] as const) {
846 yield* Effect.promise(() =>
847 nativeEvents.push({
848 type: "message.part.updated",
849 properties: {
850 sessionID: nativeSessionId,
851 part: {
852 id: `part-${tool}`,
853 sessionID: nativeSessionId,
854 messageID: "assistant-search",
855 type: "tool",
856 callID: `call-${tool}`,
857 tool,
858 state: {
859 status: "completed",
860 input,
861 output,
862 title: tool,
863 metadata: {},
864 time: { start: 1, end: 2 },
865 },
866 },
867 },
868 }),
869 );
870 }
871 yield* Effect.promise(() =>
872 nativeEvents.push({
873 type: "session.compacted",
874 properties: { sessionID: nativeSessionId },
875 }),
876 );
877 const items = (yield* Fiber.join(received)).flatMap((event) =>
878 event.type === "turn_item.updated" ? [event.turnItem] : [],
879 );
880 const read = items.find((item) => item.type === "dynamic_tool");
881 assert.equal(read?.title, "Read src/env.ts");
882 const grep = items.find((item) => item.type === "file_search");
883 assert.equal(grep?.title, "Searched TODO in web");
884 assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO");
885 assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [
886 { fileName: "apps/web", preview: "---\nfile body" },
887 ]);
888 const webSearch = items.find((item) => item.type === "web_search");
889 assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [
890 "OpenCode documentation",
891 ]);
892 assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [
893 { snippet: "---\nfile body" },
894 ]);
895 const emptyFileSearch = items.find(
896 (item) => item.type === "file_search" && item.pattern === "missing",
897 );
898 assert.ok(emptyFileSearch?.type === "file_search");
899 assert.equal(emptyFileSearch.results, undefined);
900 const emptyWebSearch = items.find(
901 (item) => item.type === "web_search" && item.patterns === undefined,
902 );
903 assert.ok(emptyWebSearch?.type === "web_search");
904 assert.equal(emptyWebSearch.results, undefined);
905 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
906 );
907
908 it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () =>
909 Effect.gen(function* () {
910 const nativeEvents = asyncEventStream();
911 const nativeSessionId = "native-opencode-mcp";
912 let statusReads = 0;
913 const harness = yield* makeOpenCodeRuntimeHarness("mcp-presentation", nativeSessionId, {
914 event: {
915 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
916 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
917 return { stream: nativeEvents.stream };
918 },
919 },
920 session: {
921 create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }),
922 promptAsync: async () => ({ data: true }),
923 },
924 mcp: {
925 status: async () => {
926 statusReads++;
927 return {
928 data: {
929 "my.server_with_underscores": { status: "connected" },
930 ambiguous: { status: "connected" },
931 ambiguous_server: { status: "connected" },
932 code: { status: "connected" },
933 apply: { status: "connected" },
934 },
935 };
936 },
937 },
938 });
939 yield* harness.startTurn();
940 const received = yield* harness.runtime.events.pipe(
941 Stream.takeUntil(
942 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
943 ),
944 Stream.runCollect,
945 Effect.forkScoped,
946 );
947 for (const status of ["running", "completed", "error"] as const) {
948 yield* Effect.promise(() =>
949 nativeEvents.push({
950 type: "message.part.updated",
951 properties: {
952 sessionID: nativeSessionId,
953 part: {
954 id: "mcp-part",
955 sessionID: nativeSessionId,
956 messageID: "mcp-assistant",
957 type: "tool",
958 callID: "mcp-call",
959 tool: "my_server_with_underscores_edit_document",
960 state: {
961 status,
962 input: { document: "remote-doc" },
963 title: "Edit remote document",
964 metadata: {},
965 output: "saved",
966 error: "failed",
967 time: { start: 1, end: 2 },
968 },
969 },
970 },
971 }),
972 );
973 }
974 yield* Effect.promise(() =>
975 nativeEvents.push({
976 type: "message.part.updated",
977 properties: {
978 sessionID: nativeSessionId,
979 part: {
980 id: "ambiguous-part",
981 sessionID: nativeSessionId,
982 messageID: "mcp-assistant",
983 type: "tool",
984 callID: "ambiguous-call",
985 tool: "ambiguous_server_edit_document",
986 state: {
987 status: "completed",
988 input: {},
989 title: "Ambiguous tool",
990 metadata: {},
991 output: "",
992 time: { start: 1, end: 2 },
993 },
994 },
995 },
996 }),
997 );
998 for (const tool of ["code_search", "apply_patch"]) {
999 yield* Effect.promise(() =>
1000 nativeEvents.push({
1001 type: "message.part.updated",
1002 properties: {
1003 sessionID: nativeSessionId,
1004 part: {
1005 id: `native-${tool}`,
1006 sessionID: nativeSessionId,
1007 messageID: "mcp-assistant",
1008 type: "tool",
1009 callID: `native-${tool}`,
1010 tool,
1011 state: {
1012 status: "completed",
1013 input: { query: "needle", filePath: "local.ts" },
1014 title: `Native ${tool}`,
1015 metadata: {},
1016 output: "done",
1017 time: { start: 1, end: 2 },
1018 },
1019 },
1020 },
1021 }),
1022 );
1023 }
1024 yield* Effect.promise(() =>
1025 nativeEvents.push({
1026 type: "session.compacted",
1027 properties: { sessionID: nativeSessionId },
1028 }),
1029 );
1030 const allItems = (yield* Fiber.join(received)).flatMap((event) =>
1031 event.type === "turn_item.updated" ? [event.turnItem] : [],
1032 );
1033 assert.equal(
1034 allItems.find((item) => item.title === "Native code_search")?.type,
1035 "web_search",
1036 );
1037 assert.equal(
1038 allItems.find((item) => item.title === "Native apply_patch")?.type,
1039 "file_change",
1040 );
1041 const items = allItems.filter((item) => item.type === "dynamic_tool");
1042 assert.deepEqual(
1043 items.slice(0, 3).map((item) => item.status),
1044 ["running", "completed", "failed"],
1045 );
1046 assert.deepEqual(
1047 items.slice(0, 3).map((item) => item.title),
1048 ["Edit remote document", "Edit remote document", "edit document"],
1049 );
1050 for (const item of items.slice(0, 3)) {
1051 assert.deepEqual(item.toolSource, {
1052 key: "mcp:my.server_with_underscores",
1053 name: "my.server with underscores",
1054 kind: "integration",
1055 });
1056 assert.deepEqual(item.input, { document: "remote-doc" });
1057 }
1058 assert.equal(items[3]?.toolName, "ambiguous_server_edit_document");
1059 assert.isUndefined(items[3]?.toolSource);
1060 assert.equal(statusReads, 1);
1061 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1062 );
1063
1064 it.effect.each(["failure", "timeout"] as const)("retries MCP status after %s", (failure) =>
1065 Effect.gen(function* () {
1066 const nativeEvents = asyncEventStream();
1067 const called = promiseGate<void>();
1068 const sessionId = `mcp-status-${failure}`;
1069 let statusReads = 0;
1070 let signal: AbortSignal | undefined;
1071 const harness = yield* makeOpenCodeRuntimeHarness(sessionId, sessionId, {
1072 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
1073 session: {
1074 create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }),
1075 promptAsync: async () => ({ data: true }),
1076 },
1077 mcp: {
1078 status: async (_input: unknown, options: { signal?: AbortSignal }) => {
1079 statusReads++;
1080 if (statusReads === 1) {
1081 signal = options?.signal;
1082 called.resolve();
1083 if (failure === "timeout") return new Promise(() => {});
1084 throw new Error("MCP status unavailable");
1085 }
1086 return { data: { weather: { status: "connected" } } };
1087 },
1088 },
1089 });
1090 yield* harness.startTurn();
1091 const received = yield* harness.runtime.events.pipe(
1092 Stream.takeUntil(
1093 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
1094 ),
1095 Stream.runCollect,
1096 Effect.forkScoped,
1097 );
1098 for (const status of ["running", "completed"] as const) {
1099 const emitted = yield* Effect.promise(() =>
1100 nativeEvents.push({
1101 type: "message.part.updated",
1102 properties: {
1103 sessionID: sessionId,
1104 part: {
1105 id: "weather-part",
1106 sessionID: sessionId,
1107 messageID: "weather-message",
1108 type: "tool",
1109 callID: "weather-call",
1110 tool: "weather_edit_document",
1111 state: {
1112 status,
1113 input: { document: "remote-doc" },
1114 title: "Edit remote document",
1115 metadata: {},
1116 output: "saved",
1117 time: { start: 1, end: 2 },
1118 },
1119 },
1120 },
1121 }),
1122 ).pipe(Effect.forkScoped);
1123 if (status === "running" && failure === "timeout") {
1124 yield* Effect.promise(() => called.promise);
1125 yield* TestClock.adjust("1 second");
1126 }
1127 yield* Fiber.join(emitted);
1128 }
1129 yield* Effect.promise(() =>
1130 nativeEvents.push({ type: "session.compacted", properties: { sessionID: sessionId } }),
1131 );
1132 const completed = (yield* Fiber.join(received)).find(
1133 (event) =>
1134 event.type === "turn_item.updated" &&
1135 event.turnItem.type === "dynamic_tool" &&
1136 event.turnItem.status === "completed",
1137 );
1138 assert.equal(statusReads, 2);
1139 assert.equal(
1140 completed?.type === "turn_item.updated" && completed.turnItem.title,
1141 "Edit remote document",
1142 );
1143 assert.deepEqual(completed?.type === "turn_item.updated" && completed.turnItem.toolSource, {
1144 key: "mcp:weather",
1145 name: "weather",
1146 kind: "integration",
1147 });
1148 if (failure === "timeout") assert.isTrue(signal?.aborted);
1149 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1150 );
1151
1152 it.effect("admits a native command on its user receipt before generation completes", () =>
1153 Effect.gen(function* () {
1154 const nativeEvents = asyncEventStream();
1155 const release = promiseGate<void>();
1156 const calls = yield* Queue.unbounded<{
1157 messageID: string;
1158 command: string;
1159 arguments: string;
1160 model: string;
1161 }>();
1162 let promptCalls = 0;
1163 const sessionId = "native-command";
1164 const harness = yield* makeOpenCodeRuntimeHarness("native-command", sessionId, {
1165 event: {
1166 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1167 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1168 return { stream: nativeEvents.stream };
1169 },
1170 },
1171 command: { list: async () => ({ data: [{ name: "review" }] }) },
1172 session: {
1173 create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }),
1174 command: async (input: {
1175 messageID: string;
1176 command: string;
1177 arguments: string;
1178 model: string;
1179 }) => {
1180 Queue.offerUnsafe(calls, input);
1181 await release.promise;
1182 return { data: true };
1183 },
1184 promptAsync: async () => {
1185 promptCalls++;
1186 return { data: true };
1187 },
1188 },
1189 });
1190 const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped);
1191 const call = yield* Queue.take(calls);
1192 assert.equal(call.command, "review");
1193 assert.equal(call.arguments, "staged changes");
1194 assert.equal(call.model, "anthropic/claude-sonnet");
1195 yield* Effect.promise(() =>
1196 nativeEvents.push({
1197 type: "message.updated",
1198 properties: {
1199 sessionID: sessionId,
1200 info: {
1201 id: call.messageID,
1202 sessionID: sessionId,
1203 role: "user",
1204 time: { created: DateTime.toEpochMillis(harness.now) },
1205 },
1206 },
1207 }),
1208 );
1209 yield* Fiber.join(start);
1210 assert.equal(promptCalls, 0);
1211 release.resolve();
1212 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1213 );
1214
1215 it.effect("sends unadvertised slash commands as ordinary prompts", () =>
1216 Effect.gen(function* () {
1217 const nativeEvents = asyncEventStream();
1218 const prompts: unknown[] = [];
1219 const harness = yield* makeOpenCodeRuntimeHarness("unknown-command", "unknown-command", {
1220 event: {
1221 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1222 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1223 return { stream: nativeEvents.stream };
1224 },
1225 },
1226 command: { list: async () => ({ data: [{ name: "review" }] }) },
1227 session: {
1228 create: async () => ({
1229 data: { id: "unknown-command", time: { created: 1, updated: 1 } },
1230 }),
1231 promptAsync: async (input: { parts: unknown }) => {
1232 prompts.push(input.parts);
1233 return { data: true };
1234 },
1235 },
1236 });
1237 yield* harness.startTurn("/unknown words");
1238 assert.deepEqual(prompts, [[{ type: "text", text: "/unknown words" }]]);
1239 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1240 );
1241
1242 it.effect("compacts with the native summarize API and emits a completed compaction", () =>
1243 Effect.gen(function* () {
1244 const nativeEvents = asyncEventStream();
1245 const summarizeCalls: Array<unknown> = [];
1246 let promptCalls = 0;
1247 const harness = yield* makeOpenCodeRuntimeHarness("compact", "native-opencode-compact", {
1248 event: {
1249 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1250 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1251 return { stream: nativeEvents.stream };
1252 },
1253 },
1254 session: {
1255 create: async () => ({
1256 data: { id: "native-opencode-compact", time: { created: 1, updated: 1 } },
1257 }),
1258 summarize: async (input: unknown) => {
1259 summarizeCalls.push(input);
1260 return { data: true };
1261 },
1262 promptAsync: async () => {
1263 promptCalls += 1;
1264 return { data: true };
1265 },
1266 },
1267 });
1268 yield* harness.startTurn("/compact");
1269 const events = yield* harness.runtime.events.pipe(
1270 Stream.takeUntil((event) => event.type === "turn.terminal"),
1271 Stream.runCollect,
1272 );
1273 assert.deepEqual(summarizeCalls, [
1274 {
1275 sessionID: "native-opencode-compact",
1276 providerID: "anthropic",
1277 modelID: "claude-sonnet",
1278 auto: false,
1279 },
1280 ]);
1281 assert.equal(promptCalls, 0);
1282 assert.equal(
1283 events.filter(
1284 (event) =>
1285 event.type === "turn_item.updated" &&
1286 event.turnItem.type === "compaction" &&
1287 event.turnItem.status === "completed",
1288 ).length,
1289 1,
1290 );
1291 assert.isTrue(
1292 events.some((event) => event.type === "turn.terminal" && event.status === "completed"),
1293 );
1294 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1295 );
1296
1297 it.effect("ends a turn on the provider thread it started on", () =>
1298 Effect.gen(function* () {
1299 const nativeEvents = asyncEventStream();
1300 const harness = yield* makeOpenCodeRuntimeHarness(
1301 "forked-row",
1302 "native-opencode-forked-row",
1303 {
1304 event: {
1305 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1306 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1307 return { stream: nativeEvents.stream };
1308 },
1309 },
1310 session: {
1311 create: async () => ({
1312 data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } },
1313 }),
1314 summarize: async () => ({ data: true }),
1315 },
1316 },
1317 );
1318 // A forked run starts on its own row for the same native session, while
1319 // the adapter tracks the session under the id it minted.
1320 const forkedRow = {
1321 ...harness.providerThread,
1322 id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"),
1323 };
1324 yield* harness.startTurn("/compact", forkedRow);
1325 const terminal = yield* harness.runtime.events.pipe(
1326 Stream.filter(
1327 (event): event is Extract<typeof event, { type: "turn.terminal" }> =>
1328 event.type === "turn.terminal",
1329 ),
1330 Stream.runHead,
1331 );
1332 assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id);
1333 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1334 );
1335
1336 it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () =>
1337 Effect.gen(function* () {
1338 const nativeEvents = asyncEventStream();
1339 const nativeSessionId = "native-opencode-reverted-snapshot";
1340 const messages = [
1341 {
1342 info: {
1343 id: "user-kept",
1344 sessionID: nativeSessionId,
1345 role: "user",
1346 time: { created: 1 },
1347 },
1348 parts: [{ type: "text", text: "keep this prompt" }],
1349 },
1350 {
1351 info: {
1352 id: "assistant-kept",
1353 sessionID: nativeSessionId,
1354 role: "assistant",
1355 time: { created: 2, completed: 3 },
1356 },
1357 parts: [{ type: "text", text: "keep this answer" }],
1358 },
1359 {
1360 info: {
1361 id: "user-reverted",
1362 sessionID: nativeSessionId,
1363 role: "user",
1364 time: { created: 4 },
1365 },
1366 parts: [{ type: "text", text: "remove this prompt" }],
1367 },
1368 {
1369 info: {
1370 id: "assistant-reverted",
1371 sessionID: nativeSessionId,
1372 role: "assistant",
1373 time: { created: 5, completed: 6 },
1374 },
1375 parts: [{ type: "text", text: "remove this answer" }],
1376 },
1377 ];
1378 const harness = yield* makeOpenCodeRuntimeHarness("reverted-snapshot", nativeSessionId, {
1379 event: {
1380 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1381 options.signal?.addEventListener("abort", () => nativeEvents.close(), {
1382 once: true,
1383 });
1384 return { stream: nativeEvents.stream };
1385 },
1386 },
1387 session: {
1388 create: async () => ({
1389 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
1390 }),
1391 get: async () => ({
1392 data: {
1393 id: nativeSessionId,
1394 time: { created: 1, updated: 6 },
1395 revert: { messageID: "user-reverted" },
1396 },
1397 }),
1398 messages: async () => ({ data: messages }),
1399 },
1400 });
1401
1402 const snapshot = yield* harness.runtime.readThreadSnapshot({
1403 providerThread: harness.providerThread,
1404 });
1405
1406 assert.deepEqual(
1407 snapshot.messages.map((message) => message.text),
1408 ["keep this prompt", "keep this answer"],
1409 );
1410 const providerPayload = snapshot.providerPayload as ReadonlyArray<{
1411 readonly info: { readonly id: string };
1412 }>;
1413 assert.deepEqual(
1414 providerPayload.map((entry) => entry.info.id),
1415 ["user-kept", "assistant-kept"],
1416 );
1417 assert.equal(snapshot.providerThread.nativeConversationHeadRef?.nativeId, "user-kept");
1418 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1419 );
1420
1421 it.effect("keeps a newly admitted prompt alive across stale idle and delayed busy evidence", () =>
1422 Effect.gen(function* () {
1423 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1424 const nativeEvents = asyncEventStream();
1425 const prompt = promiseGate<void>();
1426 const promptStarted = promiseGate<void>();
1427 const steerPrompt = promiseGate<void>();
1428 const steerPromptStarted = promiseGate<void>();
1429 const stopPromptStarted = promiseGate<void>();
1430 const statusCalled = promiseGate<void>();
1431 const steerStatusCalled = promiseGate<void>();
1432 const abortCalled = promiseGate<void>();
1433 let status: "idle" | "busy" = "busy";
1434 let promptCalls = 0;
1435 let statusCalls = 0;
1436 const promptInputs: Array<{ readonly messageID?: string }> = [];
1437 const client = {
1438 event: {
1439 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1440 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1441 return { stream: nativeEvents.stream };
1442 },
1443 },
1444 session: {
1445 create: async () => ({
1446 data: { id: "native-opencode-race", time: { created: 1, updated: 1 } },
1447 }),
1448 get: async () => ({
1449 data: { id: "native-opencode-race", time: { created: 1, updated: 1 } },
1450 }),
1451 promptAsync: async (
1452 input: { readonly messageID?: string },
1453 options?: { readonly signal?: AbortSignal },
1454 ) => {
1455 promptInputs.push(input);
1456 promptCalls += 1;
1457 if (promptCalls === 1) {
1458 promptStarted.resolve();
1459 await prompt.promise;
1460 } else if (promptCalls === 2) {
1461 steerPromptStarted.resolve();
1462 await steerPrompt.promise;
1463 } else {
1464 stopPromptStarted.resolve();
1465 await new Promise<void>((_resolve, reject) =>
1466 options?.signal?.addEventListener("abort", () => reject(options.signal!.reason), {
1467 once: true,
1468 }),
1469 );
1470 }
1471 return { data: true };
1472 },
1473 status: async () => ({
1474 data: (() => {
1475 statusCalls += 1;
1476 if (statusCalls === 1) statusCalled.resolve();
1477 else steerStatusCalled.resolve();
1478 return { "native-opencode-race": { type: status } };
1479 })(),
1480 }),
1481 messages: async () => ({ data: [] }),
1482 children: async () => ({ data: [] }),
1483 abort: async () => {
1484 abortCalled.resolve();
1485 return { data: true };
1486 },
1487 },
1488 mcp: { add: async () => ({ data: true }) },
1489 };
1490 const adapter = makeOpenCodeAdapterV2({
1491 instanceId: ProviderInstanceId.make("opencode-test"),
1492 settings: OPEN_CODE_TEST_SETTINGS,
1493 environment: {},
1494 runtime: {
1495 connectToOpenCodeServer: () =>
1496 Effect.succeed({ url: "http://test.invalid", external: true }),
1497 createOpenCodeSdkClient: () => client,
1498 } as unknown as OpenCodeRuntimeShape,
1499 idAllocator,
1500 serverConfig: {
1501 cwd: "/workspace",
1502 attachmentsDir: "/tmp/attachments",
1503 } as ServerConfig.ServerConfig["Service"],
1504 });
1505 const threadId = ThreadId.make("thread-opencode-admission-race");
1506 const providerSessionId = ProviderSessionId.make("session-opencode-admission-race");
1507 const modelSelection = {
1508 instanceId: ProviderInstanceId.make("opencode-test"),
1509 model: "anthropic/claude-sonnet",
1510 options: [],
1511 };
1512 const policy = runtimePolicy("full-access", { cwd: "/workspace" });
1513 const runtime = yield* adapter.openSession({
1514 threadId,
1515 providerSessionId,
1516 modelSelection,
1517 runtimePolicy: policy,
1518 });
1519 const providerThread = yield* runtime.ensureThread({
1520 threadId,
1521 modelSelection,
1522 runtimePolicy: policy,
1523 });
1524 const now = yield* DateTime.now;
1525 const attemptId = RunAttemptId.make("attempt-opencode-admission-race");
1526 const runId = RunId.make("run-opencode-admission-race");
1527 const start = yield* runtime
1528 .startTurn({
1529 appThread: {
1530 id: threadId,
1531 projectId: ProjectId.make("project-opencode-admission-race"),
1532 title: "race",
1533 providerInstanceId: modelSelection.instanceId,
1534 modelSelection,
1535 runtimeMode: "full-access",
1536 interactionMode: "default",
1537 branch: null,
1538 worktreePath: null,
1539 activeProviderThreadId: providerThread.id,
1540 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
1541 forkedFrom: null,
1542 createdBy: "user",
1543 creationSource: "web",
1544 createdAt: now,
1545 updatedAt: now,
1546 archivedAt: null,
1547 settledOverride: null,
1548 settledAt: null,
1549 lastVisitedAt: null,
1550 deletedAt: null,
1551 },
1552 threadId,
1553 runId,
1554 runOrdinal: 1,
1555 providerTurnOrdinal: 1,
1556 attemptId,
1557 rootNodeId: NodeId.make("node-opencode-admission-race"),
1558 providerThread,
1559 message: {
1560 createdBy: "user",
1561 creationSource: "web",
1562 messageId: MessageId.make("message-opencode-admission-race"),
1563 text: "hello",
1564 attachments: [],
1565 },
1566 modelSelection,
1567 runtimePolicy: policy,
1568 })
1569 .pipe(Effect.forkScoped);
1570
1571 yield* Effect.promise(() => promptStarted.promise);
1572 yield* Effect.promise(() =>
1573 nativeEvents.push({
1574 type: "session.status",
1575 properties: { sessionID: "native-opencode-race", status: { type: "idle" } },
1576 }),
1577 );
1578 prompt.resolve();
1579 yield* Fiber.join(start);
1580 assert.match(promptInputs[0]?.messageID ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{28}$/);
1581 yield* Effect.promise(() =>
1582 nativeEvents.push({
1583 type: "session.status",
1584 properties: { sessionID: "native-opencode-race", status: { type: "busy" } },
1585 }),
1586 );
1587 status = "busy";
1588 yield* Effect.promise(() =>
1589 nativeEvents.push({
1590 type: "message.updated",
1591 properties: {
1592 sessionID: "native-opencode-race",
1593 info: {
1594 id: "stale-user-message",
1595 sessionID: "native-opencode-race",
1596 role: "user",
1597 time: { created: DateTime.toEpochMillis(now) },
1598 },
1599 },
1600 }),
1601 );
1602 assert.equal(statusCalls, 0, "an unrelated user message must not release prompt admission");
1603 yield* Effect.promise(() =>
1604 nativeEvents.push({
1605 type: "session.status",
1606 properties: { sessionID: "native-opencode-race", status: { type: "idle" } },
1607 }),
1608 );
1609 yield* Effect.promise(() =>
1610 nativeEvents.push({
1611 type: "message.updated",
1612 properties: {
1613 sessionID: "native-opencode-race",
1614 info: {
1615 id: promptInputs[0]!.messageID!,
1616 sessionID: "native-opencode-race",
1617 role: "user",
1618 time: { created: DateTime.toEpochMillis(now) },
1619 },
1620 },
1621 }),
1622 );
1623
1624 yield* Effect.promise(() => statusCalled.promise);
1625
1626 const snapshot = yield* runtime.readThreadSnapshot({ providerThread });
1627 assert.equal(snapshot.providerTurns.at(-1)?.status, "running");
1628 const activeTurn = snapshot.providerTurns.at(-1)!;
1629 const steer = yield* runtime
1630 .steerTurn({
1631 threadId,
1632 runId,
1633 providerThread,
1634 providerTurnId: activeTurn.id,
1635 message: {
1636 messageId: MessageId.make("message-opencode-steer-race"),
1637 text: "follow up",
1638 attachments: [],
1639 createdBy: "user",
1640 creationSource: "web",
1641 },
1642 })
1643 .pipe(Effect.forkScoped);
1644 yield* Effect.promise(() => steerPromptStarted.promise);
1645 assert.notEqual(promptInputs[1]?.messageID, promptInputs[0]?.messageID);
1646 steerPrompt.resolve();
1647 yield* Fiber.join(steer);
1648 yield* Effect.promise(() =>
1649 nativeEvents.push({
1650 type: "session.status",
1651 properties: { sessionID: "native-opencode-race", status: { type: "busy" } },
1652 }),
1653 );
1654 yield* Effect.promise(() =>
1655 nativeEvents.push({
1656 type: "message.updated",
1657 properties: {
1658 sessionID: "native-opencode-race",
1659 info: {
1660 id: promptInputs[0]!.messageID!,
1661 sessionID: "native-opencode-race",
1662 role: "user",
1663 time: { created: DateTime.toEpochMillis(now) },
1664 },
1665 },
1666 }),
1667 );
1668 yield* Effect.promise(() =>
1669 nativeEvents.push({
1670 type: "session.status",
1671 properties: { sessionID: "native-opencode-race", status: { type: "idle" } },
1672 }),
1673 );
1674 yield* Effect.promise(() =>
1675 nativeEvents.push({
1676 type: "message.updated",
1677 properties: {
1678 sessionID: "native-opencode-race",
1679 info: {
1680 id: promptInputs[1]!.messageID!,
1681 sessionID: "native-opencode-race",
1682 role: "user",
1683 time: { created: DateTime.toEpochMillis(now) },
1684 },
1685 },
1686 }),
1687 );
1688 yield* Effect.promise(() => steerStatusCalled.promise);
1689 const pendingSteer = yield* runtime
1690 .steerTurn({
1691 threadId,
1692 runId,
1693 providerThread,
1694 providerTurnId: activeTurn.id,
1695 message: {
1696 messageId: MessageId.make("message-opencode-stop-pending"),
1697 text: "pending when stopped",
1698 attachments: [],
1699 createdBy: "user",
1700 creationSource: "web",
1701 },
1702 })
1703 .pipe(Effect.exit, Effect.forkScoped);
1704 yield* Effect.promise(() => stopPromptStarted.promise);
1705 const interrupt = yield* runtime
1706 .interruptTurn({ providerThread, providerTurnId: activeTurn.id })
1707 .pipe(Effect.forkScoped);
1708 yield* Effect.promise(() => abortCalled.promise);
1709 yield* Fiber.join(interrupt);
1710 assert.isTrue(Exit.isFailure(yield* Fiber.join(pendingSteer)));
1711 const promptWhileStopping = yield* Effect.exit(
1712 runtime.steerTurn({
1713 threadId,
1714 runId,
1715 providerThread,
1716 providerTurnId: activeTurn.id,
1717 message: {
1718 messageId: MessageId.make("message-opencode-after-stop"),
1719 text: "must not pass the pending stop",
1720 attachments: [],
1721 createdBy: "user",
1722 creationSource: "web",
1723 },
1724 }),
1725 );
1726 assert.isTrue(Exit.isFailure(promptWhileStopping));
1727 assert.equal(promptCalls, 3);
1728 yield* Effect.promise(() =>
1729 nativeEvents.push({
1730 type: "session.status",
1731 properties: { sessionID: "native-opencode-race", status: { type: "idle" } },
1732 }),
1733 );
1734 const interrupted = yield* runtime.readThreadSnapshot({ providerThread });
1735 assert.equal(interrupted.providerTurns.at(-1)?.status, "interrupted");
1736 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1737 );
1738
1739 it.effect("interrupts an initial prompt while its SDK request is pending", () =>
1740 Effect.gen(function* () {
1741 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1742 const nativeEvents = asyncEventStream();
1743 const promptStarted = promiseGate<void>();
1744 const abortCalled = promiseGate<void>();
1745 let abortCalls = 0;
1746 const client = {
1747 event: {
1748 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1749 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1750 return { stream: nativeEvents.stream };
1751 },
1752 },
1753 session: {
1754 create: async () => ({
1755 data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } },
1756 }),
1757 get: async () => ({
1758 data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } },
1759 }),
1760 promptAsync: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => {
1761 promptStarted.resolve();
1762 await new Promise<void>((_resolve, reject) => {
1763 const signal = options?.signal;
1764 if (signal?.aborted) {
1765 reject(signal.reason);
1766 return;
1767 }
1768 signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
1769 });
1770 return { data: true };
1771 },
1772 status: async () => ({
1773 data: { "native-opencode-initial-stop": { type: "idle" as const } },
1774 }),
1775 messages: async () => ({ data: [] }),
1776 children: async () => ({ data: [] }),
1777 abort: async () => {
1778 abortCalls += 1;
1779 abortCalled.resolve();
1780 return { data: true };
1781 },
1782 },
1783 mcp: { add: async () => ({ data: true }) },
1784 };
1785 const adapter = makeOpenCodeAdapterV2({
1786 instanceId: ProviderInstanceId.make("opencode-initial-stop-test"),
1787 settings: OPEN_CODE_TEST_SETTINGS,
1788 environment: {},
1789 runtime: {
1790 connectToOpenCodeServer: () =>
1791 Effect.succeed({ url: "http://test.invalid", external: true }),
1792 createOpenCodeSdkClient: () => client,
1793 } as unknown as OpenCodeRuntimeShape,
1794 idAllocator,
1795 serverConfig: {
1796 cwd: "/workspace",
1797 attachmentsDir: "/tmp/attachments",
1798 } as ServerConfig.ServerConfig["Service"],
1799 });
1800 const threadId = ThreadId.make("thread-opencode-initial-stop");
1801 const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop");
1802 const modelSelection = {
1803 instanceId: ProviderInstanceId.make("opencode-initial-stop-test"),
1804 model: "anthropic/claude-sonnet",
1805 options: [],
1806 };
1807 const policy = runtimePolicy("full-access", { cwd: "/workspace" });
1808 const runtime = yield* adapter.openSession({
1809 threadId,
1810 providerSessionId,
1811 modelSelection,
1812 runtimePolicy: policy,
1813 });
1814 const providerThread = yield* runtime.ensureThread({
1815 threadId,
1816 modelSelection,
1817 runtimePolicy: policy,
1818 });
1819 const terminalEvents = yield* runtime.events.pipe(
1820 Stream.takeUntil((event) => event.type === "turn.terminal"),
1821 Stream.runCollect,
1822 Effect.forkScoped,
1823 );
1824 const now = yield* DateTime.now;
1825 const start = yield* runtime
1826 .startTurn({
1827 appThread: {
1828 id: threadId,
1829 projectId: ProjectId.make("project-opencode-initial-stop"),
1830 title: "initial stop",
1831 providerInstanceId: modelSelection.instanceId,
1832 modelSelection,
1833 runtimeMode: "full-access",
1834 interactionMode: "default",
1835 branch: null,
1836 worktreePath: null,
1837 activeProviderThreadId: providerThread.id,
1838 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
1839 forkedFrom: null,
1840 createdBy: "user",
1841 creationSource: "web",
1842 createdAt: now,
1843 updatedAt: now,
1844 archivedAt: null,
1845 settledOverride: null,
1846 settledAt: null,
1847 lastVisitedAt: null,
1848 deletedAt: null,
1849 },
1850 threadId,
1851 runId: RunId.make("run-opencode-initial-stop"),
1852 runOrdinal: 1,
1853 providerTurnOrdinal: 1,
1854 attemptId: RunAttemptId.make("attempt-opencode-initial-stop"),
1855 rootNodeId: NodeId.make("node-opencode-initial-stop"),
1856 providerThread,
1857 message: {
1858 createdBy: "user",
1859 creationSource: "web",
1860 messageId: MessageId.make("message-opencode-initial-stop"),
1861 text: "hello",
1862 attachments: [],
1863 },
1864 modelSelection,
1865 runtimePolicy: policy,
1866 })
1867 .pipe(Effect.forkScoped);
1868
1869 yield* Effect.promise(() => promptStarted.promise);
1870 const running = yield* runtime.readThreadSnapshot({ providerThread });
1871 const activeTurn = running.providerTurns.at(-1)!;
1872 const interrupt = yield* runtime
1873 .interruptTurn({ providerThread, providerTurnId: activeTurn.id })
1874 .pipe(Effect.forkScoped);
1875
1876 yield* Effect.promise(() => abortCalled.promise);
1877 yield* Fiber.join(start);
1878 yield* Fiber.join(interrupt);
1879 yield* Effect.promise(() =>
1880 nativeEvents.push({
1881 type: "session.status",
1882 properties: {
1883 sessionID: "native-opencode-initial-stop",
1884 status: { type: "idle" },
1885 },
1886 }),
1887 );
1888
1889 const events = Array.from(yield* Fiber.join(terminalEvents));
1890 const terminals = events.filter((event) => event.type === "turn.terminal");
1891 assert.equal(abortCalls, 1);
1892 assert.lengthOf(terminals, 1);
1893 assert.equal(terminals[0]?.status, "interrupted");
1894 assert.isNull(terminals[0]?.failure ?? null);
1895 assert.isFalse(
1896 events.some(
1897 (event) =>
1898 (event.type === "turn.terminal" && event.status === "failed") ||
1899 (event.type === "provider_session.updated" && event.providerSession.status === "error"),
1900 ),
1901 );
1902 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1903 );
1904
1905 it.effect("fails an active turn when the OpenCode event stream ends cleanly", () =>
1906 Effect.gen(function* () {
1907 const nativeEvents = asyncEventStream();
1908 const harness = yield* makeOpenCodeRuntimeHarness(
1909 "clean-event-eof",
1910 "native-opencode-clean-event-eof",
1911 {
1912 event: {
1913 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1914 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1915 return { stream: nativeEvents.stream };
1916 },
1917 },
1918 session: {
1919 create: async () => ({
1920 data: { id: "native-opencode-clean-event-eof", time: { created: 1, updated: 1 } },
1921 }),
1922 promptAsync: async () => ({ data: true }),
1923 },
1924 },
1925 );
1926 yield* harness.startTurn();
1927 const terminalEvents = yield* harness.runtime.events.pipe(
1928 Stream.runCollect,
1929 Effect.forkScoped,
1930 );
1931
1932 nativeEvents.close();
1933 const received = Array.from(yield* Fiber.join(terminalEvents));
1934 assert.isTrue(
1935 received.some(
1936 (event) =>
1937 event.type === "provider_session.updated" && event.providerSession.status === "error",
1938 ),
1939 );
1940 const terminal = received.find((event) => event.type === "turn.terminal");
1941 assert.equal(terminal?.status, "failed");
1942 assert.equal(terminal?.failure?.class, "transport_error");
1943 assert.equal(terminal?.threadDisposition, "broken");
1944 assert.equal((yield* Effect.exit(harness.startTurn()))._tag, "Failure");
1945 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1946 );
1947
1948 it.effect("fails compaction when its response races stream termination", () =>
1949 Effect.gen(function* () {
1950 const nativeEvents = asyncEventStream();
1951 const summarizeStarted = promiseGate<void>();
1952 const summarizeResult = promiseGate<{ data: boolean }>();
1953 const baseClock = yield* Clock.Clock;
1954 const eofClockRead = yield* Deferred.make<void>();
1955 const releaseEofClockRead = yield* Deferred.make<void>();
1956 let blockNextClockRead = false;
1957 const blockingClock: Clock.Clock = {
1958 ...baseClock,
1959 currentTimeMillis: Effect.suspend(() => {
1960 if (!blockNextClockRead) return baseClock.currentTimeMillis;
1961 blockNextClockRead = false;
1962 return Deferred.succeed(eofClockRead, undefined).pipe(
1963 Effect.andThen(Deferred.await(releaseEofClockRead)),
1964 Effect.andThen(baseClock.currentTimeMillis),
1965 );
1966 }),
1967 };
1968 const harness = yield* makeOpenCodeRuntimeHarness(
1969 "compaction-eof-race",
1970 "native-opencode-compaction-eof-race",
1971 {
1972 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
1973 session: {
1974 create: async () => ({
1975 data: { id: "native-opencode-compaction-eof-race", time: { created: 1, updated: 1 } },
1976 }),
1977 summarize: () => {
1978 summarizeStarted.resolve();
1979 return summarizeResult.promise;
1980 },
1981 },
1982 },
1983 ).pipe(Effect.provideService(Clock.Clock, blockingClock));
1984 const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped);
1985 const start = yield* harness.startTurn("/compact").pipe(Effect.forkScoped);
1986 yield* Effect.promise(() => summarizeStarted.promise);
1987 blockNextClockRead = true;
1988 nativeEvents.close();
1989 yield* Deferred.await(eofClockRead);
1990 summarizeResult.resolve({ data: true });
1991 yield* Fiber.join(start);
1992 yield* Deferred.succeed(releaseEofClockRead, undefined);
1993 const received = Array.from(yield* Fiber.join(events));
1994 const terminals = received.filter((event) => event.type === "turn.terminal");
1995 assert.lengthOf(terminals, 1);
1996 assert.equal(terminals[0]?.status, "failed");
1997 assert.equal(terminals[0]?.failure?.class, "transport_error");
1998 assert.isFalse(
1999 received.some(
2000 (event) =>
2001 event.type === "turn_item.updated" &&
2002 event.turnItem.type === "compaction" &&
2003 event.turnItem.status === "completed",
2004 ),
2005 );
2006 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2007 );
2008
2009 it.effect("does not register a turn after the OpenCode event stream ends", () =>
2010 Effect.gen(function* () {
2011 const nativeEvents = asyncEventStream();
2012 let promptCalls = 0;
2013 const harness = yield* makeOpenCodeRuntimeHarness(
2014 "event-eof-start-race",
2015 "native-opencode-event-eof-start-race",
2016 {
2017 event: {
2018 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
2019 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
2020 return { stream: nativeEvents.stream };
2021 },
2022 },
2023 session: {
2024 create: async () => ({
2025 data: {
2026 id: "native-opencode-event-eof-start-race",
2027 time: { created: 1, updated: 1 },
2028 },
2029 }),
2030 promptAsync: async () => {
2031 promptCalls += 1;
2032 return { data: true };
2033 },
2034 },
2035 },
2036 );
2037 const baseClock = yield* Clock.Clock;
2038 const startClockRead = yield* Deferred.make<void>();
2039 const releaseStartClockRead = yield* Deferred.make<void>();
2040 let blockNextClockRead = true;
2041 const blockingClock: Clock.Clock = {
2042 ...baseClock,
2043 currentTimeMillis: Effect.suspend(() => {
2044 if (!blockNextClockRead) return baseClock.currentTimeMillis;
2045 blockNextClockRead = false;
2046 return Deferred.succeed(startClockRead, undefined).pipe(
2047 Effect.andThen(Deferred.await(releaseStartClockRead)),
2048 Effect.andThen(baseClock.currentTimeMillis),
2049 );
2050 }),
2051 };
2052 const start = yield* harness
2053 .startTurn()
2054 .pipe(Effect.provideService(Clock.Clock, blockingClock), Effect.exit, Effect.forkScoped);
2055 yield* Deferred.await(startClockRead);
2056
2057 const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped);
2058 nativeEvents.close();
2059 yield* Fiber.join(events);
2060 yield* Deferred.succeed(releaseStartClockRead, undefined);
2061
2062 assert.isTrue(Exit.isFailure(yield* Fiber.join(start)));
2063 assert.equal(promptCalls, 0);
2064 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2065 );
2066
2067 it("holds stale idle through prompt admission until the new user message is observed", () => {
2068 const admission = {
2069 admissionPending: true,
2070 admissionAccepted: false,
2071 admissionMessageObserved: false,
2072 idleDuringAdmission: false,
2073 };
2074
2075 assert.equal(advanceOpenCodePromptAdmission(admission, "idle"), "hold");
2076 assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "hold");
2077 assert.isTrue(admission.admissionPending);
2078 assert.equal(advanceOpenCodePromptAdmission(admission, "user-message"), "reconcile-idle");
2079 assert.isTrue(admission.admissionPending);
2080 });
2081
2082 it("releases admission only after prompt acceptance and new-turn evidence", () => {
2083 const admission = {
2084 admissionPending: true,
2085 admissionAccepted: false,
2086 admissionMessageObserved: false,
2087 idleDuringAdmission: false,
2088 };
2089
2090 assert.equal(advanceOpenCodePromptAdmission(admission, "busy"), "hold");
2091 assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release");
2092 assert.isFalse(admission.admissionPending);
2093 });
2094
2095 it("releases admission when an assistant completes under a provider-assigned message id", () => {
2096 const admission = {
2097 admissionPending: true,
2098 admissionAccepted: false,
2099 admissionMessageObserved: false,
2100 idleDuringAdmission: true,
2101 };
2102
2103 assert.equal(advanceOpenCodePromptAdmission(admission, "assistant-completed"), "release");
2104 assert.isTrue(admission.admissionAccepted);
2105 assert.isTrue(admission.admissionMessageObserved);
2106 assert.isFalse(admission.admissionPending);
2107 });
2108
2109 it("invalidates pending admission before aborting a turn", () => {
2110 const admission = {
2111 admissionGeneration: 4,
2112 admissionPending: true,
2113 admissionAccepted: false,
2114 admissionMessageObserved: false,
2115 idleDuringAdmission: false,
2116 };
2117
2118 cancelOpenCodePromptAdmission(admission, 5);
2119
2120 assert.equal(admission.admissionGeneration, 5);
2121 assert.isFalse(admission.admissionPending);
2122 assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release");
2123 });
2124
2125 it.effect("reconciles current idle and busy status replies after prompt admission", () =>
2126 Effect.gen(function* () {
2127 for (const expected of ["idle", "busy"] as const) {
2128 const admission = { admissionGeneration: 4, admissionPending: true };
2129 const status = yield* Deferred.make<"idle" | "busy" | "unknown">();
2130 const fiber = yield* reconcileOpenCodePromptAdmissionStatus(
2131 admission,
2132 4,
2133 Deferred.await(status),
2134 ).pipe(Effect.forkChild({ startImmediately: true }));
2135
2136 yield* Deferred.succeed(status, expected);
2137
2138 assert.equal(yield* Fiber.join(fiber), expected);
2139 assert.isFalse(admission.admissionPending);
2140 }
2141 }),
2142 );
2143
2144 it.effect("keeps admission pending after a transient status failure", () =>
2145 Effect.gen(function* () {
2146 const admission = { admissionGeneration: 4, admissionPending: true };
2147 assert.equal(
2148 yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("unknown")),
2149 "unknown",
2150 );
2151 assert.isTrue(admission.admissionPending);
2152 assert.equal(
2153 yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("idle")),
2154 "idle",
2155 );
2156 assert.isFalse(admission.admissionPending);
2157 }),
2158 );
2159
2160 it.effect("retries a transient status failure without another idle event", () =>
2161 Effect.gen(function* () {
2162 const nativeSessionId = "native-opencode-status-retry";
2163 const nativeEvents = asyncEventStream();
2164 const promptRelease = promiseGate<void>();
2165 const promptCalls = yield* Queue.unbounded<string>();
2166 const statusCalls = yield* Queue.unbounded<number>();
2167 let statusCallCount = 0;
2168 const client = {
2169 event: {
2170 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
2171 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
2172 return { stream: nativeEvents.stream };
2173 },
2174 },
2175 session: {
2176 create: async () => ({
2177 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
2178 }),
2179 get: async () => ({
2180 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
2181 }),
2182 promptAsync: async (input: { readonly messageID?: string }) => {
2183 Queue.offerUnsafe(promptCalls, input.messageID!);
2184 await promptRelease.promise;
2185 return { data: true };
2186 },
2187 status: async () => {
2188 statusCallCount += 1;
2189 Queue.offerUnsafe(statusCalls, statusCallCount);
2190 if (statusCallCount === 1) throw new Error("transient status failure");
2191 return { data: { [nativeSessionId]: { type: "idle" as const } } };
2192 },
2193 messages: async () => ({ data: [] }),
2194 children: async () => ({ data: [] }),
2195 abort: async () => ({ data: true }),
2196 },
2197 mcp: { add: async () => ({ data: true }) },
2198 };
2199 const harness = yield* makeOpenCodeRuntimeHarness("status-retry", nativeSessionId, client);
2200 const terminalEvents = yield* harness.runtime.events.pipe(
2201 Stream.takeUntil((event) => event.type === "turn.terminal"),
2202 Stream.runCollect,
2203 Effect.forkScoped,
2204 );
2205 const start = yield* harness.startTurn().pipe(Effect.forkScoped);
2206 const admissionMessageId = yield* Queue.take(promptCalls);
2207
2208 yield* Effect.promise(() =>
2209 nativeEvents.push({
2210 type: "session.status",
2211 properties: { sessionID: nativeSessionId, status: { type: "idle" } },
2212 }),
2213 );
2214 promptRelease.resolve();
2215 yield* Fiber.join(start);
2216 const userMessage = {
2217 type: "message.updated",
2218 properties: {
2219 sessionID: nativeSessionId,
2220 info: {
2221 id: admissionMessageId,
2222 sessionID: nativeSessionId,
2223 role: "user",
2224 time: { created: DateTime.toEpochMillis(harness.now) },
2225 },
2226 },
2227 };
2228 yield* Effect.promise(() => nativeEvents.push(userMessage));
2229 assert.equal(yield* Queue.take(statusCalls), 1);
2230
2231 yield* Effect.promise(() => nativeEvents.push(userMessage));
2232 assert.equal(statusCallCount, 1, "duplicate events must share the generation's retry worker");
2233 yield* TestClock.adjust("250 millis");
2234 assert.equal(yield* Queue.take(statusCalls), 2);
2235
2236 const events = Array.from(yield* Fiber.join(terminalEvents));
2237 const terminals = events.filter((event) => event.type === "turn.terminal");
2238 assert.equal(statusCallCount, 2);
2239 assert.lengthOf(terminals, 1);
2240 assert.equal(terminals[0]?.status, "completed");
2241 assert.isNull(terminals[0]?.failure ?? null);
2242 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2243 );
2244
2245 it.effect("does not let a queued status retry adopt a newer steer generation", () =>
2246 Effect.gen(function* () {
2247 const nativeSessionId = "native-opencode-stale-status-retry";
2248 const nativeEvents = asyncEventStream();
2249 const firstPromptRelease = promiseGate<void>();
2250 const promptCalls = yield* Queue.unbounded<string>();
2251 const statusCalls = yield* Queue.unbounded<number>();
2252 let promptCallCount = 0;
2253 let statusCallCount = 0;
2254 const client = {
2255 event: {
2256 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
2257 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
2258 return { stream: nativeEvents.stream };
2259 },
2260 },
2261 session: {
2262 create: async () => ({
2263 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
2264 }),
2265 get: async () => ({
2266 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
2267 }),
2268 promptAsync: async (input: { readonly messageID?: string }) => {
2269 promptCallCount += 1;
2270 Queue.offerUnsafe(promptCalls, input.messageID!);
2271 if (promptCallCount === 1) {
2272 await firstPromptRelease.promise;
2273 }
2274 return { data: true };
2275 },
2276 status: async () => {
2277 statusCallCount += 1;
2278 Queue.offerUnsafe(statusCalls, statusCallCount);
2279 if (statusCallCount === 1) throw new Error("transient status failure");
2280 return { data: { [nativeSessionId]: { type: "idle" as const } } };
2281 },
2282 messages: async () => ({ data: [] }),
2283 children: async () => ({ data: [] }),
2284 abort: async () => ({ data: true }),
2285 },
2286 mcp: { add: async () => ({ data: true }) },
2287 };
2288 const harness = yield* makeOpenCodeRuntimeHarness(
2289 "stale-status-retry",
2290 nativeSessionId,
2291 client,
2292 );
2293 const start = yield* harness.startTurn().pipe(Effect.forkScoped);
2294 const firstAdmissionMessageId = yield* Queue.take(promptCalls);
2295
2296 yield* Effect.promise(() =>
2297 nativeEvents.push({
2298 type: "session.status",
2299 properties: { sessionID: nativeSessionId, status: { type: "idle" } },
2300 }),
2301 );
2302 firstPromptRelease.resolve();
2303 yield* Fiber.join(start);
2304 yield* Effect.promise(() =>
2305 nativeEvents.push({
2306 type: "message.updated",
2307 properties: {
2308 sessionID: nativeSessionId,
2309 info: {
2310 id: firstAdmissionMessageId,
2311 sessionID: nativeSessionId,
2312 role: "user",
2313 time: { created: DateTime.toEpochMillis(harness.now) },
2314 },
2315 },
2316 }),
2317 );
2318 assert.equal(yield* Queue.take(statusCalls), 1);
2319
2320 const running = yield* harness.runtime.readThreadSnapshot({
2321 providerThread: harness.providerThread,
2322 });
2323 const activeTurn = running.providerTurns.at(-1)!;
2324 yield* harness.runtime.steerTurn({
2325 threadId: harness.threadId,
2326 runId: harness.runId,
2327 providerThread: harness.providerThread,
2328 providerTurnId: activeTurn.id,
2329 message: {
2330 messageId: MessageId.make("message-opencode-stale-status-retry-steer"),
2331 text: "new generation",
2332 attachments: [],
2333 createdBy: "user",
2334 creationSource: "web",
2335 },
2336 });
2337 yield* Queue.take(promptCalls);
2338 yield* TestClock.adjust("250 millis");
2339
2340 const afterRetry = yield* harness.runtime.readThreadSnapshot({
2341 providerThread: harness.providerThread,
2342 });
2343 assert.equal(statusCallCount, 1);
2344 assert.equal(afterRetry.providerTurns.at(-1)?.status, "running");
2345 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2346 );
2347
2348 it.effect("ignores a delayed status reply after steering starts a newer admission", () =>
2349 Effect.gen(function* () {
2350 const admission = { admissionGeneration: 4, admissionPending: true };
2351 const status = yield* Deferred.make<"idle" | "busy" | "unknown">();
2352 const fiber = yield* reconcileOpenCodePromptAdmissionStatus(
2353 admission,
2354 4,
2355 Deferred.await(status),
2356 ).pipe(Effect.forkChild({ startImmediately: true }));
2357
2358 admission.admissionGeneration = 5;
2359 admission.admissionPending = true;
2360 yield* Deferred.succeed(status, "idle");
2361
2362 assert.equal(yield* Fiber.join(fiber), "stale");
2363 assert.equal(admission.admissionGeneration, 5);
2364 assert.isTrue(admission.admissionPending);
2365 }),
2366 );
2367
2368 it.effect("does not revive admission when abort wins a pending status lookup", () =>
2369 Effect.gen(function* () {
2370 const admission = { admissionGeneration: 4, admissionPending: true };
2371 const status = yield* Deferred.make<"idle" | "busy" | "unknown">();
2372 const fiber = yield* reconcileOpenCodePromptAdmissionStatus(
2373 admission,
2374 4,
2375 Deferred.await(status),
2376 ).pipe(Effect.forkChild({ startImmediately: true }));
2377
2378 cancelOpenCodePromptAdmission(admission, 5);
2379 yield* Deferred.succeed(status, "idle");
2380
2381 assert.equal(yield* Fiber.join(fiber), "stale");
2382 assert.isFalse(admission.admissionPending);
2383 }),
2384 );
2385
2386 it.effect("logs bounded structural protocol diagnostics without native payload values", () =>
2387 Effect.gen(function* () {
2388 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2389 const records: Array<unknown> = [];
2390 const nativeEventLogger: EventNdjsonLogger = {
2391 filePath: "/tmp/provider-native.ndjson",
2392 write: (event) => Effect.sync(() => void records.push(event)),
2393 close: () => Effect.void,
2394 };
2395 const logProtocolEvent = makeOpenCodeProtocolLogger({
2396 nativeEventLogger,
2397 idAllocator,
2398 providerInstanceId: ProviderInstanceId.make("opencode-test"),
2399 providerSessionId: ProviderSessionId.make("provider-session-opencode-test"),
2400 threadId: ThreadId.make("thread-opencode-test"),
2401 });
2402 const secret = "secret-opencode-prompt";
2403
2404 yield* logProtocolEvent({
2405 direction: "outgoing",
2406 messageKind: "request",
2407 method: "session.prompt",
2408 payload: { prompt: secret, nested: { token: secret } },
2409 });
2410
2411 const serialized = encodeUnknownJson(records);
2412 assert.notInclude(serialized, secret);
2413 assert.include(serialized, '"protocol":"opencode-sdk.sse"');
2414 assert.include(serialized, '"method":"session.prompt"');
2415 assert.include(serialized, '"fieldCount":2');
2416 }).pipe(Effect.provide(IdAllocator.layer)),
2417 );
2418
2419 it.effect("adopts the handed-over provider thread identity on session create", () =>
2420 Effect.gen(function* () {
2421 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2422 const serverConfig = yield* ServerConfig.ServerConfig;
2423 let createCount = 0;
2424 const createInputs: Array<unknown> = [];
2425 const fakeClient = {
2426 event: {
2427 subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({
2428 // Emits nothing and ends when the pump's abort signal fires.
2429 stream: {
2430 [Symbol.asyncIterator]: () => ({
2431 next: () =>
2432 new Promise<IteratorResult<never>>((resolve) => {
2433 const done = () => resolve({ done: true, value: undefined });
2434 if (options?.signal?.aborted) return done();
2435 options?.signal?.addEventListener("abort", done, { once: true });
2436 }),
2437 }),
2438 },
2439 }),
2440 },
2441 session: {
2442 create: async (input: unknown) => {
2443 createCount += 1;
2444 createInputs.push(input);
2445 return { data: { id: `ses_native_${createCount}`, time: { created: 1, updated: 1 } } };
2446 },
2447 },
2448 } as unknown as OpencodeClient;
2449 const unused = (operation: string) => () => Effect.die(`${operation} is not used`);
2450 const runtime: OpenCodeRuntimeShape = {
2451 startOpenCodeServerProcess: unused("startOpenCodeServerProcess"),
2452 connectToOpenCodeServer: () =>
2453 Effect.succeed({
2454 url: "test://opencode",
2455 version: "test",
2456 exitCode: null,
2457 external: true,
2458 }),
2459 runOpenCodeCommand: unused("runOpenCodeCommand"),
2460 createOpenCodeSdkClient: () => fakeClient,
2461 loadOpenCodeInventory: unused("loadOpenCodeInventory"),
2462 loadInventoryFromCli: unused("loadInventoryFromCli"),
2463 loadOpenCodeSkills: unused("loadOpenCodeSkills"),
2464 loadSkillsFromCli: unused("loadSkillsFromCli"),
2465 };
2466 const instanceId = ProviderInstanceId.make("opencode");
2467 const threadId = ThreadId.make("thread-opencode-adopt");
2468 const modelSelection = { instanceId, model: "default" };
2469 const adapter = makeOpenCodeAdapterV2({
2470 instanceId,
2471 settings: OPENCODE_TEST_SETTINGS,
2472 environment: {},
2473 runtime,
2474 idAllocator,
2475 serverConfig,
2476 });
2477 const session = yield* adapter.openSession({
2478 threadId,
2479 providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"),
2480 modelSelection,
2481 runtimePolicy: runtimePolicy("full-access"),
2482 });
2483 const now = yield* DateTime.now;
2484 // The placeholder row the orchestrator creates for a first run: no
2485 // native identity yet. The adapter must bind the created session to
2486 // this row instead of minting a second session-keyed row.
2487 const placeholder: OrchestrationV2ProviderThread = {
2488 id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"),
2489 driver: OPENCODE_PROVIDER,
2490 providerInstanceId: instanceId,
2491 providerSessionId: null,
2492 appThreadId: threadId,
2493 ownerNodeId: null,
2494 nativeThreadRef: null,
2495 nativeConversationHeadRef: null,
2496 status: "not_loaded",
2497 firstRunOrdinal: 1,
2498 lastRunOrdinal: 1,
2499 handoffIds: [],
2500 forkedFrom: null,
2501 createdAt: now,
2502 updatedAt: now,
2503 };
2504 const adopted = yield* session.ensureThread({
2505 threadId,
2506 modelSelection,
2507 runtimePolicy: runtimePolicy("full-access"),
2508 existingProviderThread: placeholder,
2509 });
2510 assert.equal(adopted.id, placeholder.id);
2511 assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1");
2512 // Without a handed-over row the adapter still derives its own id.
2513 const minted = yield* session.ensureThread({
2514 threadId,
2515 modelSelection,
2516 runtimePolicy: runtimePolicy("full-access"),
2517 });
2518 assert.notEqual(minted.id, placeholder.id);
2519 assert.equal(minted.nativeThreadRef?.nativeId, "ses_native_2");
2520 // OpenCode names a session from its first prompt only when create
2521 // leaves the title unset, so the adapter never sends one.
2522 for (const input of createInputs) {
2523 assert.notProperty(input, "title");
2524 }
2525 }).pipe(
2526 Effect.scoped,
2527 Effect.provide(
2528 Layer.mergeAll(
2529 IdAllocator.layer,
2530 ServerConfig.layerTest(process.cwd(), {
2531 prefix: "t3-opencode-v2-adapter-",
2532 }).pipe(Layer.provide(NodeServices.layer)),
2533 ),
2534 ),
2535 ),
2536 );
2537
2538 it("maps native permission families to orchestration request kinds", () => {
2539 assert.equal(openCodePermissionRequestKind("bash"), "command");
2540 assert.equal(openCodePermissionRequestKind("read"), "file-read");
2541 assert.equal(openCodePermissionRequestKind("grep"), "file-read");
2542 assert.equal(openCodePermissionRequestKind("external_directory"), "file-read");
2543 assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change");
2544 assert.equal(openCodePermissionRequestKind("edit"), "file-change");
2545 assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change");
2546 assert.equal(openCodePermissionRequestKind("todowrite"), "command");
2547 assert.equal(openCodePermissionRequestKind("custom", "todowrite"), "command");
2548 });
2549
2550 it("maps OpenCode tools to semantic turn-item families", () => {
2551 assert.equal(openCodeToolProjectionKind("bash"), "command_execution");
2552 assert.equal(openCodeToolProjectionKind("edit"), "file_change");
2553 assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool");
2554 assert.equal(openCodeToolProjectionKind("lsp"), "file_search");
2555 assert.equal(openCodeToolProjectionKind("websearch"), "web_search");
2556 assert.equal(openCodeToolProjectionKind("codesearch"), "web_search");
2557 assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool");
2558 assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool");
2559 });
2560
2561 it("maps runtime modes to safe OpenCode permission rules", () => {
2562 const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required"));
2563 assert.equal(permissionAction(approvalRequired, "read"), "allow");
2564 assert.equal(permissionAction(approvalRequired, "edit"), "ask");
2565 assert.equal(permissionAction(approvalRequired, "bash"), "ask");
2566 assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask");
2567 assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask");
2568 assert.equal(permissionAction(approvalRequired, "question"), "allow");
2569
2570 const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits"));
2571 assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow");
2572 assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask");
2573
2574 const fullAccess = openCodePermissionRules(runtimePolicy("full-access"));
2575 assert.equal(permissionAction(fullAccess, "bash"), "allow");
2576 assert.equal(permissionAction(fullAccess, "edit"), "allow");
2577
2578 const granularApproval = openCodePermissionRules(
2579 runtimePolicy("full-access", {
2580 approvalPolicy: { granular: { request_permissions: true } },
2581 }),
2582 );
2583 assert.equal(permissionAction(granularApproval, "bash"), "ask");
2584 assert.equal(permissionAction(granularApproval, "read"), "allow");
2585
2586 const approvalRequiredWorkspaceWrite = openCodePermissionRules(
2587 runtimePolicy("approval-required", {
2588 sandboxPolicy: {
2589 type: "workspaceWrite",
2590 writableRoots: ["/tmp/opencode-workspace"],
2591 networkAccess: false,
2592 },
2593 }),
2594 );
2595 assert.equal(permissionAction(approvalRequiredWorkspaceWrite, "edit"), "ask");
2596 });
2597
2598 it("enforces non-interactive sandbox policy through OpenCode permissions", () => {
2599 const readOnly = openCodePermissionRules(
2600 runtimePolicy("full-access", {
2601 approvalPolicy: "never",
2602 sandboxPolicy: {
2603 type: "readOnly",
2604 access: { type: "fullAccess" },
2605 networkAccess: false,
2606 },
2607 }),
2608 );
2609 assert.equal(permissionAction(readOnly, "read"), "allow");
2610 assert.equal(permissionAction(readOnly, "edit"), "deny");
2611 assert.equal(permissionAction(readOnly, "bash"), "deny");
2612 assert.equal(permissionAction(readOnly, "webfetch"), "deny");
2613 assert.equal(permissionAction(readOnly, "doom_loop"), "deny");
2614 assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny");
2615 assert.equal(permissionAction(readOnly, "external_directory"), "allow");
2616
2617 const workspaceWrite = openCodePermissionRules(
2618 runtimePolicy("auto-accept-edits", {
2619 approvalPolicy: "never",
2620 sandboxPolicy: {
2621 type: "workspaceWrite",
2622 writableRoots: ["/tmp/opencode-workspace"],
2623 networkAccess: true,
2624 },
2625 }),
2626 );
2627 assert.equal(permissionAction(workspaceWrite, "edit"), "allow");
2628 assert.equal(permissionAction(workspaceWrite, "bash"), "deny");
2629 assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow");
2630 assert.deepInclude(workspaceWrite, {
2631 permission: "external_directory",
2632 pattern: "/tmp/opencode-workspace/*",
2633 action: "allow",
2634 });
2635 });
2636
2637 it("preserves OpenCode's recursion guard on task-created child sessions", () => {
2638 const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [
2639 { permission: "task", pattern: "*", action: "deny" },
2640 ]);
2641
2642 assert.equal(permissionAction(childRules, "read"), "allow");
2643 assert.equal(permissionAction(childRules, "bash"), "allow");
2644 assert.equal(permissionAction(childRules, "task"), "deny");
2645
2646 const approvalRequiredPolicy = runtimePolicy("approval-required");
2647 const parentRules = openCodePermissionRules(approvalRequiredPolicy);
2648 const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [
2649 ...parentRules.filter((rule) => rule.action === "deny"),
2650 { permission: "task", pattern: "*", action: "deny" },
2651 ]);
2652 assert.equal(permissionAction(childApprovalRules, "bash"), "ask");
2653 assert.equal(permissionAction(childApprovalRules, "task"), "deny");
2654 });
2655
2656 it("uses the next native user message as the exclusive fork and revert boundary", () => {
2657 const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" });
2658 const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null });
2659 const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" });
2660
2661 assert.equal(
2662 openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id),
2663 "msg-user-3",
2664 );
2665 assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id));
2666 });
2667});
2668
2669it.effect.each([false, true])(
2670 "OpenCode rewind forks history and validates the retained boundary, invalid=%s",
2671 (invalid) =>
2672 Effect.gen(function* () {
2673 const events = asyncEventStream();
2674 const nativeSessionId = "rewind-source";
2675 const forkId = "rewind-fork";
2676 const calls: string[] = [];
2677 const removed = {
2678 info: { id: "first-user", sessionID: nativeSessionId, role: "user", time: { created: 1 } },
2679 parts: [],
2680 };
2681 const harness = yield* makeOpenCodeRuntimeHarness("rewind-fork", nativeSessionId, {
2682 event: {
2683 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
2684 options.signal?.addEventListener("abort", () => events.close(), { once: true });
2685 return { stream: events.stream };
2686 },
2687 },
2688 session: {
2689 create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }),
2690 get: async ({ sessionID }: { sessionID: string }) => ({
2691 data: { id: sessionID, time: { created: 1, updated: 2 } },
2692 }),
2693 messages: async ({ sessionID }: { sessionID: string }) => ({
2694 data: sessionID === nativeSessionId || invalid ? [removed] : [],
2695 }),
2696 fork: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
2697 assert.equal(sessionID, nativeSessionId);
2698 assert.equal(messageID, "first-user");
2699 calls.push("fork");
2700 return { data: { id: forkId, time: { created: 1, updated: 2 } } };
2701 },
2702 update: async () => {
2703 calls.push("permissions");
2704 return { data: {} };
2705 },
2706 revert: async () => {
2707 throw new Error("Native revert would change workspace files");
2708 },
2709 },
2710 });
2711 const effect = harness.runtime.rollbackThread({
2712 providerThread: harness.providerThread,
2713 target: {
2714 type: "thread_start",
2715 checkpointId: CheckpointId.make("rewind-checkpoint"),
2716 appRunOrdinal: 0,
2717 },
2718 providerThreadTurns: [],
2719 });
2720 if (invalid) {
2721 const error = yield* effect.pipe(Effect.flip);
2722 assert.equal(error._tag, "ProviderAdapterRollbackThreadError");
2723 assert.deepEqual(calls, ["fork"]);
2724 } else {
2725 const result = yield* effect;
2726 assert.equal(result.providerThread.nativeThreadRef?.nativeId, forkId);
2727 assert.equal(result.messages.length, 0);
2728 assert.deepEqual(calls, ["fork", "permissions"]);
2729 }
2730 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2731);
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts+6-13
...@@ -6,22 +6,19 @@ import * as Duration from "effect/Duration";...@@ -6,22 +6,19 @@ import * as Duration from "effect/Duration";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
88
9import * as ServerConfig from "../../config.ts";9import { OpenCodeAdapterV2Driver } from "@t3tools/provider-opencode/server";
10import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts";10import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
11import { layerTestProviderHost } from "@t3tools/provider-testing/host";
11import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";12import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
12import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";13import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
13import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver";14import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver";
14import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";15import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
15import {16import { type OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
16 makeReplayServerConfig,
17 type OrchestratorV2ProviderReplayHarness,
18} from "../testkit/ProviderReplayHarness.ts";
19import {17import {
20 OPENCODE_DEFAULT_INSTANCE_ID,18 OPENCODE_DEFAULT_INSTANCE_ID,
21 OPENCODE_PROVIDER,19 OPENCODE_PROVIDER,
22 OPENCODE_SDK_PROTOCOL,20 OPENCODE_SDK_PROTOCOL,
23 OpenCodeAdapterV2Driver,21} from "@t3tools/provider-opencode/testing";
24} from "./OpenCodeAdapterV2.ts";
2522
26const OPENCODE_SDK_REPLAY_PROTOCOL = OPENCODE_SDK_PROTOCOL;23const OPENCODE_SDK_REPLAY_PROTOCOL = OPENCODE_SDK_PROTOCOL;
2724
...@@ -457,10 +454,6 @@ function layerOpenCodeReplayRuntime(transcript: OpenCodeSdkReplayTranscript) {...@@ -457,10 +454,6 @@ function layerOpenCodeReplayRuntime(transcript: OpenCodeSdkReplayTranscript) {
457}454}
458455
459function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkReplayTranscript) {456function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkReplayTranscript) {
460 const layerServerConfig = Layer.effect(
461 ServerConfig.ServerConfig,
462 makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie),
463 ).pipe(Layer.provide(NodeServices.layer));
464 return ProviderAdapterRegistry.layerFromDrivers({457 return ProviderAdapterRegistry.layerFromDrivers({
465 drivers: [OpenCodeAdapterV2Driver],458 drivers: [OpenCodeAdapterV2Driver],
466 configMap: {459 configMap: {
...@@ -473,7 +466,7 @@ function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkRepla...@@ -473,7 +466,7 @@ function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkRepla
473 Layer.provide(466 Layer.provide(
474 Layer.mergeAll(467 Layer.mergeAll(
475 layerOpenCodeReplayRuntime(transcript),468 layerOpenCodeReplayRuntime(transcript),
476 layerServerConfig,469 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
477 NodeServices.layer,470 NodeServices.layer,
478 IdAllocator.layer,471 IdAllocator.layer,
479 Layer.succeed(472 Layer.succeed(
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts deleted-3807
...@@ -1,3807 +0,0 @@
1import type {
2 Event as OpenCodeEvent,
3 Message as OpenCodeMessage,
4 Part as OpenCodePart,
5 PermissionRequest,
6 PermissionRuleset,
7 QuestionRequest,
8 Session as OpenCodeSession,
9 Todo as OpenCodeTodo,
10 ToolPart,
11} from "@opencode-ai/sdk/v2";
12import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
13import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
14import { causeErrorTag } from "@t3tools/shared/observability";
15import {
16 defaultInstanceIdForDriver,
17 type ModelSelection,
18 type OpenCodeSettings,
19 type OrchestrationV2AppThread,
20 type OrchestrationV2ConversationMessage,
21 type OrchestrationV2ExecutionNode,
22 type OrchestrationV2PlanStep,
23 type OrchestrationV2ProviderCapabilities,
24 type OrchestrationV2ProviderFailure,
25 type OrchestrationV2ProviderRef,
26 type OrchestrationV2ProviderSession,
27 type OrchestrationV2ProviderThread,
28 type OrchestrationV2ProviderTurn,
29 type OrchestrationV2RuntimeRequest,
30 type OrchestrationV2Subagent,
31 type OrchestrationV2TurnItem,
32 OpenCodeSettings as OpenCodeSettingsSchema,
33 type PlanId,
34 ProviderDriverKind,
35 type ProviderInstanceId,
36 type ProviderRequestKind,
37 type ProviderSessionId,
38 type RuntimeRequestId,
39 type ThreadId,
40} from "@t3tools/contracts";
41import * as Cause from "effect/Cause";
42import * as DateTime from "effect/DateTime";
43import * as Deferred from "effect/Deferred";
44import * as Duration from "effect/Duration";
45import * as Effect from "effect/Effect";
46import * as Exit from "effect/Exit";
47import * as Fiber from "effect/Fiber";
48import * as Layer from "effect/Layer";
49import * as Option from "effect/Option";
50import * as Queue from "effect/Queue";
51import * as Random from "effect/Random";
52import * as Schema from "effect/Schema";
53import * as Semaphore from "effect/Semaphore";
54import * as Scope from "effect/Scope";
55import * as Stream from "effect/Stream";
56
57import { resolveAttachmentPath } from "../../attachmentStore.ts";
58import * as ServerConfig from "../../config.ts";
59import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation";
60import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
61import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
62import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
63import {
64 structuralProtocolMethod,
65 summarizeNativeProtocolPayload,
66} from "../../provider/NativeProtocolLogging.ts";
67import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
68import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions";
69import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
70import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts";
71import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
72import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
73import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
74import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt";
75import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
76import {
77 ProviderAdapterDriverCreateError,
78 type ProviderAdapterDriver,
79 type ProviderAdapterDriverCreateInput,
80} from "@t3tools/provider-core/server/adapterDriver";
81import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts";
82import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts";
83
84export { openCodeToolProjectionKind } from "./OpenCodeToolItems.ts";
85
86export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode");
87export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER);
88export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const;
89const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettingsSchema)({});
90
91let openCodeMessageIdEpochMillis = -1;
92let openCodeMessageIdCounter = 0;
93
94const makeOpenCodeMessageId = Effect.fnUntraced(function* () {
95 const epochMillis = DateTime.toEpochMillis(yield* DateTime.now);
96 if (epochMillis !== openCodeMessageIdEpochMillis) {
97 openCodeMessageIdEpochMillis = epochMillis;
98 openCodeMessageIdCounter = 0;
99 }
100 openCodeMessageIdCounter += 1;
101 const encodedTime = BigInt.asUintN(
102 48,
103 BigInt(epochMillis) * 0x1000n + BigInt(openCodeMessageIdCounter),
104 )
105 .toString(16)
106 .padStart(12, "0");
107 const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
108 const random = (yield* Effect.forEach(Array.from({ length: 28 }), () =>
109 Random.nextIntBetween(0, alphabet.length - 1),
110 ))
111 .map((index) => alphabet[index])
112 .join("");
113 return `msg_${encodedTime}${random}`;
114});
115
116/**
117 * OpenCode's session, message, part, and interaction-request identifiers are
118 * durable. It does not expose a first-class turn object: the initiating user
119 * message is the best native turn correlation point, and session idle is the
120 * authoritative terminal signal.
121 */
122const OpenCodeProviderCapabilitiesV2 = {
123 sessions: {
124 // The current adapter owns one directory-bound client/server per session.
125 // Keep it isolated until its runtime is made safe for cross-thread pooling.
126 supportsMultipleProviderThreadsPerSession: false,
127 supportsModelSwitchInSession: true,
128 supportsProviderSwitchingViaHandoff: true,
129 supportsRuntimeModeSwitchInSession: false,
130 pendingRequestsSurviveRestart: false,
131 },
132 threads: {
133 canCreateEmptyThread: true,
134 canReadThreadSnapshot: true,
135 canRollbackThread: true,
136 canForkThread: true,
137 canForkFromTurn: true,
138 canForkFromSubagentThread: true,
139 exposesNativeThreadId: true,
140 },
141 turns: {
142 exposesNativeTurnId: false,
143 emitsTurnStarted: true,
144 emitsTurnCompleted: true,
145 supportsInterrupt: true,
146 supportsActiveSteering: true,
147 supportsSteeringByInterruptRestart: true,
148 supportsQueuedMessages: true,
149 terminalStatusQuality: "strong",
150 },
151 streaming: {
152 streamsAssistantText: true,
153 streamsReasoning: true,
154 streamsToolOutput: true,
155 streamsPlanText: false,
156 emitsMessageCompleted: true,
157 },
158 tools: {
159 exposesToolItemIds: true,
160 emitsToolStarted: true,
161 emitsToolCompleted: true,
162 emitsToolOutput: true,
163 supportsMcpTools: true,
164 supportsDynamicToolCallbacks: false,
165 },
166 approvals: {
167 supportsCommandApproval: true,
168 supportsFileReadApproval: true,
169 supportsFileChangeApproval: true,
170 supportsApplyPatchApproval: true,
171 approvalsHaveNativeRequestIds: true,
172 approvalCallbacksAreLiveOnly: true,
173 approvalsCanOriginateFromSubagents: true,
174 },
175 planning: {
176 emitsPlanUpdated: true,
177 emitsTodoList: true,
178 emitsProposedPlan: false,
179 supportsStructuredQuestions: true,
180 planDeltasHaveItemIds: false,
181 },
182 subagents: {
183 supportsSubagents: true,
184 exposesSubagentThreadIds: true,
185 emitsSubagentLifecycle: true,
186 canWaitForSubagents: true,
187 canCloseSubagents: false,
188 canForkSubagentThread: true,
189 },
190 context: {
191 acceptsSystemContext: false,
192 acceptsDeveloperContext: false,
193 acceptsSyntheticUserContext: true,
194 canGenerateSummaries: true,
195 canConsumeHandoffSummaries: true,
196 supportsDeltaHandoff: true,
197 supportsFullThreadHandoff: true,
198 maxRecommendedHandoffChars: null,
199 },
200 checkpointing: {
201 appCanCheckpointFilesystem: true,
202 supportsNestedCheckpointScopes: true,
203 providerCanRollbackConversation: true,
204 providerRollbackReturnsSnapshot: true,
205 providerCanReadConversationSnapshot: true,
206 },
207 identity: {
208 nativeThreadIds: "strong",
209 nativeTurnIds: "weak",
210 nativeItemIds: "strong",
211 nativeRequestIds: "strong",
212 },
213 runtimePolicy: {
214 enforcement: "native",
215 },
216} satisfies OrchestrationV2ProviderCapabilities;
217
218type TerminalTurnStatus = Extract<
219 OrchestrationV2ProviderTurn["status"],
220 "completed" | "interrupted" | "failed" | "cancelled"
221>;
222
223type OpenCodeStepUsage = Pick<
224 Extract<OpenCodePart, { readonly type: "step-finish" }>,
225 "id" | "tokens"
226>;
227
228interface OpenCodeTurnTokenUsageAccumulator {
229 readonly partIds: Set<string>;
230 readonly promptMessageIds: Set<string>;
231 readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">;
232 // Native removal does not undo usage. Keep unresolved counts until this turn settles.
233 readonly unresolvedStepsByMessageId: Map<string, Map<string, OpenCodeStepUsage>>;
234 inputTokens: number;
235 cachedInputTokens: number;
236 cacheCreationTokens: number;
237 outputTokens: number;
238 reasoningTokens: number;
239 hasSubagents: boolean;
240 complete: boolean;
241}
242
243function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator {
244 return {
245 partIds: new Set(),
246 promptMessageIds: new Set(),
247 assistantOwnershipByMessageId: new Map(),
248 unresolvedStepsByMessageId: new Map(),
249 inputTokens: 0,
250 cachedInputTokens: 0,
251 cacheCreationTokens: 0,
252 outputTokens: 0,
253 reasoningTokens: 0,
254 hasSubagents: false,
255 complete: true,
256 };
257}
258
259function accumulateOpenCodeStepUsage(
260 accumulator: OpenCodeTurnTokenUsageAccumulator,
261 part: OpenCodeStepUsage,
262): void {
263 if (accumulator.partIds.has(part.id)) return;
264 accumulator.partIds.add(part.id);
265 accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write;
266 accumulator.cachedInputTokens += part.tokens.cache.read;
267 accumulator.cacheCreationTokens += part.tokens.cache.write;
268 accumulator.outputTokens += part.tokens.output + part.tokens.reasoning;
269 accumulator.reasoningTokens += part.tokens.reasoning;
270}
271
272interface ActiveOpenCodeTurn {
273 readonly usage: OpenCodeTurnTokenUsageAccumulator;
274 readonly isRoot: boolean;
275 readonly threadId: ThreadId;
276 readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null;
277 readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"];
278 readonly appThread: OrchestrationV2AppThread;
279 readonly modelSelection: ModelSelection;
280 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
281 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
282 /** The provider thread the turn started on, which its terminal names. */
283 readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"];
284 readonly providerTurnOrdinal: number;
285 readonly runOrdinal: number;
286 readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"];
287 readonly startedAt: DateTime.Utc;
288 readonly itemOrdinals: Map<string, number>;
289 readonly parts: Map<string, Exclude<OpenCodePart, ToolPart>>;
290 readonly partIdsByMessage: Map<string, Set<string>>;
291 readonly toolNamesByCallId: Map<string, string>;
292 mcpServerNames?: ReadonlyArray<string>;
293 readonly providerTurn: OrchestrationV2ProviderTurn;
294 nextItemOrdinal: number;
295 nativeUserMessageId: string | null;
296 admissionMessageId: string | null;
297 interrupted: boolean;
298 finalized: boolean;
299 planId: PlanId | null;
300 admissionGeneration: number;
301 admissionReconciliationGeneration: number | null;
302 admissionPending: boolean;
303 admissionAccepted: boolean;
304 admissionMessageObserved: boolean;
305 idleDuringAdmission: boolean;
306 admissionSettled: Deferred.Deferred<void>;
307 admissionAbortController: AbortController | null;
308}
309
310type OpenCodeAdmissionSignal =
311 | "accepted"
312 | "assistant-completed"
313 | "busy"
314 | "idle"
315 | "user-message";
316type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release";
317
318export function advanceOpenCodePromptAdmission(
319 admission: Pick<
320 ActiveOpenCodeTurn,
321 "admissionAccepted" | "admissionMessageObserved" | "admissionPending" | "idleDuringAdmission"
322 >,
323 signal: OpenCodeAdmissionSignal,
324): OpenCodeAdmissionAction {
325 if (!admission.admissionPending) return "release";
326 if (signal === "assistant-completed") {
327 admission.admissionAccepted = true;
328 admission.admissionMessageObserved = true;
329 admission.admissionPending = false;
330 return "release";
331 }
332 if (signal === "idle") {
333 admission.idleDuringAdmission = true;
334 return "hold";
335 }
336 if (signal === "accepted") admission.admissionAccepted = true;
337 if (signal === "busy" || signal === "user-message") admission.admissionMessageObserved = true;
338 if (!admission.admissionAccepted || !admission.admissionMessageObserved) return "hold";
339 if (admission.idleDuringAdmission) return "reconcile-idle";
340 admission.admissionPending = false;
341 return "release";
342}
343
344export function cancelOpenCodePromptAdmission(
345 admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">,
346 nextGeneration: number,
347): void {
348 admission.admissionGeneration = nextGeneration;
349 admission.admissionPending = false;
350}
351
352export const reconcileOpenCodePromptAdmissionStatus = Effect.fn(
353 "reconcileOpenCodePromptAdmissionStatus",
354)(function* (
355 admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">,
356 generation: number,
357 readStatus: Effect.Effect<"busy" | "idle" | "unknown">,
358) {
359 if (admission.admissionGeneration !== generation || !admission.admissionPending) {
360 return "stale" as const;
361 }
362 const status = yield* readStatus;
363 if (admission.admissionGeneration !== generation || !admission.admissionPending) {
364 return "stale" as const;
365 }
366 if (status !== "unknown") admission.admissionPending = false;
367 return status;
368});
369
370interface OpenCodeSubagentContext {
371 readonly nativeItemId: string;
372 readonly nodeId: OrchestrationV2Subagent["id"];
373 readonly parentState: OpenCodeThreadState;
374 readonly parentTurn: ActiveOpenCodeTurn;
375 readonly prompt: string;
376 readonly title: string | null;
377 readonly startedAt: DateTime.Utc;
378 childSessionId: string | null;
379 childThreadId: ThreadId | null;
380 childProviderThreadId: OrchestrationV2ProviderThread["id"] | null;
381 model: string | null;
382 result: string | null;
383}
384
385interface OpenCodeThreadState {
386 readonly nativeSessionId: string;
387 providerThread: OrchestrationV2ProviderThread;
388 appThread: OrchestrationV2AppThread | null;
389 activeTurn: ActiveOpenCodeTurn | null;
390 readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>;
391 readonly messages: Map<string, OrchestrationV2ConversationMessage>;
392 readonly runtimeRequests: Map<string, OrchestrationV2RuntimeRequest>;
393 readonly messageRoles: Map<string, "user" | "assistant">;
394 readonly userMessageIds: Array<string>;
395 parentSubagent: OpenCodeSubagentContext | null;
396 nextChildTurnOrdinal: number;
397 nextAdmissionGeneration: number;
398}
399
400interface OpenCodeRequestOwner {
401 readonly state: OpenCodeThreadState;
402 readonly turn: ActiveOpenCodeTurn;
403 /** The subagent on the owner's thread that leads to the asking session. */
404 readonly subagent: OpenCodeSubagentContext | null;
405}
406
407/**
408 * The top-level thread and active turn that own a session's requests, walking
409 * up through (possibly nested) subagents.
410 */
411function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined {
412 let owner = state;
413 let subagent: OpenCodeSubagentContext | null = null;
414 while (owner.parentSubagent !== null) {
415 subagent = owner.parentSubagent;
416 owner = subagent.parentState;
417 }
418 return owner.activeTurn === null ? undefined : { state: owner, turn: owner.activeTurn, subagent };
419}
420
421interface PendingOpenCodeRequest {
422 readonly requestId: RuntimeRequestId;
423 readonly nativeRequestId: string;
424 /** The session that asked, which may be a subagent's. */
425 readonly nativeSessionId: string;
426 readonly turn: ActiveOpenCodeTurn;
427 readonly state: OpenCodeThreadState;
428 readonly nodeId: OrchestrationV2ExecutionNode["id"];
429 readonly parentNodeId: OrchestrationV2ExecutionNode["id"];
430 readonly turnItemId: OrchestrationV2TurnItem["id"];
431 readonly requestKind: OpenCodePermissionRequestKind | "user_input";
432 readonly createdAt: DateTime.Utc;
433 readonly permission?: PermissionRequest;
434 readonly question?: QuestionRequest;
435}
436
437export interface OpenCodeAdapterV2Options {
438 readonly instanceId: ProviderInstanceId;
439 readonly settings: OpenCodeSettings;
440 readonly environment: NodeJS.ProcessEnv;
441 readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape;
442 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
443 readonly serverConfig: ServerConfig.ServerConfig["Service"];
444 readonly nativeEventLogger?: EventNdjsonLogger;
445}
446
447export interface OpenCodeProtocolLogEvent {
448 readonly direction: "incoming" | "outgoing";
449 readonly messageKind: "request" | "response" | "notification" | "error";
450 readonly method: string;
451 readonly payload: unknown;
452}
453
454function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) {
455 return {
456 direction: event.direction,
457 messageKind: event.messageKind,
458 method: structuralProtocolMethod(event.method),
459 payload: summarizeNativeProtocolPayload(event.payload),
460 };
461}
462
463export function makeOpenCodeProtocolLogger(input: {
464 readonly nativeEventLogger: EventNdjsonLogger | undefined;
465 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
466 readonly providerInstanceId: ProviderInstanceId;
467 readonly providerSessionId: ProviderSessionId;
468 readonly threadId: ThreadId;
469}): (event: OpenCodeProtocolLogEvent) => Effect.Effect<void, never> {
470 return (event) =>
471 Effect.gen(function* () {
472 if (!input.nativeEventLogger) return;
473 const observedAt = DateTime.formatIso(yield* DateTime.now);
474 const method = structuralProtocolMethod(event.method);
475 yield* input.nativeEventLogger.write(
476 {
477 observedAt,
478 event: {
479 id: yield* input.idAllocator.allocate.rawEvent({
480 providerSessionId: input.providerSessionId,
481 method,
482 }),
483 kind: "protocol",
484 protocol: OPENCODE_SDK_PROTOCOL,
485 provider: OPENCODE_PROVIDER,
486 providerInstanceId: input.providerInstanceId,
487 providerSessionId: input.providerSessionId,
488 createdAt: observedAt,
489 threadId: input.threadId,
490 payload: formatOpenCodeProtocolLogPayload(event),
491 },
492 },
493 input.threadId,
494 );
495 }).pipe(
496 Effect.catchCause((cause) =>
497 Cause.hasInterrupts(cause)
498 ? Effect.interrupt
499 : Effect.logWarning("Failed to write native OpenCode event log.", {
500 errorTag: causeErrorTag(cause),
501 reasonCount: cause.reasons.length,
502 provider: OPENCODE_PROVIDER,
503 threadId: input.threadId,
504 }),
505 ),
506 );
507}
508
509function protocolError(
510 detail: string,
511 payload?: unknown,
512): ProviderAdapter.ProviderAdapterProtocolError {
513 return new ProviderAdapter.ProviderAdapterProtocolError({
514 driver: OPENCODE_PROVIDER,
515 detail,
516 ...(payload === undefined ? {} : { payload }),
517 });
518}
519
520function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string {
521 const nativeId = providerThread.nativeThreadRef?.nativeId;
522 if (nativeId === null || nativeId === undefined) {
523 throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`);
524 }
525 return nativeId;
526}
527
528function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc {
529 if (value === undefined) return fallback;
530 return Option.getOrElse(DateTime.make(value), () => fallback);
531}
532
533function nonEmptyString(value: unknown): string | undefined {
534 return typeof value === "string" && value.trim().length > 0 ? value : undefined;
535}
536
537function recordValue(input: unknown, key: string): unknown {
538 return typeof input === "object" && input !== null && key in input
539 ? (input as Record<string, unknown>)[key]
540 : undefined;
541}
542
543function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined {
544 for (const key of keys) {
545 const value = nonEmptyString(recordValue(input, key));
546 if (value !== undefined) return value;
547 }
548 return undefined;
549}
550
551function sdkResponseForRawLog(value: unknown): unknown {
552 if (typeof value !== "object" || value === null) return value;
553 if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null };
554 if ("stream" in value) return { subscribed: true };
555 return value;
556}
557
558type OpenCodePermissionRequestKind = Extract<
559 ProviderRequestKind,
560 "command" | "file-read" | "file-change"
561>;
562
563export function openCodePermissionRequestKind(
564 permission: string,
565 toolName?: string,
566): OpenCodePermissionRequestKind {
567 const normalized = permission.toLowerCase();
568 const normalizedTool = toolName?.toLowerCase() ?? "";
569 if (
570 normalized === "edit" ||
571 normalized === "write" ||
572 normalized === "patch" ||
573 normalized === "apply_patch" ||
574 normalizedTool === "edit" ||
575 normalizedTool === "write" ||
576 normalizedTool === "patch" ||
577 normalizedTool === "apply_patch"
578 ) {
579 return "file-change";
580 }
581 if (
582 normalized === "read" ||
583 normalized === "glob" ||
584 normalized === "grep" ||
585 normalized === "lsp" ||
586 normalized === "external_directory" ||
587 normalizedTool === "read" ||
588 normalizedTool.includes("glob") ||
589 normalizedTool.includes("grep") ||
590 normalizedTool.includes("search")
591 ) {
592 return "file-read";
593 }
594 return "command";
595}
596
597const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [
598 "question",
599 "read",
600 "glob",
601 "grep",
602 "lsp",
603 "todowrite",
604 "task",
605 "skill",
606] as const;
607
608const OPENCODE_RESTRICTED_PERMISSIONS = [
609 "bash",
610 "edit",
611 "webfetch",
612 "websearch",
613 "codesearch",
614 "external_directory",
615 "doom_loop",
616] as const;
617
618/**
619 * OpenCode does not provide an OS sandbox, so permission rules are also the
620 * enforcement boundary for non-interactive policies. Read/planning tools are
621 * safe by default; edits are auto-approved only for workspace-write modes,
622 * while shell/network/external access remains gated unless policy explicitly
623 * allows it.
624 */
625export function openCodePermissionRules(
626 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
627): PermissionRuleset {
628 const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy");
629 const sandboxType = recordString(sandboxPolicy, "type");
630 const rawApprovalPolicy = runtimePolicy.approvalPolicy;
631 const approvalPolicy = nonEmptyString(rawApprovalPolicy);
632 const requiresApproval =
633 approvalPolicy === undefined
634 ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) ||
635 runtimePolicy.runtimeMode !== "full-access"
636 : approvalPolicy !== "never";
637 const externallySandboxed = sandboxType === "externalSandbox";
638 const dangerFullAccess = sandboxType === "dangerFullAccess";
639 const implicitFullAccess =
640 sandboxType === undefined && runtimePolicy.runtimeMode === "full-access";
641
642 if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) {
643 return [{ permission: "*", pattern: "*", action: "allow" }];
644 }
645
646 // Task sessions initially inherit only parent deny rules. Seed explicit
647 // denies before the effective ask/allow overrides so a child is safe during
648 // the short interval before emitSubagent installs its complete policy.
649 const rules: PermissionRuleset = [
650 { permission: "*", pattern: "*", action: "deny" },
651 ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({
652 permission,
653 pattern: "*",
654 action: "deny" as const,
655 })),
656 ];
657
658 if (requiresApproval) {
659 rules.push({ permission: "*", pattern: "*", action: "ask" });
660 for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) {
661 rules.push({ permission, pattern: "*", action: "ask" });
662 }
663 }
664
665 rules.push(
666 ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({
667 permission,
668 pattern: "*",
669 action: "allow" as const,
670 })),
671 );
672
673 rules.push(
674 { permission: "read", pattern: "*.env", action: requiresApproval ? "ask" : "deny" },
675 { permission: "read", pattern: "*.env.*", action: requiresApproval ? "ask" : "deny" },
676 { permission: "read", pattern: "*.env.example", action: "allow" },
677 );
678
679 if (
680 runtimePolicy.runtimeMode === "auto-accept-edits" ||
681 (!requiresApproval && sandboxType === "workspaceWrite")
682 ) {
683 rules.push({ permission: "edit", pattern: "*", action: "allow" });
684 }
685
686 if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) {
687 for (const permission of ["webfetch", "websearch", "codesearch"] as const) {
688 rules.push({ permission, pattern: "*", action: "allow" });
689 }
690 }
691
692 if (!requiresApproval && sandboxType === "readOnly") {
693 const access = recordValue(sandboxPolicy, "access");
694 if (recordString(access, "type") === "fullAccess") {
695 rules.push({ permission: "external_directory", pattern: "*", action: "allow" });
696 }
697 }
698
699 if (!requiresApproval && sandboxType === "workspaceWrite") {
700 const writableRoots = recordValue(sandboxPolicy, "writableRoots");
701 if (Array.isArray(writableRoots)) {
702 for (const root of writableRoots) {
703 if (typeof root === "string" && root.trim().length > 0) {
704 rules.push({
705 permission: "external_directory",
706 pattern: `${root.replace(/\/$/, "")}/*`,
707 action: "allow",
708 });
709 }
710 }
711 }
712 }
713
714 return rules;
715}
716
717function permissionRuleEquals(
718 left: PermissionRuleset[number],
719 right: PermissionRuleset[number],
720): boolean {
721 return (
722 left.permission === right.permission &&
723 left.pattern === right.pattern &&
724 left.action === right.action
725 );
726}
727
728/**
729 * OpenCode task sessions inherit only the parent's deny/external-directory
730 * rules and then add agent-specific restrictions such as disabling nested
731 * tasks. Install the complete parent policy while retaining only rules that
732 * were added specifically for the selected child agent.
733 */
734export function openCodeChildPermissionRules(
735 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
736 nativeChildRules: PermissionRuleset,
737): PermissionRuleset {
738 const parentRules = openCodePermissionRules(runtimePolicy);
739 const inheritedRules = parentRules.filter(
740 (rule) => rule.permission === "external_directory" || rule.action === "deny",
741 );
742 const childSpecificRules = nativeChildRules.filter(
743 (childRule) =>
744 !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)),
745 );
746 return [...parentRules, ...childSpecificRules];
747}
748
749/**
750 * OpenCode's fork/revert boundary is exclusive. To retain the selected app
751 * turn, address the next native user message; omitting a boundary retains the
752 * current head when the selected turn is already last.
753 */
754export function openCodeBoundaryAfterProviderTurn(
755 providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>,
756 selectedProviderTurnId: OrchestrationV2ProviderTurn["id"],
757): string | undefined {
758 const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId);
759 if (selected === undefined) return undefined;
760 return providerTurns
761 .filter((turn) => turn.ordinal > selected.ordinal)
762 .toSorted((left, right) => left.ordinal - right.ordinal)
763 .map((turn) => turn.nativeTurnRef?.nativeId)
764 .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined);
765}
766
767function toolStatus(part: ToolPart): {
768 readonly node: OrchestrationV2ExecutionNode["status"];
769 readonly item: OrchestrationV2TurnItem["status"];
770} {
771 switch (part.state.status) {
772 case "pending":
773 return { node: "pending", item: "pending" };
774 case "running":
775 return { node: "running", item: "running" };
776 case "completed":
777 return { node: "completed", item: "completed" };
778 case "error":
779 return { node: "failed", item: "failed" };
780 }
781}
782
783function toolInput(part: ToolPart): Record<string, unknown> {
784 return part.state.input;
785}
786
787function toolOutput(part: ToolPart): string | undefined {
788 if (part.state.status === "completed") return part.state.output;
789 if (part.state.status === "error") return part.state.error;
790 return undefined;
791}
792
793function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc {
794 return dateTimeFromEpoch(
795 part.state.status === "pending" ? undefined : part.state.time.start,
796 now,
797 );
798}
799
800function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null {
801 return part.state.status === "completed" || part.state.status === "error"
802 ? dateTimeFromEpoch(part.state.time.end, now)
803 : null;
804}
805
806function toolTitle(part: ToolPart): string | null {
807 return part.state.status === "running" || part.state.status === "completed"
808 ? (part.state.title ?? null)
809 : null;
810}
811
812function toolModel(part: ToolPart): string | null {
813 const metadata =
814 part.state.status === "running" ||
815 part.state.status === "completed" ||
816 part.state.status === "error"
817 ? part.state.metadata
818 : undefined;
819 const model = recordValue(metadata, "model");
820 const providerId = recordString(model, "providerID", "providerId");
821 const modelId = recordString(model, "modelID", "modelId", "id");
822 return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null;
823}
824
825function taskSessionId(part: ToolPart): string | null {
826 const metadata =
827 part.state.status === "running" ||
828 part.state.status === "completed" ||
829 part.state.status === "error"
830 ? part.state.metadata
831 : undefined;
832 return recordString(metadata, "sessionId", "sessionID") ?? null;
833}
834
835function makeProviderThread(input: {
836 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
837 readonly providerInstanceId: ProviderInstanceId;
838 readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
839 readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
840 readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
841 readonly nativeSession: OpenCodeSession;
842 readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"];
843 readonly now: DateTime.Utc;
844}): OrchestrationV2ProviderThread {
845 const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now);
846 return {
847 id: input.idAllocator.derive.providerThread({
848 driver: OPENCODE_PROVIDER,
849 nativeThreadId: input.nativeSession.id,
850 }),
851 driver: OPENCODE_PROVIDER,
852 providerInstanceId: input.providerInstanceId,
853 providerSessionId: input.providerSessionId,
854 appThreadId: input.appThreadId,
855 ownerNodeId: input.ownerNodeId ?? null,
856 nativeThreadRef: {
857 driver: OPENCODE_PROVIDER,
858 nativeId: input.nativeSession.id,
859 strength: "strong",
860 },
861 nativeConversationHeadRef: null,
862 status: "idle",
863 firstRunOrdinal: null,
864 lastRunOrdinal: null,
865 handoffIds: [],
866 forkedFrom: input.forkedFrom ?? null,
867 createdAt,
868 updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now),
869 };
870}
871
872function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") {
873 return {
874 driver: OPENCODE_PROVIDER,
875 nativeId,
876 strength,
877 } satisfies OrchestrationV2ProviderRef;
878}
879
880function openCodeErrorMessage(event: Extract<OpenCodeEvent, { type: "session.error" }>): string {
881 const error = event.properties.error;
882 if (error === undefined) return "OpenCode session failed without an error payload.";
883 return recordString(error.data, "message") ?? error.name;
884}
885
886function terminalStatusForError(
887 event: Extract<OpenCodeEvent, { type: "session.error" }>,
888 turn: ActiveOpenCodeTurn,
889): TerminalTurnStatus {
890 return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed";
891}
892
893function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean {
894 return event.properties.error?.name === "MessageAbortedError";
895}
896
897function isOpenCodeNotFound(cause: unknown): boolean {
898 const seen = new Set<unknown>();
899 const queue: Array<unknown> = [cause];
900 for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) {
901 const node = queue.shift();
902 if (node === null || typeof node !== "object" || seen.has(node)) {
903 continue;
904 }
905 seen.add(node);
906 const record = node as Record<string, unknown>;
907
908 const response = record.response;
909 const statuses = [
910 record.status,
911 record.statusCode,
912 response !== null && typeof response === "object"
913 ? (response as { readonly status?: unknown }).status
914 : undefined,
915 ].filter((status): status is number => typeof status === "number");
916 if (statuses.includes(404)) {
917 return true;
918 }
919 if (statuses.length > 0) {
920 continue;
921 }
922
923 const name = record.name;
924 if (typeof name === "string" && name.toLowerCase() === "notfounderror") {
925 return true;
926 }
927
928 for (const key of ["cause", "body", "error", "data"] as const) {
929 if (record[key] !== undefined) {
930 queue.push(record[key]);
931 }
932 }
933 }
934 return false;
935}
936
937function unwrapData<A>(operation: string, result: { readonly data?: A }): NonNullable<A> {
938 if (result.data === undefined) {
939 throw new OpenCodeRuntime.OpenCodeRuntimeError({
940 operation,
941 detail: `OpenCode ${operation} returned no response payload.`,
942 });
943 }
944 return result.data as NonNullable<A>;
945}
946
947export function makeOpenCodeAdapterV2(
948 options: OpenCodeAdapterV2Options,
949): ProviderAdapter.ProviderAdapterV2Shape {
950 const { idAllocator, runtime, serverConfig } = options;
951
952 return ProviderAdapter.ProviderAdapterV2.of({
953 instanceId: options.instanceId,
954 driver: OPENCODE_PROVIDER,
955 getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2),
956 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
957 openSession: Effect.fn("OpenCodeAdapterV2.openSession")(
958 function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) {
959 const scope = yield* Effect.scope;
960 const cwd = input.runtimePolicy.cwd ?? serverConfig.cwd;
961 const connection = yield* runtime.connectToOpenCodeServer({
962 binaryPath: options.settings.binaryPath,
963 directory: cwd,
964 serverUrl: options.settings.serverUrl,
965 environment: options.environment,
966 });
967 const client = runtime.createOpenCodeSdkClient({
968 baseUrl: connection.url,
969 directory: cwd,
970 ...(connection.external && options.settings.serverPassword
971 ? { serverPassword: options.settings.serverPassword }
972 : {}),
973 });
974
975 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
976 const hasT3Mcp = mcpSession !== undefined && !connection.external;
977 const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp);
978 if (hasT3Mcp) {
979 yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () =>
980 client.mcp.add({
981 name: "t3-code",
982 config: {
983 type: "remote",
984 url: mcpSession.endpoint,
985 headers: { Authorization: mcpSession.authorizationHeader },
986 oauth: false,
987 },
988 }),
989 );
990 }
991
992 const now = yield* DateTime.now;
993 let sessionEntity: OrchestrationV2ProviderSession = {
994 id: input.providerSessionId,
995 driver: OPENCODE_PROVIDER,
996 providerInstanceId: options.instanceId,
997 status: "ready",
998 cwd,
999 model: input.modelSelection.model,
1000 capabilities: OpenCodeProviderCapabilitiesV2,
1001 createdAt: now,
1002 updatedAt: now,
1003 lastError: null,
1004 };
1005 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>();
1006 let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null;
1007 const threads = new Map<string, OpenCodeThreadState>();
1008 const commandReceipts = new Map<string, Deferred.Deferred<void>>();
1009 const commandControllers = new Map<string, Set<AbortController>>();
1010 const pendingRequests = new Map<string, PendingOpenCodeRequest>();
1011 const pendingRequestsByNativeId = new Map<string, PendingOpenCodeRequest>();
1012 const subagentsByNativeItemId = new Map<string, OpenCodeSubagentContext>();
1013 const subagentsByChildSessionId = new Map<string, OpenCodeSubagentContext>();
1014 // Permission and question requests can originate from child sessions
1015 // (task subagents and their descendants). Related sessions map back
1016 // to the root thread state whose active turn owns the request; asks
1017 // arriving before the relation is known resolve it via session.get.
1018 const relatedSessionOwners = new Map<string, OpenCodeThreadState>();
1019 // Sessions of this runtime that OpenCode reports busy. A background
1020 // task child keeps running after its parent turn settles, so idle
1021 // release must not close the server under it.
1022 const busySessionIds = new Set<string>();
1023 // Requests settled before their session relation resolved: a late
1024 // routing attempt must never resurrect them. Bounded because entries
1025 // only matter for the seconds a routing retry can still be running.
1026 const settledNativeRequestIds = new Set<string>();
1027 const pendingChildRequestRoutes = new Set<string>();
1028 const rememberSettledRequest = (nativeRequestId: string) => {
1029 if (settledNativeRequestIds.size >= 2_048) settledNativeRequestIds.clear();
1030 settledNativeRequestIds.add(nativeRequestId);
1031 };
1032 const abortController = new AbortController();
1033 let closing = false;
1034 let hasConnected = false;
1035
1036 const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) =>
1037 Queue.offer(events, event).pipe(Effect.asVoid);
1038
1039 const logProtocolEvent = makeOpenCodeProtocolLogger({
1040 nativeEventLogger: options.nativeEventLogger,
1041 idAllocator,
1042 providerInstanceId: options.instanceId,
1043 providerSessionId: input.providerSessionId,
1044 threadId: input.threadId,
1045 });
1046
1047 const sdkCall = <A>(
1048 method: string,
1049 payload: unknown,
1050 call: (signal: AbortSignal) => Promise<A>,
1051 ): Effect.Effect<A, OpenCodeRuntime.OpenCodeRuntimeError> =>
1052 logProtocolEvent({
1053 direction: "outgoing",
1054 messageKind: "request",
1055 method,
1056 payload,
1057 }).pipe(
1058 Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)),
1059 Effect.tap((response) =>
1060 logProtocolEvent({
1061 direction: "incoming",
1062 messageKind: "response",
1063 method,
1064 payload: sdkResponseForRawLog(response),
1065 }),
1066 ),
1067 );
1068
1069 const abortDescendants = (rootId: string) =>
1070 Effect.gen(function* () {
1071 const visited = new Set([rootId]);
1072 const semaphore = Semaphore.makeUnsafe(8);
1073 const visit = (
1074 sessionId: string,
1075 abort: boolean,
1076 ): Effect.Effect<OpenCodeRuntime.OpenCodeRuntimeError | undefined> =>
1077 Effect.gen(function* () {
1078 const abortResult = abort
1079 ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) =>
1080 client.session.abort({ sessionID: sessionId }, { signal }),
1081 ).pipe(
1082 semaphore.withPermit,
1083 Effect.catchIf(isOpenCodeNotFound, () => Effect.void),
1084 Effect.result,
1085 )
1086 : undefined;
1087 const childrenResult = yield* sdkCall(
1088 "session.children",
1089 { sessionID: sessionId },
1090 (signal) => client.session.children({ sessionID: sessionId }, { signal }),
1091 ).pipe(
1092 semaphore.withPermit,
1093 Effect.catchIf(isOpenCodeNotFound, () => Effect.void),
1094 Effect.result,
1095 );
1096 const firstFailure =
1097 abortResult?._tag === "Failure" ? abortResult.failure : undefined;
1098 if (childrenResult._tag === "Failure")
1099 return firstFailure ?? childrenResult.failure;
1100 const fresh = (childrenResult.success?.data ?? []).filter((child) => {
1101 if (visited.has(child.id)) return false;
1102 visited.add(child.id);
1103 return true;
1104 });
1105 const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), {
1106 concurrency: 8,
1107 });
1108 return firstFailure ?? failures.find((failure) => failure !== undefined);
1109 });
1110 const failure = yield* visit(rootId, false);
1111 if (failure) return yield* Effect.fail(failure);
1112 }).pipe(Effect.timeout("15 seconds"));
1113
1114 const updateProviderSession = (
1115 status: OrchestrationV2ProviderSession["status"],
1116 lastError: string | null = sessionEntity.lastError,
1117 ) =>
1118 Effect.gen(function* () {
1119 const updatedAt = yield* DateTime.now;
1120 sessionEntity = { ...sessionEntity, status, lastError, updatedAt };
1121 yield* emitProviderEvent({
1122 type: "provider_session.updated",
1123 driver: OPENCODE_PROVIDER,
1124 providerSession: sessionEntity,
1125 });
1126 });
1127
1128 const updateProviderThread = (
1129 state: OpenCodeThreadState,
1130 patch: Partial<OrchestrationV2ProviderThread>,
1131 ) =>
1132 Effect.gen(function* () {
1133 const updatedAt = yield* DateTime.now;
1134 state.providerThread = { ...state.providerThread, ...patch, updatedAt };
1135 yield* emitProviderEvent({
1136 type: "provider_thread.updated",
1137 driver: OPENCODE_PROVIDER,
1138 providerThread: state.providerThread,
1139 });
1140 });
1141
1142 const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => {
1143 const existing = turn.itemOrdinals.get(nativeItemId);
1144 if (existing !== undefined) return existing;
1145 const ordinal = turn.nextItemOrdinal++;
1146 turn.itemOrdinals.set(nativeItemId, ordinal);
1147 return ordinal;
1148 };
1149
1150 const emitProviderTurn = (
1151 state: OpenCodeThreadState,
1152 turn: ActiveOpenCodeTurn,
1153 status: OrchestrationV2ProviderTurn["status"],
1154 completedAt: DateTime.Utc | null,
1155 ) => {
1156 const providerTurn: OrchestrationV2ProviderTurn = {
1157 ...turn.providerTurn,
1158 nativeTurnRef:
1159 turn.nativeUserMessageId === null
1160 ? turn.providerTurn.nativeTurnRef
1161 : providerRef(turn.nativeUserMessageId, "weak"),
1162 status,
1163 completedAt,
1164 ...(completedAt === null
1165 ? {}
1166 : {
1167 turnTokenUsage:
1168 turn.usage.partIds.size === 0
1169 ? {
1170 usageScope: "main_agent" as const,
1171 usageStatus: "unavailable" as const,
1172 hasSubagents: turn.usage.hasSubagents,
1173 }
1174 : {
1175 usageScope: "main_agent" as const,
1176 usageStatus:
1177 status === "completed" &&
1178 turn.usage.complete &&
1179 turn.usage.unresolvedStepsByMessageId.size === 0
1180 ? ("complete" as const)
1181 : ("partial" as const),
1182 inputTokens: turn.usage.inputTokens,
1183 cachedInputTokens: turn.usage.cachedInputTokens,
1184 cacheCreationTokens: turn.usage.cacheCreationTokens,
1185 outputTokens: turn.usage.outputTokens,
1186 reasoningTokens: turn.usage.reasoningTokens,
1187 hasSubagents: turn.usage.hasSubagents,
1188 },
1189 }),
1190 };
1191 Object.assign(turn.providerTurn, providerTurn);
1192 state.providerTurns.set(String(providerTurn.id), providerTurn);
1193 return emitProviderEvent({
1194 type: "provider_turn.updated",
1195 driver: OPENCODE_PROVIDER,
1196 threadId: turn.threadId,
1197 providerTurn,
1198 });
1199 };
1200
1201 const emitTextPart = Effect.fnUntraced(function* (
1202 state: OpenCodeThreadState,
1203 turn: ActiveOpenCodeTurn,
1204 part: Extract<OpenCodePart, { type: "text" | "reasoning" }>,
1205 forceCompleted = false,
1206 ) {
1207 if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return;
1208 if (part.text.length === 0) return;
1209 const emittedAt = yield* DateTime.now;
1210 const isCompleted = forceCompleted || part.time?.end !== undefined;
1211 const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt);
1212 const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null;
1213 const nativeItemRef = providerRef(part.id);
1214 const nodeId = idAllocator.derive.nodeFromProviderItem({
1215 driver: OPENCODE_PROVIDER,
1216 nativeItemId: part.id,
1217 });
1218 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1219 driver: OPENCODE_PROVIDER,
1220 nativeItemId: part.id,
1221 });
1222 const ordinal = itemOrdinal(turn, part.id);
1223 yield* emitProviderEvent({
1224 type: "node.updated",
1225 driver: OPENCODE_PROVIDER,
1226 node: {
1227 id: nodeId,
1228 threadId: turn.threadId,
1229 runId: turn.runId,
1230 parentNodeId: turn.rootNodeId,
1231 rootNodeId: turn.rootNodeId,
1232 kind: part.type === "text" ? "assistant_message" : "reasoning",
1233 status: isCompleted ? "completed" : "running",
1234 countsForRun: false,
1235 providerThreadId: state.providerThread.id,
1236 providerTurnId: turn.providerTurnId,
1237 nativeItemRef,
1238 runtimeRequestId: null,
1239 checkpointScopeId: null,
1240 startedAt,
1241 completedAt,
1242 },
1243 });
1244 if (part.type === "text") {
1245 const messageId = idAllocator.derive.messageFromProviderItem({
1246 driver: OPENCODE_PROVIDER,
1247 nativeItemId: part.id,
1248 });
1249 const message: OrchestrationV2ConversationMessage = {
1250 createdBy: "agent",
1251 creationSource: "provider",
1252 id: messageId,
1253 threadId: turn.threadId,
1254 runId: turn.runId,
1255 nodeId,
1256 role: "assistant",
1257 text: part.text,
1258 attachments: [],
1259 streaming: !isCompleted,
1260 createdAt: startedAt,
1261 updatedAt: emittedAt,
1262 };
1263 state.messages.set(String(message.id), message);
1264 yield* emitProviderEvent({
1265 type: "message.updated",
1266 driver: OPENCODE_PROVIDER,
1267 message,
1268 });
1269 yield* emitProviderEvent({
1270 type: "turn_item.updated",
1271 driver: OPENCODE_PROVIDER,
1272 turnItem: {
1273 id: turnItemId,
1274 threadId: turn.threadId,
1275 runId: turn.runId,
1276 nodeId,
1277 providerThreadId: state.providerThread.id,
1278 providerTurnId: turn.providerTurnId,
1279 nativeItemRef,
1280 parentItemId: null,
1281 ordinal,
1282 status: isCompleted ? "completed" : "running",
1283 title: null,
1284 startedAt,
1285 completedAt,
1286 updatedAt: emittedAt,
1287 type: "assistant_message",
1288 messageId,
1289 text: part.text,
1290 streaming: !isCompleted,
1291 },
1292 });
1293 return;
1294 }
1295 yield* emitProviderEvent({
1296 type: "turn_item.updated",
1297 driver: OPENCODE_PROVIDER,
1298 turnItem: {
1299 id: turnItemId,
1300 threadId: turn.threadId,
1301 runId: turn.runId,
1302 nodeId,
1303 providerThreadId: state.providerThread.id,
1304 providerTurnId: turn.providerTurnId,
1305 nativeItemRef,
1306 parentItemId: null,
1307 ordinal,
1308 status: isCompleted ? "completed" : "running",
1309 title: null,
1310 startedAt,
1311 completedAt,
1312 updatedAt: emittedAt,
1313 type: "reasoning",
1314 text: part.text,
1315 streaming: !isCompleted,
1316 },
1317 });
1318 });
1319
1320 const emitSubagent = Effect.fnUntraced(function* (
1321 state: OpenCodeThreadState,
1322 turn: ActiveOpenCodeTurn,
1323 part: ToolPart,
1324 ) {
1325 const now = yield* DateTime.now;
1326 const nativeItemRef = providerRef(part.id);
1327 const nodeId = idAllocator.derive.nodeFromProviderItem({
1328 driver: OPENCODE_PROVIDER,
1329 nativeItemId: part.id,
1330 });
1331 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1332 driver: OPENCODE_PROVIDER,
1333 nativeItemId: part.id,
1334 });
1335 turn.usage.hasSubagents = true;
1336 const input = toolInput(part);
1337 const prompt = recordString(input, "prompt") ?? "";
1338 const title = toolTitle(part) ?? recordString(input, "description") ?? null;
1339 let context = subagentsByNativeItemId.get(part.id);
1340 if (context === undefined) {
1341 context = {
1342 nativeItemId: part.id,
1343 nodeId,
1344 parentState: state,
1345 parentTurn: turn,
1346 prompt,
1347 title,
1348 startedAt: toolStartedAt(part, now),
1349 childSessionId: null,
1350 childThreadId: null,
1351 childProviderThreadId: null,
1352 model: null,
1353 result: null,
1354 };
1355 subagentsByNativeItemId.set(part.id, context);
1356 }
1357 context.model = toolModel(part) ?? context.model;
1358 const childSessionId = taskSessionId(part);
1359 if (childSessionId !== null && context.childSessionId === null) {
1360 context.childSessionId = childSessionId;
1361 context.childThreadId = idAllocator.derive.threadFromProviderThread({
1362 driver: OPENCODE_PROVIDER,
1363 nativeThreadId: childSessionId,
1364 });
1365 context.childProviderThreadId = idAllocator.derive.providerThread({
1366 driver: OPENCODE_PROVIDER,
1367 nativeThreadId: childSessionId,
1368 });
1369 subagentsByChildSessionId.set(childSessionId, context);
1370 relatedSessionOwners.set(childSessionId, state);
1371 const childModelSelection: ModelSelection = {
1372 instanceId: options.instanceId,
1373 model: context.model ?? turn.modelSelection.model,
1374 };
1375 const childThread = makeSubagentChildThread({
1376 parentThread: turn.appThread,
1377 childThreadId: context.childThreadId,
1378 parentNodeId: nodeId,
1379 activeProviderThreadId: context.childProviderThreadId,
1380 providerInstanceId: options.instanceId,
1381 modelSelection: childModelSelection,
1382 title: subagentThreadTitle({
1383 parentTitle: turn.appThread.title,
1384 title,
1385 prompt,
1386 ordinal: itemOrdinal(turn, part.id),
1387 }),
1388 now,
1389 createdBy: "agent",
1390 creationSource: "provider",
1391 });
1392 const childProviderThread: OrchestrationV2ProviderThread = {
1393 id: context.childProviderThreadId,
1394 driver: OPENCODE_PROVIDER,
1395 providerInstanceId: options.instanceId,
1396 providerSessionId: inputProviderSessionId,
1397 appThreadId: context.childThreadId,
1398 ownerNodeId: nodeId,
1399 nativeThreadRef: providerRef(childSessionId),
1400 nativeConversationHeadRef: null,
1401 status: "active",
1402 firstRunOrdinal: null,
1403 lastRunOrdinal: null,
1404 handoffIds: [],
1405 forkedFrom: null,
1406 createdAt: now,
1407 updatedAt: now,
1408 };
1409 const childSessionResponse = yield* sdkCall(
1410 "session.get",
1411 { sessionID: childSessionId },
1412 () => client.session.get({ sessionID: childSessionId }),
1413 );
1414 const nativeChildSession = unwrapData("session.get", childSessionResponse);
1415 const childPermission = openCodeChildPermissionRules(
1416 turn.runtimePolicy,
1417 nativeChildSession.permission ?? [],
1418 );
1419 yield* sdkCall(
1420 "session.update",
1421 { sessionID: childSessionId, permission: childPermission },
1422 () =>
1423 client.session.update({
1424 sessionID: childSessionId,
1425 permission: childPermission,
1426 }),
1427 );
1428 threads.set(childSessionId, {
1429 nativeSessionId: childSessionId,
1430 providerThread: childProviderThread,
1431 appThread: childThread,
1432 activeTurn: null,
1433 providerTurns: new Map(),
1434 messages: new Map(),
1435 runtimeRequests: new Map(),
1436 messageRoles: new Map(),
1437 userMessageIds: [],
1438 parentSubagent: context,
1439 nextChildTurnOrdinal: 1,
1440 nextAdmissionGeneration: 1,
1441 });
1442 yield* emitProviderEvent({
1443 type: "app_thread.created",
1444 driver: OPENCODE_PROVIDER,
1445 appThread: childThread,
1446 });
1447 yield* emitProviderEvent({
1448 type: "provider_thread.updated",
1449 driver: OPENCODE_PROVIDER,
1450 providerThread: childProviderThread,
1451 });
1452 }
1453 const output = toolOutput(part);
1454 if (part.state.status === "completed" && output !== undefined) context.result = output;
1455 const status = toolStatus(part);
1456 const completedAt = toolCompletedAt(part, now);
1457 const subagentStatus: OrchestrationV2Subagent["status"] =
1458 status.item === "failed"
1459 ? "failed"
1460 : status.item === "completed"
1461 ? "completed"
1462 : status.item === "pending"
1463 ? "pending"
1464 : "running";
1465 const subagent: OrchestrationV2Subagent = {
1466 id: nodeId,
1467 threadId: turn.threadId,
1468 runId: turn.runId,
1469 parentNodeId: turn.rootNodeId,
1470 origin: "provider_native",
1471 createdBy: "agent",
1472 driver: OPENCODE_PROVIDER,
1473 providerInstanceId: options.instanceId,
1474 providerThreadId: context.childProviderThreadId,
1475 childThreadId: context.childThreadId,
1476 nativeTaskRef: nativeItemRef,
1477 prompt,
1478 title,
1479 model: context.model,
1480 status: subagentStatus,
1481 result: context.result,
1482 startedAt: context.startedAt,
1483 completedAt,
1484 updatedAt: now,
1485 };
1486 yield* emitProviderEvent({
1487 type: "node.updated",
1488 driver: OPENCODE_PROVIDER,
1489 node: {
1490 id: nodeId,
1491 threadId: turn.threadId,
1492 runId: turn.runId,
1493 parentNodeId: turn.rootNodeId,
1494 rootNodeId: turn.rootNodeId,
1495 kind: "subagent",
1496 status: status.node,
1497 countsForRun: false,
1498 providerThreadId: context.childProviderThreadId ?? state.providerThread.id,
1499 providerTurnId: turn.providerTurnId,
1500 nativeItemRef,
1501 runtimeRequestId: null,
1502 checkpointScopeId: null,
1503 startedAt: context.startedAt,
1504 completedAt,
1505 },
1506 });
1507 yield* emitProviderEvent({
1508 type: "subagent.updated",
1509 driver: OPENCODE_PROVIDER,
1510 subagent,
1511 });
1512 yield* emitProviderEvent({
1513 type: "turn_item.updated",
1514 driver: OPENCODE_PROVIDER,
1515 turnItem: {
1516 id: turnItemId,
1517 threadId: turn.threadId,
1518 runId: turn.runId,
1519 nodeId,
1520 providerThreadId: state.providerThread.id,
1521 providerTurnId: turn.providerTurnId,
1522 nativeItemRef,
1523 parentItemId: null,
1524 ordinal: itemOrdinal(turn, part.id),
1525 status: status.item,
1526 title,
1527 startedAt: context.startedAt,
1528 completedAt,
1529 updatedAt: now,
1530 type: "subagent",
1531 subagentId: nodeId,
1532 origin: "provider_native",
1533 driver: OPENCODE_PROVIDER,
1534 providerInstanceId: options.instanceId,
1535 childThreadId: context.childThreadId,
1536 prompt,
1537 result: context.result,
1538 },
1539 });
1540 });
1541
1542 const emitToolPart = Effect.fnUntraced(function* (
1543 state: OpenCodeThreadState,
1544 turn: ActiveOpenCodeTurn,
1545 part: ToolPart,
1546 ) {
1547 const normalizedTool = part.tool.toLowerCase();
1548 if (normalizedTool === "task") {
1549 yield* emitSubagent(state, turn, part);
1550 return;
1551 }
1552 // question.asked carries the respondable semantic item. Projecting
1553 // the implementation tool as well would duplicate it in the UI.
1554 if (normalizedTool === "question") return;
1555 const now = yield* DateTime.now;
1556 const status = toolStatus(part);
1557 const startedAt = toolStartedAt(part, now);
1558 const completedAt = toolCompletedAt(part, now);
1559 const nativeItemRef = providerRef(part.id);
1560 const nodeId = idAllocator.derive.nodeFromProviderItem({
1561 driver: OPENCODE_PROVIDER,
1562 nativeItemId: part.id,
1563 });
1564 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1565 driver: OPENCODE_PROVIDER,
1566 nativeItemId: part.id,
1567 });
1568 const base = {
1569 id: turnItemId,
1570 threadId: turn.threadId,
1571 runId: turn.runId,
1572 nodeId,
1573 providerThreadId: state.providerThread.id,
1574 providerTurnId: turn.providerTurnId,
1575 nativeItemRef,
1576 parentItemId: null,
1577 ordinal: itemOrdinal(turn, part.id),
1578 status: status.item,
1579 title: toolTitle(part),
1580 startedAt,
1581 completedAt,
1582 updatedAt: now,
1583 } satisfies Pick<
1584 OrchestrationV2TurnItem,
1585 | "id"
1586 | "threadId"
1587 | "runId"
1588 | "nodeId"
1589 | "providerThreadId"
1590 | "providerTurnId"
1591 | "nativeItemRef"
1592 | "parentItemId"
1593 | "ordinal"
1594 | "status"
1595 | "title"
1596 | "startedAt"
1597 | "completedAt"
1598 | "updatedAt"
1599 >;
1600 const input = toolInput(part);
1601 const output = toolOutput(part);
1602 const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch";
1603 if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) {
1604 const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) =>
1605 client.mcp.status(undefined, { signal, throwOnError: true }),
1606 ).pipe(
1607 Effect.timeout("1 second"),
1608 Effect.map((response) => Object.keys(response.data ?? {})),
1609 Effect.catch(() => Effect.succeed(undefined)),
1610 );
1611 if (serverNames !== undefined) turn.mcpServerNames = serverNames;
1612 }
1613 const matchingServers = turn.mcpServerNames?.filter(
1614 (name) =>
1615 !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`),
1616 );
1617 const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined;
1618 const presentation =
1619 serverName === undefined
1620 ? {}
1621 : mcpToolPresentation({
1622 serverName,
1623 toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1),
1624 title: toolTitle(part) === part.tool ? undefined : toolTitle(part),
1625 });
1626 const turnItem: OrchestrationV2TurnItem = matchingServers?.length
1627 ? {
1628 ...base,
1629 type: "dynamic_tool",
1630 ...presentation,
1631 toolName: part.tool,
1632 input,
1633 ...(output === undefined ? {} : { output }),
1634 }
1635 : openCodeToolTurnItem(base, {
1636 name: part.tool,
1637 input,
1638 output,
1639 completedMetadata:
1640 part.state.status === "completed" ? part.state.metadata : undefined,
1641 });
1642 yield* emitProviderEvent({
1643 type: "node.updated",
1644 driver: OPENCODE_PROVIDER,
1645 node: {
1646 id: nodeId,
1647 threadId: turn.threadId,
1648 runId: turn.runId,
1649 parentNodeId: turn.rootNodeId,
1650 rootNodeId: turn.rootNodeId,
1651 kind: "tool_call",
1652 status: status.node,
1653 countsForRun: false,
1654 providerThreadId: state.providerThread.id,
1655 providerTurnId: turn.providerTurnId,
1656 nativeItemRef,
1657 runtimeRequestId: null,
1658 checkpointScopeId: null,
1659 startedAt,
1660 completedAt,
1661 },
1662 });
1663 yield* emitProviderEvent({
1664 type: "turn_item.updated",
1665 driver: OPENCODE_PROVIDER,
1666 turnItem,
1667 });
1668 });
1669
1670 const emitTodo = Effect.fnUntraced(function* (
1671 state: OpenCodeThreadState,
1672 turn: ActiveOpenCodeTurn,
1673 todos: ReadonlyArray<OpenCodeTodo>,
1674 ) {
1675 const now = yield* DateTime.now;
1676 if (turn.planId === null) {
1677 turn.planId = yield* idAllocator.allocate.plan({
1678 threadId: turn.threadId,
1679 ...(turn.runId === null ? {} : { runId: turn.runId }),
1680 driver: OPENCODE_PROVIDER,
1681 });
1682 }
1683 const planId = turn.planId;
1684 const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`;
1685 const nodeId = idAllocator.derive.nodeFromProviderItem({
1686 driver: OPENCODE_PROVIDER,
1687 nativeItemId,
1688 });
1689 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1690 driver: OPENCODE_PROVIDER,
1691 nativeItemId,
1692 });
1693 const steps: Array<OrchestrationV2PlanStep> = todos.map((todo, index) => ({
1694 id: `${nativeItemId}:${index + 1}`,
1695 text: todo.content.trim() || `Todo ${index + 1}`,
1696 status:
1697 todo.status === "completed"
1698 ? "completed"
1699 : todo.status === "in_progress"
1700 ? "running"
1701 : "pending",
1702 }));
1703 const completed = steps.length > 0 && steps.every((step) => step.status === "completed");
1704 yield* emitProviderEvent({
1705 type: "node.updated",
1706 driver: OPENCODE_PROVIDER,
1707 node: {
1708 id: nodeId,
1709 threadId: turn.threadId,
1710 runId: turn.runId,
1711 parentNodeId: turn.rootNodeId,
1712 rootNodeId: turn.rootNodeId,
1713 kind: "todo_list",
1714 status: completed ? "completed" : "running",
1715 countsForRun: false,
1716 providerThreadId: state.providerThread.id,
1717 providerTurnId: turn.providerTurnId,
1718 nativeItemRef: providerRef(nativeItemId, "weak"),
1719 runtimeRequestId: null,
1720 checkpointScopeId: null,
1721 startedAt: turn.startedAt,
1722 completedAt: completed ? now : null,
1723 },
1724 });
1725 yield* emitProviderEvent({
1726 type: "plan.updated",
1727 driver: OPENCODE_PROVIDER,
1728 plan: {
1729 id: planId,
1730 threadId: turn.threadId,
1731 runId: turn.runId,
1732 nodeId,
1733 status: completed ? "completed" : "active",
1734 kind: "todo_list",
1735 steps,
1736 },
1737 });
1738 yield* emitProviderEvent({
1739 type: "turn_item.updated",
1740 driver: OPENCODE_PROVIDER,
1741 turnItem: {
1742 id: turnItemId,
1743 threadId: turn.threadId,
1744 runId: turn.runId,
1745 nodeId,
1746 providerThreadId: state.providerThread.id,
1747 providerTurnId: turn.providerTurnId,
1748 nativeItemRef: providerRef(nativeItemId, "weak"),
1749 parentItemId: null,
1750 ordinal: itemOrdinal(turn, nativeItemId),
1751 status: completed ? "completed" : "running",
1752 title: "Todo list",
1753 startedAt: turn.startedAt,
1754 completedAt: completed ? now : null,
1755 updatedAt: now,
1756 type: "todo_list",
1757 planId,
1758 steps,
1759 },
1760 });
1761 });
1762
1763 const requestQuestions = (request: QuestionRequest) =>
1764 request.questions.map((question, index) => ({
1765 id: OpenCodeRuntime.openCodeQuestionId(index, question),
1766 header: question.header.trim() || `Question ${index + 1}`,
1767 question: question.question.trim() || question.header.trim() || `Question ${index + 1}`,
1768 options: question.options.map((option) => ({
1769 label: option.label.trim() || "Option",
1770 description: option.description.trim() || option.label.trim() || "Option",
1771 })),
1772 }));
1773
1774 const runtimeRequestTurnItem = (
1775 pending: PendingOpenCodeRequest,
1776 status: OrchestrationV2TurnItem["status"],
1777 completedAt: DateTime.Utc | null,
1778 updatedAt: DateTime.Utc,
1779 ): OrchestrationV2TurnItem => {
1780 const base = {
1781 id: pending.turnItemId,
1782 threadId: pending.turn.threadId,
1783 runId: pending.turn.runId,
1784 nodeId: pending.nodeId,
1785 providerThreadId: pending.state.providerThread.id,
1786 providerTurnId: pending.turn.providerTurnId,
1787 nativeItemRef: providerRef(pending.nativeRequestId),
1788 parentItemId: null,
1789 ordinal: itemOrdinal(pending.turn, pending.nativeRequestId),
1790 status,
1791 startedAt: pending.createdAt,
1792 completedAt,
1793 updatedAt,
1794 };
1795 if (pending.question !== undefined) {
1796 return {
1797 ...base,
1798 title: "User input",
1799 type: "user_input_request",
1800 requestId: pending.requestId,
1801 questions: requestQuestions(pending.question),
1802 };
1803 }
1804 const permission = pending.permission;
1805 if (permission === undefined) {
1806 throw protocolError(`OpenCode request ${pending.requestId} has no native payload`);
1807 }
1808 return {
1809 ...base,
1810 title: permission.permission,
1811 type: "approval_request",
1812 requestId: pending.requestId,
1813 requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind,
1814 prompt:
1815 permission.patterns.length === 0
1816 ? permission.permission
1817 : permission.patterns.join("\n"),
1818 };
1819 };
1820
1821 const emitRuntimeRequest = Effect.fnUntraced(function* (
1822 owner: OpenCodeRequestOwner,
1823 nativeRequestId: string,
1824 request:
1825 | { readonly type: "permission"; readonly value: PermissionRequest }
1826 | { readonly type: "question"; readonly value: QuestionRequest },
1827 ) {
1828 if (pendingRequestsByNativeId.has(nativeRequestId)) return;
1829 const { state, turn, subagent } = owner;
1830 const now = yield* DateTime.now;
1831 const requestId = yield* idAllocator.allocate.runtimeRequest({
1832 driver: OPENCODE_PROVIDER,
1833 providerTurnId: turn.providerTurnId,
1834 nativeRequestId,
1835 });
1836 const nodeId = idAllocator.derive.approvalNode({ requestId });
1837 const turnItemId = idAllocator.derive.approvalTurnItem({ requestId });
1838 // The tool call belongs to the asking session's turn, not the owner's.
1839 const permissionToolName =
1840 request.type === "permission" && request.value.tool !== undefined
1841 ? threads
1842 .get(request.value.sessionID)
1843 ?.activeTurn?.toolNamesByCallId.get(request.value.tool.callID)
1844 : undefined;
1845 const permissionRequestKind =
1846 request.type === "permission"
1847 ? openCodePermissionRequestKind(request.value.permission, permissionToolName)
1848 : undefined;
1849 const requestKind: OrchestrationV2RuntimeRequest["kind"] =
1850 permissionRequestKind ?? "user_input";
1851 const pending: PendingOpenCodeRequest = {
1852 requestId,
1853 nativeRequestId,
1854 nativeSessionId: request.value.sessionID,
1855 turn,
1856 state,
1857 nodeId,
1858 parentNodeId: subagent?.nodeId ?? turn.rootNodeId,
1859 turnItemId,
1860 requestKind,
1861 createdAt: now,
1862 ...(request.type === "permission"
1863 ? { permission: request.value }
1864 : { question: request.value }),
1865 };
1866 pendingRequests.set(String(requestId), pending);
1867 pendingRequestsByNativeId.set(nativeRequestId, pending);
1868 const runtimeRequest: OrchestrationV2RuntimeRequest = {
1869 id: requestId,
1870 nodeId,
1871 providerTurnId: turn.providerTurnId,
1872 nativeRequestRef: providerRef(nativeRequestId),
1873 kind: requestKind,
1874 status: "pending",
1875 responseCapability: {
1876 type: "live",
1877 providerSessionId: inputProviderSessionId,
1878 },
1879 createdAt: now,
1880 resolvedAt: null,
1881 };
1882 state.runtimeRequests.set(String(requestId), runtimeRequest);
1883 yield* emitProviderEvent({
1884 type: "node.updated",
1885 driver: OPENCODE_PROVIDER,
1886 node: {
1887 id: nodeId,
1888 threadId: turn.threadId,
1889 runId: turn.runId,
1890 parentNodeId: pending.parentNodeId,
1891 rootNodeId: turn.rootNodeId,
1892 kind: request.type === "question" ? "user_input_request" : "approval_request",
1893 status: "waiting",
1894 countsForRun: false,
1895 providerThreadId: state.providerThread.id,
1896 providerTurnId: turn.providerTurnId,
1897 nativeItemRef: providerRef(nativeRequestId),
1898 runtimeRequestId: requestId,
1899 checkpointScopeId: null,
1900 startedAt: now,
1901 completedAt: null,
1902 },
1903 });
1904 yield* emitProviderEvent({
1905 type: "runtime_request.updated",
1906 driver: OPENCODE_PROVIDER,
1907 threadId: turn.threadId,
1908 runtimeRequest,
1909 });
1910 yield* emitProviderEvent({
1911 type: "turn_item.updated",
1912 driver: OPENCODE_PROVIDER,
1913 turnItem: runtimeRequestTurnItem(pending, "waiting", null, now),
1914 });
1915 yield* updateProviderSession("waiting", null);
1916 });
1917
1918 const resolveRuntimeRequest = Effect.fnUntraced(function* (
1919 nativeRequestId: string,
1920 status: "resolved" | "cancelled",
1921 ) {
1922 const pending = pendingRequestsByNativeId.get(nativeRequestId);
1923 if (pending === undefined) return;
1924 const now = yield* DateTime.now;
1925 const current = pending.state.runtimeRequests.get(String(pending.requestId));
1926 if (current !== undefined) {
1927 const resolved: OrchestrationV2RuntimeRequest = {
1928 ...current,
1929 status,
1930 resolvedAt: now,
1931 };
1932 pending.state.runtimeRequests.set(String(pending.requestId), resolved);
1933 yield* emitProviderEvent({
1934 type: "runtime_request.updated",
1935 driver: OPENCODE_PROVIDER,
1936 threadId: pending.turn.threadId,
1937 runtimeRequest: resolved,
1938 });
1939 }
1940 yield* emitProviderEvent({
1941 type: "node.updated",
1942 driver: OPENCODE_PROVIDER,
1943 node: {
1944 id: pending.nodeId,
1945 threadId: pending.turn.threadId,
1946 runId: pending.turn.runId,
1947 parentNodeId: pending.parentNodeId,
1948 rootNodeId: pending.turn.rootNodeId,
1949 kind: pending.question === undefined ? "approval_request" : "user_input_request",
1950 status: status === "resolved" ? "completed" : "cancelled",
1951 countsForRun: false,
1952 providerThreadId: pending.state.providerThread.id,
1953 providerTurnId: pending.turn.providerTurnId,
1954 nativeItemRef: providerRef(nativeRequestId),
1955 runtimeRequestId: pending.requestId,
1956 checkpointScopeId: null,
1957 startedAt: pending.createdAt,
1958 completedAt: now,
1959 },
1960 });
1961 yield* emitProviderEvent({
1962 type: "turn_item.updated",
1963 driver: OPENCODE_PROVIDER,
1964 turnItem: runtimeRequestTurnItem(
1965 pending,
1966 status === "resolved" ? "completed" : "cancelled",
1967 now,
1968 now,
1969 ),
1970 });
1971 pendingRequests.delete(String(pending.requestId));
1972 pendingRequestsByNativeId.delete(nativeRequestId);
1973 const hasOtherPending = Array.from(pendingRequests.values()).some(
1974 (candidate) => candidate.turn.isRoot,
1975 );
1976 if (!hasOtherPending) yield* updateProviderSession("running", null);
1977 });
1978
1979 /** Resolve the thread state a session belongs to: its own, or for a
1980 * child not registered yet, the nearest known ancestor found by walking
1981 * the native parent chain. Registers every hop so later requests from
1982 * the same child resolve without another lookup. */
1983 const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) {
1984 const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId);
1985 if (known !== undefined) return known;
1986 let cursor = sessionId;
1987 const hops: string[] = [];
1988 for (let depth = 0; depth < 5; depth += 1) {
1989 const response = yield* sdkCall("session.get", { sessionID: cursor }, () =>
1990 client.session.get({ sessionID: cursor }),
1991 ).pipe(Effect.option);
1992 const info = Option.getOrUndefined(response)?.data;
1993 const parentId = info?.parentID;
1994 if (parentId === undefined) return undefined;
1995 hops.push(cursor);
1996 const owner = threads.get(parentId) ?? relatedSessionOwners.get(parentId);
1997 if (owner !== undefined) {
1998 for (const hop of hops) relatedSessionOwners.set(hop, owner);
1999 return owner;
2000 }
2001 cursor = parentId;
2002 }
2003 return undefined;
2004 });
2005
2006 /** Every request is asked on the top-level thread and its active turn,
2007 * under the subagent that leads to the asking session, because native
2008 * subagent threads are hidden from the sidebar. A child's request can
2009 * arrive before the task part or session.created event that reveals
2010 * its relation to a thread. The first resolution attempt runs inline
2011 * (the replayable path); if the relation or the owning turn is not
2012 * established yet, a short forked backoff keeps trying instead of
2013 * dropping the request. */
2014 const routeRuntimeRequest = Effect.fnUntraced(function* (
2015 nativeRequestId: string,
2016 sessionId: string,
2017 request:
2018 | { readonly type: "permission"; readonly value: PermissionRequest }
2019 | { readonly type: "question"; readonly value: QuestionRequest },
2020 ) {
2021 if (pendingChildRequestRoutes.has(nativeRequestId)) return;
2022 const attempt = Effect.gen(function* () {
2023 if (
2024 settledNativeRequestIds.has(nativeRequestId) ||
2025 pendingRequestsByNativeId.has(nativeRequestId)
2026 ) {
2027 return true;
2028 }
2029 const state = yield* resolveSessionOwner(sessionId);
2030 const owner = state === undefined ? undefined : topLevelRequestOwner(state);
2031 if (owner === undefined) return false;
2032 yield* emitRuntimeRequest(owner, nativeRequestId, request);
2033 return true;
2034 });
2035 if (yield* attempt) return;
2036 pendingChildRequestRoutes.add(nativeRequestId);
2037 yield* Effect.gen(function* () {
2038 for (let retry = 0; retry < 5; retry += 1) {
2039 yield* Effect.sleep(Duration.millis(Math.min(200 * 2 ** retry, 2_000)));
2040 if (yield* attempt) return;
2041 }
2042 }).pipe(
2043 Effect.ensuring(Effect.sync(() => pendingChildRequestRoutes.delete(nativeRequestId))),
2044 Effect.forkIn(scope),
2045 );
2046 });
2047
2048 const finalizeTurn = Effect.fnUntraced(function* (
2049 state: OpenCodeThreadState,
2050 turn: ActiveOpenCodeTurn,
2051 status: TerminalTurnStatus,
2052 terminal?: {
2053 readonly failure?: OrchestrationV2ProviderFailure;
2054 readonly threadDisposition?: "reusable" | "broken";
2055 },
2056 ) {
2057 if (turn.finalized) return;
2058 if (nativeStreamFailure !== null) {
2059 status = "failed";
2060 terminal = { failure: nativeStreamFailure, threadDisposition: "broken" };
2061 }
2062 turn.finalized = true;
2063 const completedAt = yield* DateTime.now;
2064 for (const part of turn.parts.values()) {
2065 if (part.type === "text" || part.type === "reasoning") {
2066 yield* emitTextPart(state, turn, part, true);
2067 }
2068 }
2069 for (const pending of Array.from(pendingRequests.values())) {
2070 if (
2071 pending.turn.providerTurnId === turn.providerTurnId ||
2072 pending.nativeSessionId === state.nativeSessionId
2073 ) {
2074 yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled");
2075 }
2076 }
2077 yield* emitProviderTurn(state, turn, status, completedAt);
2078 const threadDisposition = terminal?.threadDisposition ?? "reusable";
2079 yield* updateProviderThread(state, {
2080 status: turn.isRoot ? "active" : threadDisposition === "broken" ? "error" : "idle",
2081 nativeConversationHeadRef:
2082 turn.nativeUserMessageId === null
2083 ? state.providerThread.nativeConversationHeadRef
2084 : providerRef(turn.nativeUserMessageId, "weak"),
2085 });
2086 state.activeTurn = null;
2087 if (!turn.isRoot) {
2088 yield* emitProviderEvent({
2089 type: "node.updated",
2090 driver: OPENCODE_PROVIDER,
2091 node: {
2092 id: turn.rootNodeId,
2093 threadId: turn.threadId,
2094 runId: null,
2095 parentNodeId: null,
2096 rootNodeId: turn.rootNodeId,
2097 kind: "root_turn",
2098 status,
2099 countsForRun: false,
2100 providerThreadId: state.providerThread.id,
2101 providerTurnId: turn.providerTurnId,
2102 nativeItemRef: providerRef(state.nativeSessionId),
2103 runtimeRequestId: null,
2104 checkpointScopeId: null,
2105 startedAt: turn.startedAt,
2106 completedAt,
2107 },
2108 });
2109 return;
2110 }
2111 const anotherTurnIsActive = Array.from(threads.values()).some(
2112 (candidate) => candidate.activeTurn?.isRoot === true,
2113 );
2114 yield* updateProviderSession(
2115 anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready",
2116 status === "failed" ? sessionEntity.lastError : null,
2117 );
2118 yield* emitProviderEvent(
2119 status === "failed"
2120 ? {
2121 type: "turn.terminal",
2122 driver: OPENCODE_PROVIDER,
2123 providerThreadId: turn.providerThreadId,
2124 providerTurnId: turn.providerTurnId,
2125 runOrdinal: turn.runOrdinal,
2126 failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`),
2127 status,
2128 failure:
2129 terminal?.failure ??
2130 makeProviderFailure({
2131 message: sessionEntity.lastError ?? undefined,
2132 class: "provider_error",
2133 }),
2134 threadDisposition,
2135 }
2136 : {
2137 type: "turn.terminal",
2138 driver: OPENCODE_PROVIDER,
2139 providerThreadId: turn.providerThreadId,
2140 providerTurnId: turn.providerTurnId,
2141 runOrdinal: turn.runOrdinal,
2142 status,
2143 failure: null,
2144 threadDisposition,
2145 },
2146 );
2147 });
2148
2149 const promptAdmissionIsCurrent = (
2150 state: OpenCodeThreadState,
2151 turn: ActiveOpenCodeTurn,
2152 generation: number,
2153 ) =>
2154 state.activeTurn === turn &&
2155 !turn.finalized &&
2156 !turn.interrupted &&
2157 turn.admissionPending &&
2158 turn.admissionGeneration === generation;
2159
2160 const runPromptAdmissionReconciliation = Effect.fnUntraced(function* (
2161 state: OpenCodeThreadState,
2162 turn: ActiveOpenCodeTurn,
2163 generation: number,
2164 ) {
2165 while (promptAdmissionIsCurrent(state, turn, generation)) {
2166 const status = yield* reconcileOpenCodePromptAdmissionStatus(
2167 turn,
2168 generation,
2169 sdkCall("session.status", { sessionID: state.nativeSessionId, generation }, () =>
2170 client.session.status(),
2171 ).pipe(
2172 Effect.match({
2173 onFailure: () => "unknown" as const,
2174 onSuccess: (response) => {
2175 const statuses = unwrapData("session.status", response);
2176 const sessionStatus = statuses[state.nativeSessionId];
2177 return sessionStatus === undefined || sessionStatus.type === "idle"
2178 ? ("idle" as const)
2179 : ("busy" as const);
2180 },
2181 }),
2182 ),
2183 );
2184 if (
2185 state.activeTurn !== turn ||
2186 status === "stale" ||
2187 turn.finalized ||
2188 turn.interrupted ||
2189 turn.admissionGeneration !== generation
2190 ) {
2191 return;
2192 }
2193 if (status === "idle") {
2194 yield* finalizeTurn(state, turn, "completed");
2195 return;
2196 }
2197 if (status === "busy") return;
2198 yield* Effect.sleep("250 millis");
2199 }
2200 });
2201
2202 const reconcilePromptAdmission = Effect.fnUntraced(function* (
2203 state: OpenCodeThreadState,
2204 turn: ActiveOpenCodeTurn,
2205 ) {
2206 const generation = turn.admissionGeneration;
2207 if (
2208 !promptAdmissionIsCurrent(state, turn, generation) ||
2209 turn.admissionReconciliationGeneration === generation
2210 ) {
2211 return;
2212 }
2213 turn.admissionReconciliationGeneration = generation;
2214 yield* runPromptAdmissionReconciliation(state, turn, generation).pipe(
2215 Effect.ensuring(
2216 Effect.sync(() => {
2217 if (turn.admissionReconciliationGeneration === generation) {
2218 turn.admissionReconciliationGeneration = null;
2219 }
2220 }),
2221 ),
2222 Effect.forkIn(scope),
2223 );
2224 });
2225
2226 const createChildTurn = Effect.fnUntraced(function* (
2227 state: OpenCodeThreadState,
2228 message: Extract<OpenCodeMessage, { role: "user" }>,
2229 ) {
2230 if (state.appThread === null || state.parentSubagent === null) return null;
2231 const now = yield* DateTime.now;
2232 const startedAt = dateTimeFromEpoch(message.time.created, now);
2233 const rootNodeId = idAllocator.derive.nodeFromProviderItem({
2234 driver: OPENCODE_PROVIDER,
2235 nativeItemId: `${state.nativeSessionId}:root:${message.id}`,
2236 });
2237 const providerTurnId = idAllocator.derive.providerTurn({
2238 driver: OPENCODE_PROVIDER,
2239 nativeTurnId: message.id,
2240 });
2241 const providerTurn: OrchestrationV2ProviderTurn = {
2242 id: providerTurnId,
2243 providerThreadId: state.providerThread.id,
2244 nodeId: rootNodeId,
2245 runAttemptId: null,
2246 nativeTurnRef: providerRef(message.id, "weak"),
2247 ordinal: state.nextChildTurnOrdinal++,
2248 status: "running",
2249 startedAt,
2250 completedAt: null,
2251 };
2252 const turn: ActiveOpenCodeTurn = {
2253 isRoot: false,
2254 threadId: state.appThread.id,
2255 runId: null,
2256 rootNodeId,
2257 appThread: state.appThread,
2258 modelSelection: state.appThread.modelSelection,
2259 runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy,
2260 providerTurnId,
2261 providerThreadId: providerTurn.providerThreadId,
2262 providerTurnOrdinal: providerTurn.ordinal,
2263 runOrdinal: state.parentSubagent.parentTurn.runOrdinal,
2264 runAttemptId: null,
2265 startedAt,
2266 itemOrdinals: new Map(),
2267 usage: makeOpenCodeTurnTokenUsageAccumulator(),
2268 parts: new Map(),
2269 partIdsByMessage: new Map(),
2270 toolNamesByCallId: new Map(),
2271 providerTurn,
2272 nextItemOrdinal: 1,
2273 nativeUserMessageId: message.id,
2274 admissionMessageId: null,
2275 interrupted: false,
2276 finalized: false,
2277 planId: null,
2278 admissionGeneration: 0,
2279 admissionReconciliationGeneration: null,
2280 admissionPending: false,
2281 admissionAccepted: true,
2282 admissionMessageObserved: true,
2283 idleDuringAdmission: false,
2284 admissionSettled: Deferred.makeUnsafe<void>(),
2285 admissionAbortController: null,
2286 };
2287 state.activeTurn = turn;
2288 state.providerTurns.set(String(providerTurnId), providerTurn);
2289 yield* emitProviderEvent({
2290 type: "node.updated",
2291 driver: OPENCODE_PROVIDER,
2292 node: {
2293 id: rootNodeId,
2294 threadId: turn.threadId,
2295 runId: null,
2296 parentNodeId: null,
2297 rootNodeId,
2298 kind: "root_turn",
2299 status: "running",
2300 countsForRun: false,
2301 providerThreadId: state.providerThread.id,
2302 providerTurnId,
2303 nativeItemRef: providerRef(message.id, "weak"),
2304 runtimeRequestId: null,
2305 checkpointScopeId: null,
2306 startedAt,
2307 completedAt: null,
2308 },
2309 });
2310 yield* emitProviderTurn(state, turn, "running", null);
2311 return turn;
2312 });
2313
2314 const projectChildUserPart = Effect.fnUntraced(function* (
2315 state: OpenCodeThreadState,
2316 turn: ActiveOpenCodeTurn,
2317 part: Extract<OpenCodePart, { type: "text" }>,
2318 ) {
2319 const now = yield* DateTime.now;
2320 const messageId = idAllocator.derive.messageFromProviderItem({
2321 driver: OPENCODE_PROVIDER,
2322 nativeItemId: part.messageID,
2323 });
2324 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
2325 driver: OPENCODE_PROVIDER,
2326 nativeItemId: part.messageID,
2327 });
2328 const projected: OrchestrationV2ConversationMessage = {
2329 createdBy: "agent",
2330 creationSource: "provider",
2331 id: messageId,
2332 threadId: turn.threadId,
2333 runId: null,
2334 nodeId: turn.rootNodeId,
2335 role: "user",
2336 text: part.text,
2337 attachments: [],
2338 streaming: false,
2339 createdAt: turn.startedAt,
2340 updatedAt: now,
2341 };
2342 state.messages.set(String(messageId), projected);
2343 yield* emitProviderEvent({
2344 type: "message.updated",
2345 driver: OPENCODE_PROVIDER,
2346 message: projected,
2347 });
2348 yield* emitProviderEvent({
2349 type: "turn_item.updated",
2350 driver: OPENCODE_PROVIDER,
2351 turnItem: {
2352 createdBy: "agent",
2353 creationSource: "provider",
2354 id: turnItemId,
2355 threadId: turn.threadId,
2356 runId: null,
2357 nodeId: turn.rootNodeId,
2358 providerThreadId: state.providerThread.id,
2359 providerTurnId: turn.providerTurnId,
2360 nativeItemRef: providerRef(part.messageID),
2361 parentItemId: null,
2362 ordinal: itemOrdinal(turn, part.messageID),
2363 status: "completed",
2364 title: null,
2365 startedAt: projected.createdAt,
2366 completedAt: now,
2367 updatedAt: now,
2368 type: "user_message",
2369 messageId,
2370 inputIntent: "turn_start",
2371 text: part.text,
2372 attachments: [],
2373 },
2374 });
2375 });
2376
2377 const emitCompactionItem = Effect.fn("OpenCodeAdapterV2.emitCompactionItem")(function* (
2378 state: OpenCodeThreadState,
2379 turn: ActiveOpenCodeTurn,
2380 ) {
2381 const nativeItemId = `${turn.providerTurnId}:compaction`;
2382 if (turn.itemOrdinals.has(nativeItemId)) return;
2383 const now = yield* DateTime.now;
2384 yield* emitProviderEvent({
2385 type: "turn_item.updated",
2386 driver: OPENCODE_PROVIDER,
2387 turnItem: {
2388 id: idAllocator.derive.turnItemFromProviderItem({
2389 driver: OPENCODE_PROVIDER,
2390 nativeItemId,
2391 }),
2392 threadId: turn.threadId,
2393 runId: turn.runId,
2394 nodeId: turn.rootNodeId,
2395 providerThreadId: state.providerThread.id,
2396 providerTurnId: turn.providerTurnId,
2397 nativeItemRef: providerRef(nativeItemId, "weak"),
2398 parentItemId: null,
2399 ordinal: itemOrdinal(turn, nativeItemId),
2400 type: "compaction",
2401 driver: OPENCODE_PROVIDER,
2402 status: "completed",
2403 title: "Context compacted",
2404 startedAt: turn.startedAt,
2405 completedAt: now,
2406 updatedAt: now,
2407 },
2408 });
2409 });
2410
2411 const handleMessageUpdated = Effect.fnUntraced(function* (
2412 event: Extract<OpenCodeEvent, { type: "message.updated" }>,
2413 ) {
2414 const state = threads.get(event.properties.sessionID);
2415 if (state === undefined) return;
2416 const message = event.properties.info;
2417 state.messageRoles.set(message.id, message.role);
2418 if (message.role === "assistant") {
2419 const usage = state.activeTurn?.usage;
2420 if (usage === undefined) return;
2421 const prior = usage.assistantOwnershipByMessageId.get(message.id);
2422 const ownership =
2423 prior !== undefined && prior !== "unknown"
2424 ? prior
2425 : !message.parentID
2426 ? "unknown"
2427 : usage.promptMessageIds.has(message.parentID)
2428 ? "owned"
2429 : "other";
2430 usage.assistantOwnershipByMessageId.set(message.id, ownership);
2431 if (ownership !== "unknown") {
2432 if (ownership === "owned") {
2433 for (const step of usage.unresolvedStepsByMessageId.get(message.id)?.values() ??
2434 []) {
2435 accumulateOpenCodeStepUsage(usage, step);
2436 }
2437 }
2438 usage.unresolvedStepsByMessageId.delete(message.id);
2439 }
2440 return;
2441 }
2442 const commandReceipt = commandReceipts.get(message.id);
2443 if (commandReceipt) yield* Deferred.succeed(commandReceipt, undefined);
2444 const isNewUserMessage = !state.userMessageIds.includes(message.id);
2445 if (isNewUserMessage) state.userMessageIds.push(message.id);
2446 let turn = state.activeTurn;
2447 if (turn === null && state.parentSubagent !== null && isNewUserMessage) {
2448 turn = yield* createChildTurn(state, message);
2449 }
2450 const matchesAdmission =
2451 turn !== null &&
2452 (turn.admissionMessageId === null || turn.admissionMessageId === message.id);
2453 if (turn !== null && matchesAdmission) turn.usage.promptMessageIds.add(message.id);
2454 if (turn !== null && matchesAdmission && turn.nativeUserMessageId === null) {
2455 turn.nativeUserMessageId = message.id;
2456 yield* emitProviderTurn(state, turn, "running", null);
2457 }
2458 if (turn !== null && matchesAdmission && turn.admissionPending) {
2459 if (advanceOpenCodePromptAdmission(turn, "user-message") === "reconcile-idle") {
2460 yield* reconcilePromptAdmission(state, turn);
2461 }
2462 }
2463 });
2464
2465 const handlePartUpdated = Effect.fnUntraced(function* (
2466 event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
2467 ) {
2468 const part = event.properties.part;
2469 const state = threads.get(part.sessionID);
2470 const turn = state?.activeTurn;
2471 if (state === undefined || turn === null || turn === undefined || turn.finalized) return;
2472 if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") {
2473 if (!turn.isRoot) yield* projectChildUserPart(state, turn, part);
2474 return;
2475 }
2476 if (part.type === "step-finish") {
2477 const usage = turn.usage;
2478 const ownership = usage.assistantOwnershipByMessageId.get(part.messageID);
2479 if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part);
2480 else if (
2481 ownership === "unknown" ||
2482 (ownership === undefined && !state.messageRoles.has(part.messageID))
2483 ) {
2484 const steps =
2485 usage.unresolvedStepsByMessageId.get(part.messageID) ??
2486 new Map<string, OpenCodeStepUsage>();
2487 steps.set(part.id, { id: part.id, tokens: part.tokens });
2488 usage.unresolvedStepsByMessageId.set(part.messageID, steps);
2489 }
2490 return;
2491 }
2492 if (part.type === "tool") {
2493 // Approval routing needs the tool name, without retaining its input and output.
2494 turn.toolNamesByCallId.set(part.callID, part.tool);
2495 } else {
2496 turn.parts.set(part.id, part);
2497 const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set<string>();
2498 ids.add(part.id);
2499 turn.partIdsByMessage.set(part.messageID, ids);
2500 }
2501 switch (part.type) {
2502 case "text":
2503 case "reasoning":
2504 yield* emitTextPart(state, turn, part);
2505 return;
2506 case "tool":
2507 yield* emitToolPart(state, turn, part);
2508 return;
2509 default:
2510 return;
2511 }
2512 });
2513
2514 const handlePartDelta = Effect.fnUntraced(function* (
2515 event: Extract<OpenCodeEvent, { type: "message.part.delta" }>,
2516 ) {
2517 if (event.properties.field !== "text") return;
2518 const state = threads.get(event.properties.sessionID);
2519 const turn = state?.activeTurn;
2520 const current = turn?.parts.get(event.properties.partID);
2521 if (
2522 state === undefined ||
2523 turn === null ||
2524 turn === undefined ||
2525 current === undefined ||
2526 (current.type !== "text" && current.type !== "reasoning")
2527 ) {
2528 return;
2529 }
2530 const updated = { ...current, text: current.text + event.properties.delta };
2531 turn.parts.set(updated.id, updated);
2532 yield* emitTextPart(state, turn, updated);
2533 });
2534
2535 const handleAssistantCompleted = Effect.fnUntraced(function* (
2536 event: Extract<OpenCodeEvent, { type: "message.updated" }>,
2537 ) {
2538 const message = event.properties.info;
2539 if (message.role !== "assistant" || message.time.completed === undefined) return;
2540 const state = threads.get(message.sessionID);
2541 const turn = state?.activeTurn;
2542 if (state === undefined || turn === null || turn === undefined) return;
2543 // Some OpenCode versions ignore the client-provided message ID. A
2544 // completed assistant message is definitive admission evidence, so
2545 // let the following idle event settle the turn without weakening
2546 // the stale-user-message guard.
2547 advanceOpenCodePromptAdmission(turn, "assistant-completed");
2548 for (const partId of turn.partIdsByMessage.get(message.id) ?? []) {
2549 const part = turn.parts.get(partId);
2550 if (part?.type === "text" || part?.type === "reasoning") {
2551 yield* emitTextPart(state, turn, part, true);
2552 }
2553 }
2554 });
2555
2556 const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) {
2557 yield* logProtocolEvent({
2558 direction: "incoming",
2559 messageKind: "notification",
2560 method: event.type,
2561 payload: event,
2562 });
2563 switch (event.type) {
2564 case "server.connected":
2565 if (hasConnected) {
2566 for (const state of threads.values()) {
2567 if (state.activeTurn !== null) state.activeTurn.usage.complete = false;
2568 }
2569 }
2570 hasConnected = true;
2571 return;
2572 case "message.updated":
2573 yield* handleMessageUpdated(event);
2574 yield* handleAssistantCompleted(event);
2575 return;
2576 case "session.compacted": {
2577 const state = threads.get(event.properties.sessionID);
2578 if (state?.activeTurn !== undefined && state.activeTurn !== null) {
2579 yield* emitCompactionItem(state, state.activeTurn);
2580 }
2581 return;
2582 }
2583 case "message.part.updated":
2584 yield* handlePartUpdated(event);
2585 return;
2586 case "message.part.delta":
2587 yield* handlePartDelta(event);
2588 return;
2589 case "todo.updated": {
2590 const state = threads.get(event.properties.sessionID);
2591 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
2592 yield* emitTodo(state, state.activeTurn, event.properties.todos);
2593 }
2594 return;
2595 }
2596 case "permission.asked":
2597 yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, {
2598 type: "permission",
2599 value: event.properties,
2600 });
2601 return;
2602 case "question.asked":
2603 yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, {
2604 type: "question",
2605 value: event.properties,
2606 });
2607 return;
2608 case "permission.replied":
2609 case "question.replied":
2610 rememberSettledRequest(event.properties.requestID);
2611 yield* resolveRuntimeRequest(event.properties.requestID, "resolved");
2612 return;
2613 case "question.rejected":
2614 rememberSettledRequest(event.properties.requestID);
2615 yield* resolveRuntimeRequest(event.properties.requestID, "cancelled");
2616 return;
2617 case "session.created":
2618 case "session.updated": {
2619 const info = event.properties.info;
2620 if (info.parentID !== undefined && !threads.has(info.id)) {
2621 const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID);
2622 if (owner !== undefined) {
2623 relatedSessionOwners.set(info.id, owner);
2624 }
2625 }
2626 return;
2627 }
2628 case "session.deleted":
2629 relatedSessionOwners.delete(event.properties.info.id);
2630 busySessionIds.delete(event.properties.info.id);
2631 return;
2632 case "session.status": {
2633 const sessionId = event.properties.sessionID;
2634 switch (event.properties.status.type) {
2635 case "busy":
2636 case "retry":
2637 if (threads.has(sessionId) || relatedSessionOwners.has(sessionId)) {
2638 busySessionIds.add(sessionId);
2639 }
2640 break;
2641 case "idle":
2642 busySessionIds.delete(sessionId);
2643 break;
2644 }
2645 const state = threads.get(sessionId);
2646 if (state === undefined) return;
2647 if (event.properties.status.type === "busy") {
2648 yield* updateProviderThread(state, { status: "active" });
2649 return;
2650 }
2651 if (event.properties.status.type === "idle" && state.activeTurn !== null) {
2652 if (state.activeTurn.admissionPending) {
2653 advanceOpenCodePromptAdmission(state.activeTurn, "idle");
2654 return;
2655 }
2656 yield* finalizeTurn(
2657 state,
2658 state.activeTurn,
2659 state.activeTurn.interrupted ? "interrupted" : "completed",
2660 );
2661 }
2662 return;
2663 }
2664 case "session.idle": {
2665 busySessionIds.delete(event.properties.sessionID);
2666 const state = threads.get(event.properties.sessionID);
2667 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
2668 if (state.activeTurn.admissionPending) {
2669 advanceOpenCodePromptAdmission(state.activeTurn, "idle");
2670 return;
2671 }
2672 yield* finalizeTurn(
2673 state,
2674 state.activeTurn,
2675 state.activeTurn.interrupted ? "interrupted" : "completed",
2676 );
2677 }
2678 return;
2679 }
2680 case "session.error": {
2681 const states =
2682 event.properties.sessionID === undefined
2683 ? Array.from(threads.values()).filter((state) => state.activeTurn !== null)
2684 : [threads.get(event.properties.sessionID)].filter(
2685 (state): state is OpenCodeThreadState => state !== undefined,
2686 );
2687 const message = openCodeErrorMessage(event);
2688 if (
2689 !isMessageAbortedError(event) &&
2690 (event.properties.sessionID === undefined ||
2691 states.some((state) => state.parentSubagent === null))
2692 ) {
2693 yield* updateProviderSession("error", message);
2694 }
2695 for (const state of states) {
2696 if (state.activeTurn !== null) {
2697 yield* finalizeTurn(
2698 state,
2699 state.activeTurn,
2700 terminalStatusForError(event, state.activeTurn),
2701 {
2702 failure: makeProviderFailure({
2703 message,
2704 code: event.properties.error?.name ?? null,
2705 class: "provider_error",
2706 }),
2707 threadDisposition:
2708 event.properties.sessionID === undefined ? "broken" : "reusable",
2709 },
2710 );
2711 }
2712 }
2713 return;
2714 }
2715 default:
2716 return;
2717 }
2718 });
2719
2720 const inputProviderSessionId = input.providerSessionId;
2721
2722 const subscription = yield* sdkCall("event.subscribe", {}, () =>
2723 client.event.subscribe(undefined, { signal: abortController.signal }),
2724 );
2725 yield* Scope.addFinalizer(
2726 scope,
2727 Effect.sync(() => abortController.abort()),
2728 );
2729 yield* Stream.fromAsyncIterable(
2730 subscription.stream,
2731 (cause) =>
2732 new OpenCodeRuntime.OpenCodeRuntimeError({
2733 operation: "event.subscribe",
2734 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
2735 cause,
2736 }),
2737 ).pipe(
2738 Stream.runForEach(handleEvent),
2739 Effect.exit,
2740 Effect.flatMap((exit) =>
2741 Effect.gen(function* () {
2742 // No further status can clear a busy session once the stream ends.
2743 busySessionIds.clear();
2744 if (closing || abortController.signal.aborted) return;
2745 const detail = Exit.isSuccess(exit)
2746 ? "OpenCode event stream ended unexpectedly."
2747 : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause));
2748 nativeStreamFailure = makeProviderFailure({
2749 message: detail,
2750 class: "transport_error",
2751 });
2752 yield* updateProviderSession("error", detail);
2753 for (const state of threads.values()) {
2754 if (state.activeTurn !== null)
2755 yield* finalizeTurn(state, state.activeTurn, "failed", {
2756 failure: makeProviderFailure({ message: detail, class: "transport_error" }),
2757 threadDisposition: "broken",
2758 });
2759 }
2760 yield* Queue.end(events);
2761 }),
2762 ),
2763 Effect.forkIn(scope),
2764 );
2765
2766 if (!connection.external && connection.exitCode !== null) {
2767 yield* connection.exitCode.pipe(
2768 Effect.flatMap((code) =>
2769 abortController.signal.aborted
2770 ? Effect.void
2771 : Effect.gen(function* () {
2772 const detail = `OpenCode server exited unexpectedly (${code}).`;
2773 yield* updateProviderSession("error", detail);
2774 for (const state of threads.values()) {
2775 if (state.activeTurn !== null) {
2776 yield* finalizeTurn(state, state.activeTurn, "failed", {
2777 failure: makeProviderFailure({
2778 message: detail,
2779 class: "transport_error",
2780 }),
2781 threadDisposition: "broken",
2782 });
2783 }
2784 }
2785 }),
2786 ),
2787 Effect.forkIn(scope),
2788 );
2789 }
2790
2791 if (connection.external) {
2792 yield* Scope.addFinalizer(
2793 scope,
2794 Effect.suspend(() =>
2795 Effect.forEach(
2796 Array.from(threads.values()).filter((state) => state.parentSubagent === null),
2797 (state) =>
2798 sdkCall("session.abort", { sessionID: state.nativeSessionId }, (signal) =>
2799 client.session.abort({ sessionID: state.nativeSessionId }, { signal }),
2800 ).pipe(
2801 Effect.timeout("1 second"),
2802 Effect.ignore({ log: true }),
2803 Effect.andThen(
2804 abortDescendants(state.nativeSessionId).pipe(
2805 Effect.timeout("1 second"),
2806 Effect.ignore({ log: true }),
2807 ),
2808 ),
2809 ),
2810 { concurrency: 8, discard: true },
2811 ),
2812 ),
2813 );
2814 }
2815
2816 yield* Scope.addFinalizer(
2817 scope,
2818 Effect.sync(() => {
2819 closing = true;
2820 }),
2821 );
2822
2823 const registerThread = (
2824 nativeSession: OpenCodeSession,
2825 providerThread: OrchestrationV2ProviderThread,
2826 appThread: OrchestrationV2AppThread | null,
2827 ): OpenCodeThreadState => {
2828 const existing = threads.get(nativeSession.id);
2829 if (existing !== undefined) {
2830 existing.providerThread = providerThread;
2831 if (appThread !== null) existing.appThread = appThread;
2832 return existing;
2833 }
2834 const state: OpenCodeThreadState = {
2835 nativeSessionId: nativeSession.id,
2836 providerThread,
2837 appThread,
2838 activeTurn: null,
2839 providerTurns: new Map(),
2840 messages: new Map(),
2841 runtimeRequests: new Map(),
2842 messageRoles: new Map(),
2843 userMessageIds: [],
2844 parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null,
2845 nextChildTurnOrdinal: 1,
2846 nextAdmissionGeneration: 1,
2847 };
2848 threads.set(nativeSession.id, state);
2849 return state;
2850 };
2851
2852 const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => {
2853 const text = providerMessageTextWithAttachmentPaths({
2854 text: turnInput.message.text,
2855 attachments: turnInput.message.attachments,
2856 resolveAttachmentPath: (attachment) =>
2857 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
2858 }).trim();
2859 const files = OpenCodeRuntime.toOpenCodeFileParts({
2860 attachments: turnInput.message.attachments,
2861 resolveAttachmentPath: (attachment) =>
2862 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
2863 });
2864 if (text.length === 0 && files.length === 0) {
2865 throw protocolError("OpenCode turns require text or at least one valid attachment");
2866 }
2867 return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files];
2868 };
2869
2870 const readSnapshot = Effect.fnUntraced(function* (
2871 providerThread: OrchestrationV2ProviderThread,
2872 ) {
2873 const sessionId = nativeThreadId(providerThread);
2874 const sessionResponse = yield* sdkCall("session.get", { sessionID: sessionId }, () =>
2875 client.session.get({ sessionID: sessionId }),
2876 );
2877 const nativeSession = unwrapData("session.get", sessionResponse);
2878 const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () =>
2879 client.session.messages({ sessionID: sessionId }),
2880 );
2881 const nativeMessages = [];
2882 for (const entry of unwrapData("session.messages", response)) {
2883 // OpenCode retains reverted records in session.messages. Its
2884 // revert marker identifies the first removed message, so stop
2885 // before it or a restart resurrects rolled-back assistant work.
2886 if (entry.info.id === nativeSession.revert?.messageID) break;
2887 nativeMessages.push(entry);
2888 }
2889 const state = threads.get(sessionId);
2890 const snapshotNow = yield* DateTime.now;
2891 const messages: Array<OrchestrationV2ConversationMessage> = nativeMessages.flatMap(
2892 ({ info, parts }) => {
2893 const text = parts
2894 .filter(
2895 (part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text",
2896 )
2897 .filter((part) => part.ignored !== true && part.synthetic !== true)
2898 .map((part) => part.text)
2899 .join("\n");
2900 if (text.length === 0) return [];
2901 const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow);
2902 return [
2903 {
2904 createdBy: info.role === "user" ? "user" : "agent",
2905 creationSource: "provider",
2906 id: idAllocator.derive.messageFromProviderItem({
2907 driver: OPENCODE_PROVIDER,
2908 nativeItemId: info.id,
2909 }),
2910 threadId: providerThread.appThreadId ?? input.threadId,
2911 runId: null,
2912 nodeId: null,
2913 role: info.role,
2914 text,
2915 attachments: [],
2916 streaming: false,
2917 createdAt,
2918 updatedAt:
2919 info.role === "assistant"
2920 ? dateTimeFromEpoch(info.time.completed, createdAt)
2921 : createdAt,
2922 },
2923 ];
2924 },
2925 );
2926 const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id;
2927 return {
2928 providerThread: {
2929 ...providerThread,
2930 providerSessionId: input.providerSessionId,
2931 nativeConversationHeadRef:
2932 lastUser === undefined ? null : providerRef(lastUser, "weak"),
2933 status: "idle" as const,
2934 updatedAt: snapshotNow,
2935 },
2936 providerTurns: state === undefined ? [] : [...state.providerTurns.values()],
2937 messages,
2938 runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()],
2939 providerPayload: nativeMessages,
2940 };
2941 });
2942
2943 // Native commands wait for generation, unlike promptAsync. A user-message
2944 // receipt admits the run while the scoped request continues in the background.
2945 const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* (
2946 state: OpenCodeThreadState,
2947 turn: ActiveOpenCodeTurn,
2948 payload: Parameters<typeof client.session.promptAsync>[0] & {
2949 messageID: string;
2950 sessionID: string;
2951 },
2952 abortController: AbortController,
2953 ) {
2954 const text = payload.parts
2955 ?.filter((part) => part.type === "text")
2956 .map((part) => part.text)
2957 .join("\n")
2958 .trim();
2959 const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/);
2960 const command = match
2961 ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe(
2962 Effect.timeout("10 seconds"),
2963 Effect.orElseSucceed(() => []),
2964 )).find((entry) => entry.name === match[1])
2965 : undefined;
2966 if (!command) {
2967 return yield* sdkCall("session.promptAsync", payload, (signal) =>
2968 client.session.promptAsync(payload, {
2969 signal: AbortSignal.any([signal, abortController.signal]),
2970 }),
2971 ).pipe(Effect.asVoid);
2972 }
2973 const receipt = Deferred.makeUnsafe<void>();
2974 commandReceipts.set(payload.messageID, receipt);
2975 const controllers =
2976 commandControllers.get(payload.sessionID) ?? new Set<AbortController>();
2977 controllers.add(abortController);
2978 commandControllers.set(payload.sessionID, controllers);
2979 const generation = turn.admissionGeneration;
2980 const commandPayload = {
2981 sessionID: payload.sessionID,
2982 messageID: payload.messageID,
2983 command: command.name,
2984 arguments: match?.[2] ?? "",
2985 ...(payload.model
2986 ? { model: `${payload.model.providerID}/${payload.model.modelID}` }
2987 : {}),
2988 ...(payload.agent ? { agent: payload.agent } : {}),
2989 ...(payload.variant ? { variant: payload.variant } : {}),
2990 parts: payload.parts?.filter((part) => part.type === "file") ?? [],
2991 };
2992 const request = yield* sdkCall("session.command", commandPayload, (signal) =>
2993 client.session.command(commandPayload, {
2994 signal: AbortSignal.any([signal, abortController.signal]),
2995 }),
2996 ).pipe(
2997 Effect.asVoid,
2998 Effect.tapError((cause) =>
2999 abortController.signal.aborted ||
3000 turn.finalized ||
3001 turn.admissionGeneration !== generation
3002 ? Effect.void
3003 : finalizeTurn(state, turn, "failed", {
3004 failure: makeProviderFailure({ cause, class: "provider_error" }),
3005 }),
3006 ),
3007 Effect.ensuring(
3008 Effect.sync(() => {
3009 commandReceipts.delete(payload.messageID);
3010 controllers.delete(abortController);
3011 if (controllers.size === 0) commandControllers.delete(payload.sessionID);
3012 }),
3013 ),
3014 Effect.forkIn(scope),
3015 );
3016 yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe(
3017 Effect.timeout("10 seconds"),
3018 Effect.catchTags({
3019 TimeoutError: (cause) => {
3020 const error = new OpenCodeRuntime.OpenCodeRuntimeError({
3021 operation: "session.command",
3022 detail: "OpenCode command admission did not complete within 10 seconds.",
3023 cause,
3024 });
3025 abortController.abort();
3026 return finalizeTurn(state, turn, "failed", {
3027 failure: makeProviderFailure({ cause: error, class: "provider_error" }),
3028 }).pipe(Effect.andThen(Effect.fail(error)));
3029 },
3030 }),
3031 );
3032 });
3033
3034 const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
3035 instanceId: options.instanceId,
3036 driver: OPENCODE_PROVIDER,
3037 providerSessionId: input.providerSessionId,
3038 providerSession: sessionEntity,
3039 events: Stream.fromEffectRepeat(Queue.take(events)),
3040 hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0),
3041 ensureThread: (threadInput) =>
3042 Effect.gen(function* () {
3043 // Only a row that already carries a native session can be
3044 // resumed; a placeholder without one still needs session.create.
3045 if (threadInput.existingProviderThread?.nativeThreadRef != null) {
3046 return yield* runtimeSession.resumeThread({
3047 providerThread: threadInput.existingProviderThread,
3048 });
3049 }
3050 // No title: OpenCode generates one from the first prompt only when
3051 // session.create leaves it unset (SessionPrompt.ensureTitle).
3052 const response = yield* sdkCall(
3053 "session.create",
3054 { permission: openCodePermissionRules(threadInput.runtimePolicy) },
3055 () =>
3056 client.session.create({
3057 permission: openCodePermissionRules(threadInput.runtimePolicy),
3058 }),
3059 );
3060 const nativeSession = unwrapData("session.create", response);
3061 const createdAt = yield* DateTime.now;
3062 const created = makeProviderThread({
3063 idAllocator,
3064 providerInstanceId: options.instanceId,
3065 providerSessionId: input.providerSessionId,
3066 appThreadId: threadInput.threadId,
3067 nativeSession,
3068 now: createdAt,
3069 });
3070 const existing = threadInput.existingProviderThread;
3071 // Bind the new native session to the caller's row when one was
3072 // handed over: a second live row per app thread would make
3073 // `activeProviderThreadId` flap between the two on every update.
3074 const providerThread =
3075 existing === undefined
3076 ? created
3077 : {
3078 ...existing,
3079 providerSessionId: input.providerSessionId,
3080 nativeThreadRef: created.nativeThreadRef,
3081 nativeConversationHeadRef: created.nativeConversationHeadRef,
3082 status: created.status,
3083 updatedAt: created.updatedAt,
3084 };
3085 registerThread(nativeSession, providerThread, null);
3086 return providerThread;
3087 }).pipe(
3088 Effect.mapError(
3089 (cause) =>
3090 new ProviderAdapter.ProviderAdapterEnsureThreadError({
3091 driver: OPENCODE_PROVIDER,
3092 threadId: threadInput.threadId,
3093 cause,
3094 }),
3095 ),
3096 ),
3097 resumeThread: (threadInput) =>
3098 Effect.gen(function* () {
3099 const sessionId = nativeThreadId(threadInput.providerThread);
3100 const response = yield* sdkCall("session.get", { sessionID: sessionId }, () =>
3101 client.session.get({ sessionID: sessionId }),
3102 );
3103 const nativeSession = unwrapData("session.get", response);
3104 const resumedAt = yield* DateTime.now;
3105 const providerThread = {
3106 ...threadInput.providerThread,
3107 providerSessionId: input.providerSessionId,
3108 status: "idle" as const,
3109 updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt),
3110 };
3111 registerThread(nativeSession, providerThread, null);
3112 return providerThread;
3113 }).pipe(
3114 Effect.mapError(
3115 (cause) =>
3116 new ProviderAdapter.ProviderAdapterResumeThreadError({
3117 driver: OPENCODE_PROVIDER,
3118 providerSessionId: input.providerSessionId,
3119 providerThreadId: threadInput.providerThread.id,
3120 cause,
3121 }),
3122 ),
3123 ),
3124 compactThread: (turnInput) =>
3125 runtimeSession.startTurn({
3126 ...turnInput,
3127 message: { ...turnInput.message, text: "/compact" },
3128 }),
3129 startTurn: (turnInput) =>
3130 Effect.gen(function* () {
3131 if (nativeStreamFailure !== null) {
3132 return yield* protocolError(
3133 "OpenCode event stream has ended; reconnect the provider session before starting another turn.",
3134 );
3135 }
3136 const sessionId = nativeThreadId(turnInput.providerThread);
3137 const state = threads.get(sessionId);
3138 if (state === undefined) {
3139 return yield* protocolError(`OpenCode session ${sessionId} is not registered`);
3140 }
3141 if (state.activeTurn !== null) {
3142 return yield* protocolError(
3143 `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`,
3144 );
3145 }
3146 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(
3147 turnInput.modelSelection.model,
3148 );
3149 if (parsedModel === null) {
3150 return yield* protocolError(
3151 `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`,
3152 );
3153 }
3154 const isCompaction =
3155 turnInput.message.text.trim() === "/compact" &&
3156 turnInput.message.attachments.length === 0;
3157 const parts = isCompaction ? [] : resolvePromptParts(turnInput);
3158 const startedAt = yield* DateTime.now;
3159 const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`;
3160 const providerTurnId = idAllocator.derive.providerTurn({
3161 driver: OPENCODE_PROVIDER,
3162 nativeTurnId: syntheticNativeTurnId,
3163 });
3164 const providerTurn: OrchestrationV2ProviderTurn = {
3165 id: providerTurnId,
3166 providerThreadId: turnInput.providerThread.id,
3167 nodeId: turnInput.rootNodeId,
3168 runAttemptId: turnInput.attemptId,
3169 nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"),
3170 ordinal: turnInput.providerTurnOrdinal,
3171 status: "running",
3172 startedAt,
3173 completedAt: null,
3174 };
3175 const admissionMessageId = yield* makeOpenCodeMessageId();
3176 // No Effect may be yielded between this check and installing the
3177 // turn. If the event stream ended while IDs were being prepared,
3178 // registering afterward would leave a running turn that the EOF
3179 // handler had already finished scanning.
3180 if (nativeStreamFailure !== null) {
3181 return yield* protocolError(
3182 "OpenCode event stream has ended; reconnect the provider session before starting another turn.",
3183 );
3184 }
3185 if (state.activeTurn !== null) {
3186 return yield* protocolError(
3187 `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`,
3188 );
3189 }
3190 const turn: ActiveOpenCodeTurn = {
3191 isRoot: true,
3192 threadId: turnInput.threadId,
3193 runId: turnInput.runId,
3194 rootNodeId: turnInput.rootNodeId,
3195 appThread: turnInput.appThread,
3196 modelSelection: turnInput.modelSelection,
3197 runtimePolicy: turnInput.runtimePolicy,
3198 providerTurnId,
3199 providerThreadId: turnInput.providerThread.id,
3200 providerTurnOrdinal: turnInput.providerTurnOrdinal,
3201 runOrdinal: turnInput.runOrdinal,
3202 runAttemptId: turnInput.attemptId,
3203 startedAt,
3204 itemOrdinals: new Map(),
3205 usage: makeOpenCodeTurnTokenUsageAccumulator(),
3206 parts: new Map(),
3207 partIdsByMessage: new Map(),
3208 toolNamesByCallId: new Map(),
3209 providerTurn,
3210 nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1,
3211 nativeUserMessageId: null,
3212 admissionMessageId,
3213 interrupted: false,
3214 finalized: false,
3215 planId: null,
3216 admissionGeneration: state.nextAdmissionGeneration++,
3217 admissionReconciliationGeneration: null,
3218 admissionPending: !isCompaction,
3219 admissionAccepted: isCompaction,
3220 admissionMessageObserved: false,
3221 idleDuringAdmission: false,
3222 admissionSettled: Deferred.makeUnsafe<void>(),
3223 admissionAbortController: new AbortController(),
3224 };
3225 if (turn.admissionMessageId !== null)
3226 turn.usage.promptMessageIds.add(turn.admissionMessageId);
3227 const admissionSettled = turn.admissionSettled;
3228 const admissionAbortController = turn.admissionAbortController;
3229 state.appThread = turnInput.appThread;
3230 state.activeTurn = turn;
3231 state.providerTurns.set(String(providerTurnId), providerTurn);
3232 yield* emitProviderTurn(state, turn, "running", null);
3233 yield* updateProviderThread(state, {
3234 status: "active",
3235 firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal,
3236 lastRunOrdinal: turnInput.runOrdinal,
3237 });
3238 yield* updateProviderSession("running", null);
3239 if (isCompaction) {
3240 yield* sdkCall(
3241 "session.summarize",
3242 { sessionID: sessionId, ...parsedModel, auto: false },
3243 (signal) =>
3244 client.session.summarize(
3245 { sessionID: sessionId, ...parsedModel, auto: false },
3246 { signal: AbortSignal.any([signal, admissionAbortController!.signal]) },
3247 ),
3248 ).pipe(
3249 Effect.tap(() =>
3250 turn.interrupted || turn.finalized || nativeStreamFailure !== null
3251 ? Effect.void
3252 : emitCompactionItem(state, turn),
3253 ),
3254 Effect.tap(() =>
3255 finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "completed"),
3256 ),
3257 Effect.tapError((cause) =>
3258 finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "failed", {
3259 failure: makeProviderFailure({ cause, class: "provider_error" }),
3260 }),
3261 ),
3262 Effect.ensuring(Deferred.succeed(admissionSettled, undefined)),
3263 );
3264 return;
3265 }
3266 const systemPrompt = [
3267 orchestrationSystemPrompt,
3268 buildRuntimeInstructions({
3269 harness: "OpenCode",
3270 model: turnInput.modelSelection.model,
3271 }),
3272 ]
3273 .filter(Boolean)
3274 .join("\n\n");
3275 const agent =
3276 getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ??
3277 (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined);
3278 const variant = getModelSelectionStringOptionValue(
3279 turnInput.modelSelection,
3280 "variant",
3281 );
3282 yield* submitPrompt(
3283 state,
3284 turn,
3285 {
3286 sessionID: sessionId,
3287 messageID: turn.admissionMessageId!,
3288 model: parsedModel,
3289 ...(agent === undefined ? {} : { agent }),
3290 ...(variant === undefined ? {} : { variant }),
3291 system: systemPrompt,
3292 parts,
3293 },
3294 admissionAbortController!,
3295 ).pipe(
3296 Effect.tapError((cause) =>
3297 admissionAbortController!.signal.aborted
3298 ? Effect.void
3299 : finalizeTurn(state, turn, "failed", {
3300 failure: makeProviderFailure({ cause, class: "provider_error" }),
3301 }),
3302 ),
3303 Effect.catch((cause) =>
3304 admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause),
3305 ),
3306 Effect.ensuring(
3307 Effect.all([
3308 Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore),
3309 Effect.sync(() => {
3310 if (turn.admissionAbortController === admissionAbortController) {
3311 turn.admissionAbortController = null;
3312 }
3313 }),
3314 ]).pipe(Effect.asVoid),
3315 ),
3316 );
3317 if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) {
3318 const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted");
3319 if (admissionAction === "reconcile-idle") {
3320 yield* reconcilePromptAdmission(state, turn);
3321 }
3322 }
3323 }).pipe(
3324 Effect.mapError(
3325 (cause) =>
3326 new ProviderAdapter.ProviderAdapterTurnStartError({
3327 driver: OPENCODE_PROVIDER,
3328 threadId: turnInput.threadId,
3329 providerThreadId: turnInput.providerThread.id,
3330 runId: turnInput.runId,
3331 cause,
3332 }),
3333 ),
3334 ),
3335 steerTurn: (steerInput) =>
3336 Effect.gen(function* () {
3337 const sessionId = nativeThreadId(steerInput.providerThread);
3338 const state = threads.get(sessionId);
3339 const turn = state?.activeTurn;
3340 if (
3341 state === undefined ||
3342 turn === undefined ||
3343 turn === null ||
3344 turn.providerTurnId !== steerInput.providerTurnId ||
3345 turn.interrupted
3346 ) {
3347 return yield* protocolError(
3348 `OpenCode turn ${steerInput.providerTurnId} is not active`,
3349 );
3350 }
3351 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model);
3352 if (parsedModel === null) {
3353 return yield* protocolError(
3354 `OpenCode model '${turn.modelSelection.model}' must use provider/model format`,
3355 );
3356 }
3357 const text = providerMessageTextWithAttachmentPaths({
3358 text: steerInput.message.text,
3359 attachments: steerInput.message.attachments,
3360 resolveAttachmentPath: (attachment) =>
3361 resolveAttachmentPath({
3362 attachmentsDir: serverConfig.attachmentsDir,
3363 attachment,
3364 }),
3365 }).trim();
3366 const files = OpenCodeRuntime.toOpenCodeFileParts({
3367 attachments: steerInput.message.attachments,
3368 resolveAttachmentPath: (attachment) =>
3369 resolveAttachmentPath({
3370 attachmentsDir: serverConfig.attachmentsDir,
3371 attachment,
3372 }),
3373 });
3374 if (text.length === 0 && files.length === 0) {
3375 return yield* protocolError("OpenCode steering requires text or an attachment");
3376 }
3377 const parts = [
3378 ...(text.length === 0 ? [] : [{ type: "text" as const, text }]),
3379 ...files,
3380 ];
3381 turn.admissionGeneration = state.nextAdmissionGeneration++;
3382 turn.admissionMessageId = yield* makeOpenCodeMessageId();
3383 turn.admissionPending = true;
3384 turn.admissionAccepted = false;
3385 turn.admissionMessageObserved = false;
3386 turn.idleDuringAdmission = false;
3387 turn.admissionSettled = Deferred.makeUnsafe<void>();
3388 turn.admissionAbortController = new AbortController();
3389 if (turn.admissionMessageId !== null)
3390 turn.usage.promptMessageIds.add(turn.admissionMessageId);
3391 const admissionSettled = turn.admissionSettled;
3392 const admissionAbortController = turn.admissionAbortController;
3393 yield* submitPrompt(
3394 state,
3395 turn,
3396 {
3397 sessionID: sessionId,
3398 messageID: turn.admissionMessageId,
3399 model: parsedModel,
3400 parts,
3401 },
3402 admissionAbortController!,
3403 ).pipe(
3404 Effect.ensuring(
3405 Effect.all([
3406 Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore),
3407 Effect.sync(() => {
3408 if (turn.admissionAbortController === admissionAbortController) {
3409 turn.admissionAbortController = null;
3410 }
3411 }),
3412 ]).pipe(Effect.asVoid),
3413 ),
3414 );
3415 if (state.activeTurn === turn && !turn.finalized) {
3416 const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted");
3417 if (admissionAction === "reconcile-idle") {
3418 yield* reconcilePromptAdmission(state, turn);
3419 }
3420 }
3421 }).pipe(
3422 Effect.mapError(
3423 (cause) =>
3424 new ProviderAdapter.ProviderAdapterSteerRunError({
3425 driver: OPENCODE_PROVIDER,
3426 providerThreadId: steerInput.providerThread.id,
3427 providerTurnId: steerInput.providerTurnId,
3428 cause,
3429 }),
3430 ),
3431 ),
3432 interruptTurn: (interruptInput) =>
3433 Effect.gen(function* () {
3434 const sessionId = nativeThreadId(interruptInput.providerThread);
3435 const state = threads.get(sessionId);
3436 const turn = state?.activeTurn;
3437 // Stop on a settled turn: a background task child can outlive
3438 // it (busySessionIds), and aborting the descendants ends it.
3439 if (
3440 (turn === undefined || turn === null) &&
3441 interruptInput.requestRuntimeRestart === true
3442 ) {
3443 for (const controller of commandControllers.get(sessionId) ?? [])
3444 controller.abort();
3445 yield* abortDescendants(sessionId);
3446 return;
3447 }
3448 if (
3449 turn === undefined ||
3450 turn === null ||
3451 turn.providerTurnId !== interruptInput.providerTurnId
3452 ) {
3453 return yield* protocolError(
3454 `OpenCode turn ${interruptInput.providerTurnId} is not active`,
3455 );
3456 }
3457 turn.interrupted = true;
3458 for (const controller of commandControllers.get(sessionId) ?? []) controller.abort();
3459 const admissionWasPending = turn.admissionPending;
3460 cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++);
3461 turn.admissionAbortController?.abort();
3462 if (admissionWasPending) {
3463 // Settle the cancelled request before sending the session abort.
3464 // A timed-out local request must not prevent the definitive
3465 // server-side abort below from running.
3466 yield* Deferred.await(turn.admissionSettled).pipe(
3467 Effect.timeout("10 seconds"),
3468 Effect.ignore,
3469 );
3470 }
3471 yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) =>
3472 client.session.abort({ sessionID: sessionId }, { signal }),
3473 ).pipe(
3474 Effect.timeout("10 seconds"),
3475 // The turn can settle while the abort is in flight, and
3476 // aborting an already-idle session fails. That stop still
3477 // succeeded; only surface failures for a turn that is
3478 // genuinely still running.
3479 Effect.catch((cause) =>
3480 turn.finalized
3481 ? Effect.void
3482 : Effect.suspend(() => {
3483 turn.interrupted = false;
3484 return Effect.fail(cause);
3485 }),
3486 ),
3487 );
3488 // Root abort does not stop child sessions. Report incomplete cleanup
3489 // even if the root has already emitted its terminal event.
3490 yield* abortDescendants(sessionId);
3491 }).pipe(
3492 Effect.mapError(
3493 (cause) =>
3494 new ProviderAdapter.ProviderAdapterInterruptError({
3495 driver: OPENCODE_PROVIDER,
3496 providerThreadId: interruptInput.providerThread.id,
3497 providerTurnId: interruptInput.providerTurnId,
3498 cause,
3499 }),
3500 ),
3501 ),
3502 respondToRuntimeRequest: (requestInput) =>
3503 Effect.gen(function* () {
3504 const pending = pendingRequests.get(String(requestInput.requestId));
3505 if (pending === undefined) {
3506 return yield* protocolError(
3507 `No pending OpenCode request ${requestInput.requestId}`,
3508 );
3509 }
3510 if (pending.question !== undefined) {
3511 if (requestInput.answers === undefined) {
3512 return yield* protocolError(
3513 `OpenCode question request ${requestInput.requestId} requires answers`,
3514 );
3515 }
3516 const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers(
3517 pending.question,
3518 requestInput.answers,
3519 );
3520 yield* sdkCall(
3521 "question.reply",
3522 { requestID: pending.nativeRequestId, answers },
3523 (signal) =>
3524 client.question.reply(
3525 {
3526 requestID: pending.nativeRequestId,
3527 answers,
3528 },
3529 { signal },
3530 ),
3531 ).pipe(Effect.timeout("10 seconds"));
3532 return;
3533 }
3534 if (requestInput.decision === undefined) {
3535 return yield* protocolError(
3536 `OpenCode approval request ${requestInput.requestId} requires a decision`,
3537 );
3538 }
3539 const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision);
3540 yield* sdkCall(
3541 "permission.reply",
3542 { requestID: pending.nativeRequestId, reply },
3543 (signal) =>
3544 client.permission.reply(
3545 {
3546 requestID: pending.nativeRequestId,
3547 reply,
3548 },
3549 { signal },
3550 ),
3551 ).pipe(Effect.timeout("10 seconds"));
3552 }).pipe(
3553 Effect.mapError(
3554 (cause) =>
3555 new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
3556 driver: OPENCODE_PROVIDER,
3557 requestId: requestInput.requestId,
3558 cause,
3559 }),
3560 ),
3561 ),
3562 readThreadSnapshot: (snapshotInput) =>
3563 readSnapshot(snapshotInput.providerThread).pipe(
3564 Effect.mapError(
3565 (cause) =>
3566 new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
3567 driver: OPENCODE_PROVIDER,
3568 providerThreadId: snapshotInput.providerThread.id,
3569 cause,
3570 }),
3571 ),
3572 ),
3573 rollbackThread: (rollbackInput) =>
3574 Effect.gen(function* () {
3575 const sessionId = nativeThreadId(rollbackInput.providerThread);
3576 const state = threads.get(sessionId);
3577 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
3578 return yield* protocolError(
3579 `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`,
3580 );
3581 }
3582 const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () =>
3583 client.session.messages({ sessionID: sessionId }),
3584 );
3585 const messages = unwrapData("session.messages", response);
3586 let boundaryMessageId: string | undefined;
3587 if (rollbackInput.target.type === "thread_start") {
3588 boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id;
3589 } else {
3590 boundaryMessageId = openCodeBoundaryAfterProviderTurn(
3591 rollbackInput.providerThreadTurns,
3592 rollbackInput.target.providerTurn.id,
3593 );
3594 }
3595 let retainedThread = rollbackInput.providerThread;
3596 if (boundaryMessageId !== undefined) {
3597 const boundaryIndex = messages.findIndex(
3598 ({ info }) => info.id === boundaryMessageId,
3599 );
3600 if (boundaryIndex < 0)
3601 return yield* protocolError(
3602 "The OpenCode rewind boundary is no longer available.",
3603 );
3604 const fork = unwrapData(
3605 "session.fork",
3606 yield* sdkCall(
3607 "session.fork",
3608 { sessionID: sessionId, messageID: boundaryMessageId },
3609 () =>
3610 client.session.fork({ sessionID: sessionId, messageID: boundaryMessageId }),
3611 ),
3612 );
3613 const retained = unwrapData(
3614 "session.messages",
3615 yield* sdkCall("session.messages", { sessionID: fork.id }, () =>
3616 client.session.messages({ sessionID: fork.id }),
3617 ),
3618 );
3619 if (retained.length !== boundaryIndex)
3620 return yield* protocolError(
3621 "OpenCode did not preserve the requested rewind boundary.",
3622 );
3623 yield* sdkCall("session.update", { sessionID: fork.id }, () =>
3624 client.session.update({
3625 sessionID: fork.id,
3626 permission: openCodePermissionRules(input.runtimePolicy),
3627 }),
3628 );
3629 retainedThread = {
3630 ...rollbackInput.providerThread,
3631 nativeThreadRef: providerRef(fork.id),
3632 };
3633 registerThread(fork, retainedThread, state?.appThread ?? null);
3634 }
3635 const snapshot = yield* readSnapshot(retainedThread);
3636 return {
3637 ...snapshot,
3638 providerThread: {
3639 ...snapshot.providerThread,
3640 nativeConversationHeadRef:
3641 rollbackInput.target.type === "provider_turn"
3642 ? rollbackInput.target.providerTurn.nativeTurnRef
3643 : null,
3644 },
3645 };
3646 }).pipe(
3647 Effect.mapError(
3648 (cause) =>
3649 new ProviderAdapter.ProviderAdapterRollbackThreadError({
3650 driver: OPENCODE_PROVIDER,
3651 providerThreadId: rollbackInput.providerThread.id,
3652 checkpointId: rollbackInput.target.checkpointId,
3653 cause,
3654 }),
3655 ),
3656 ),
3657 forkThread: (forkInput) =>
3658 Effect.gen(function* () {
3659 const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread);
3660 const sourceState = threads.get(sourceSessionId);
3661 if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) {
3662 return yield* protocolError(
3663 `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`,
3664 );
3665 }
3666 let boundaryMessageId: string | undefined;
3667 if (forkInput.providerTurnId !== undefined) {
3668 const sourceTurns = forkInput.sourceProviderTurns ?? [];
3669 const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId);
3670 if (selected === undefined) {
3671 return yield* protocolError(
3672 `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`,
3673 );
3674 }
3675 boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id);
3676 }
3677 const response = yield* sdkCall(
3678 "session.fork",
3679 {
3680 sessionID: sourceSessionId,
3681 ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }),
3682 },
3683 () =>
3684 client.session.fork({
3685 sessionID: sourceSessionId,
3686 ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }),
3687 }),
3688 );
3689 const nativeSession = unwrapData("session.fork", response);
3690 const forkedAt = yield* DateTime.now;
3691 const providerThread = makeProviderThread({
3692 idAllocator,
3693 providerInstanceId: options.instanceId,
3694 providerSessionId: input.providerSessionId,
3695 appThreadId: forkInput.targetThreadId,
3696 ...(forkInput.ownerNodeId === undefined
3697 ? {}
3698 : { ownerNodeId: forkInput.ownerNodeId }),
3699 nativeSession,
3700 forkedFrom: {
3701 providerThreadId: forkInput.sourceProviderThread.id,
3702 ...(forkInput.providerTurnId === undefined
3703 ? {}
3704 : { providerTurnId: forkInput.providerTurnId }),
3705 },
3706 now: forkedAt,
3707 });
3708 registerThread(nativeSession, providerThread, null);
3709 return providerThread;
3710 }).pipe(
3711 Effect.mapError(
3712 (cause) =>
3713 new ProviderAdapter.ProviderAdapterForkThreadError({
3714 driver: OPENCODE_PROVIDER,
3715 providerThreadId: forkInput.sourceProviderThread.id,
3716 cause,
3717 }),
3718 ),
3719 ),
3720 };
3721
3722 return runtimeSession;
3723 },
3724 (effect, input) =>
3725 effect.pipe(
3726 Effect.mapError(
3727 (cause) =>
3728 new ProviderAdapter.ProviderAdapterOpenSessionError({
3729 driver: OPENCODE_PROVIDER,
3730 providerSessionId: input.providerSessionId,
3731 cause,
3732 }),
3733 ),
3734 ),
3735 ),
3736 });
3737}
3738
3739export type OpenCodeAdapterV2DriverEnv =
3740 | OpenCodeRuntime.OpenCodeRuntime
3741 | IdAllocator.IdAllocatorV2
3742 | ProviderEventLoggers.ProviderEventLoggers
3743 | ServerConfig.ServerConfig;
3744
3745export const OpenCodeAdapterV2Driver: ProviderAdapterDriver<
3746 OpenCodeSettings,
3747 OpenCodeAdapterV2DriverEnv
3748> = {
3749 driverKind: OPENCODE_PROVIDER,
3750 configSchema: OpenCodeSettingsSchema,
3751 defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS,
3752 create: Effect.fn("OpenCodeAdapterV2Driver.create")(
3753 function* (input: ProviderAdapterDriverCreateInput<OpenCodeSettings>) {
3754 const hostEnvironment = yield* HostProcessEnvironment;
3755 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
3756 const idAllocator = yield* IdAllocator.IdAllocatorV2;
3757 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
3758 const serverConfig = yield* ServerConfig.ServerConfig;
3759 return makeOpenCodeAdapterV2({
3760 instanceId: input.instanceId,
3761 settings: { ...input.config, enabled: input.enabled },
3762 environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
3763 runtime: openCodeRuntime,
3764 idAllocator,
3765 serverConfig,
3766 ...(providerEventLoggers.native === undefined
3767 ? {}
3768 : { nativeEventLogger: providerEventLoggers.native }),
3769 });
3770 },
3771 (effect, input) =>
3772 effect.pipe(
3773 Effect.mapError(
3774 (cause) =>
3775 new ProviderAdapterDriverCreateError({
3776 driver: OPENCODE_PROVIDER,
3777 instanceId: input.instanceId,
3778 detail: "Failed to create OpenCode v2 adapter.",
3779 cause,
3780 }),
3781 ),
3782 ),
3783 ),
3784};
3785
3786const layer: Layer.Layer<ProviderAdapter.ProviderAdapterV2, never, OpenCodeAdapterV2DriverEnv> =
3787 Layer.effect(
3788 ProviderAdapter.ProviderAdapterV2,
3789 Effect.gen(function* () {
3790 const hostEnvironment = yield* HostProcessEnvironment;
3791 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
3792 const idAllocator = yield* IdAllocator.IdAllocatorV2;
3793 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
3794 const serverConfig = yield* ServerConfig.ServerConfig;
3795 return makeOpenCodeAdapterV2({
3796 instanceId: OPENCODE_DEFAULT_INSTANCE_ID,
3797 settings: DEFAULT_OPENCODE_SETTINGS,
3798 environment: hostEnvironment,
3799 runtime: openCodeRuntime,
3800 idAllocator,
3801 serverConfig,
3802 ...(providerEventLoggers.native === undefined
3803 ? {}
3804 : { nativeEventLogger: providerEventLoggers.native }),
3805 });
3806 }),
3807 );
apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts deleted-170
...@@ -1,170 +0,0 @@
1/**
2 * Turn-item bodies for OpenCode tool calls, shared by the 1.x and 2.x runtimes.
3 * Both report a tool as a name, a JSON input, text output, and free-form
4 * metadata; only how they deliver those differs.
5 */
6import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
7import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
8
9// Search results stay on the timeline wire, so keep their text a preview.
10const SEARCH_PREVIEW_MAX_CHARS = 8_000;
11
12type ToolItemBase = Omit<
13 Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>,
14 "type" | "toolName" | "input" | "output"
15>;
16
17export function openCodeToolProjectionKind(
18 toolName: string,
19): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" {
20 const normalized = toolName.toLowerCase();
21 if (normalized === "todowrite") {
22 return "dynamic_tool";
23 }
24 if (normalized.includes("bash") || normalized.includes("shell")) {
25 return "command_execution";
26 }
27 if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) {
28 return "file_change";
29 }
30 if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") {
31 return "web_search";
32 }
33 if (normalized === "read") {
34 return "dynamic_tool";
35 }
36 if (
37 normalized.includes("glob") ||
38 normalized.includes("grep") ||
39 normalized.includes("search") ||
40 normalized.includes("lsp")
41 ) {
42 return "file_search";
43 }
44 return "dynamic_tool";
45}
46
47function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined {
48 if (typeof input !== "object" || input === null) return undefined;
49 for (const key of keys) {
50 const value = (input as Record<string, unknown>)[key];
51 if (typeof value === "string" && value.trim().length > 0) return value;
52 }
53 return undefined;
54}
55
56function recordNumber(input: unknown, ...keys: ReadonlyArray<string>): number | undefined {
57 if (typeof input !== "object" || input === null) return undefined;
58 for (const key of keys) {
59 const value = (input as Record<string, unknown>)[key];
60 if (typeof value === "number" && Number.isFinite(value)) return value;
61 }
62 return undefined;
63}
64
65function stableJson(value: unknown): string {
66 if (typeof value === "string") return value;
67 try {
68 return JSON.stringify(value, null, 2);
69 } catch {
70 return String(value);
71 }
72}
73
74/**
75 * The turn item for one tool call. `completedMetadata` is the tool's metadata
76 * once it succeeded: exit codes and diffs are only trusted from a finished call.
77 */
78export function openCodeToolTurnItem(
79 base: ToolItemBase,
80 tool: {
81 readonly name: string;
82 readonly input: Record<string, unknown>;
83 readonly output: string | undefined;
84 readonly completedMetadata: unknown;
85 },
86): OrchestrationV2TurnItem {
87 const { input, output } = tool;
88 switch (openCodeToolProjectionKind(tool.name)) {
89 case "command_execution": {
90 const exitCode = recordNumber(tool.completedMetadata, "exit", "exitCode");
91 return {
92 ...base,
93 type: "command_execution",
94 input: recordString(input, "command", "cmd") ?? stableJson(input),
95 ...(output === undefined ? {} : { output }),
96 ...(exitCode === undefined ? {} : { exitCode }),
97 };
98 }
99 case "file_change": {
100 const oldStr = recordString(input, "oldString", "oldText");
101 const newStr = recordString(input, "newString", "content", "newText");
102 // A failed edit has no diff; keep its error where the diff would be.
103 const diffStr =
104 recordString(tool.completedMetadata, "diff", "patch") ??
105 (base.status === "failed" && output?.trim() ? output : undefined);
106 return {
107 ...base,
108 type: "file_change",
109 fileName: recordString(input, "filePath", "path", "file") ?? tool.name,
110 ...(oldStr === undefined ? {} : { oldStr }),
111 ...(newStr === undefined ? {} : { newStr }),
112 ...(diffStr === undefined ? {} : { diffStr }),
113 };
114 }
115 case "file_search": {
116 const pattern = recordString(input, "pattern", "query", "path", "filePath");
117 // OpenCode reports matches as plain text, so keep it as one result row
118 // under the searched path, like the ACP search projection.
119 const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim();
120 return {
121 ...base,
122 title:
123 formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ??
124 base.title,
125 type: "file_search",
126 ...(pattern === undefined ? {} : { pattern }),
127 ...(!output?.trim() || searchRoot === undefined
128 ? {}
129 : {
130 results: [
131 { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) },
132 ],
133 }),
134 };
135 }
136 case "web_search": {
137 const pattern = recordString(input, "query", "url", "pattern");
138 const url = recordString(input, "url")?.trim();
139 return {
140 ...base,
141 type: "web_search",
142 ...(pattern === undefined ? {} : { patterns: [pattern] }),
143 ...(!output?.trim()
144 ? {}
145 : {
146 results: [
147 {
148 ...(url === undefined ? {} : { url }),
149 snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS),
150 },
151 ],
152 }),
153 };
154 }
155 case "dynamic_tool": {
156 const readPath = recordString(input, "filePath", "path", "file");
157 return {
158 ...base,
159 title:
160 tool.name.toLowerCase() === "read" && readPath !== undefined
161 ? formatReadToolLabel(readPath)
162 : base.title,
163 type: "dynamic_tool",
164 toolName: tool.name,
165 input,
166 ...(output === undefined ? {} : { output }),
167 };
168 }
169 }
170}
apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+2-2
...@@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";...@@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
30import * as ModelManifest from "../provider/ModelManifest.ts";30import * as ModelManifest from "../provider/ModelManifest.ts";
31import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";31import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
32import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";32import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
33import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";33import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
34import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";34import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
35import * as ServerSettings from "../serverSettings.ts";35import * as ServerSettings from "../serverSettings.ts";
36import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";36import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
37import * as VcsProcess from "../vcs/VcsProcess.ts";37import * as VcsProcess from "../vcs/VcsProcess.ts";
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+2-2
...@@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";...@@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
30import * as ModelManifest from "../provider/ModelManifest.ts";30import * as ModelManifest from "../provider/ModelManifest.ts";
31import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";31import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
32import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";32import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
33import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";33import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
34import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";34import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
35import * as ServerSettings from "../serverSettings.ts";35import * as ServerSettings from "../serverSettings.ts";
36import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";36import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
37import * as VcsProcess from "../vcs/VcsProcess.ts";37import * as VcsProcess from "../vcs/VcsProcess.ts";
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts+1-1
...@@ -27,7 +27,7 @@ import {...@@ -27,7 +27,7 @@ import {
27 OPENCODE2_HTTP_PROTOCOL,27 OPENCODE2_HTTP_PROTOCOL,
28 OpenCode2OrchestratorReplayHarness,28 OpenCode2OrchestratorReplayHarness,
29} from "./Adapters/OpenCode2AdapterV2.testkit.ts";29} from "./Adapters/OpenCode2AdapterV2.testkit.ts";
30import { OPENCODE_PROVIDER } from "./Adapters/OpenCodeAdapterV2.ts";30import { OPENCODE_PROVIDER } from "@t3tools/provider-opencode/testing";
31import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime.ts";31import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime.ts";
32import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts";32import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts";
33import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts";33import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts";
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts+3-3
...@@ -55,9 +55,9 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";...@@ -55,9 +55,9 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
55import * as ModelManifest from "../provider/ModelManifest.ts";55import * as ModelManifest from "../provider/ModelManifest.ts";
56import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";56import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
57import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";57import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
58import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts";58import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client";
59import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";59import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
60import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";60import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
61import * as ServerSettings from "../serverSettings.ts";61import * as ServerSettings from "../serverSettings.ts";
62import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";62import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
63import * as VcsProcess from "../vcs/VcsProcess.ts";63import * as VcsProcess from "../vcs/VcsProcess.ts";
apps/server/src/orchestration-v2/Orchestrator.ts+1-1
...@@ -121,7 +121,7 @@ import {...@@ -121,7 +121,7 @@ import {
121 subagentResultForRun,121 subagentResultForRun,
122 delegatedTaskProgress,122 delegatedTaskProgress,
123 subagentThreadTitle,123 subagentThreadTitle,
124} from "./SubagentProjection.ts";124} from "@t3tools/provider-core/server/subagentProjection";
125import {125import {
126 forkableSourceRunStatusError,126 forkableSourceRunStatusError,
127 isForkableSourceRunStatus,127 isForkableSourceRunStatus,
apps/server/src/orchestration-v2/SubagentProjection.test.ts+1-1
...@@ -21,7 +21,7 @@ import {...@@ -21,7 +21,7 @@ import {
21 delegatedTaskProgress,21 delegatedTaskProgress,
22 subagentResultForRun,22 subagentResultForRun,
23 makeSubagentConversationArtifacts,23 makeSubagentConversationArtifacts,
24} from "./SubagentProjection.ts";24} from "@t3tools/provider-core/server/subagentProjection";
2525
26import { emptyProjection } from "./ProjectionStore.ts";26import { emptyProjection } from "./ProjectionStore.ts";
2727
apps/server/src/orchestration-v2/SubagentProjection.ts deleted-259
...@@ -1,259 +0,0 @@
1import type {
2 MessageId,
3 ModelSelection,
4 NodeId,
5 OrchestrationV2AppThread,
6 OrchestrationV2Actor,
7 OrchestrationV2ConversationMessage,
8 OrchestrationV2CreationSource,
9 OrchestrationV2ProviderRef,
10 OrchestrationV2Run,
11 OrchestrationV2ThreadProjection,
12 OrchestrationV2TurnItem,
13 ProviderInstanceId,
14 ProviderThreadId,
15 ProviderTurnId,
16 ThreadId,
17 TurnItemId,
18} from "@t3tools/contracts";
19import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
20import * as DateTime from "effect/DateTime";
21import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
22
23function trimmed(value: string | null | undefined): string | undefined {
24 const result = value?.trim();
25 return result && result.length > 0 ? result : undefined;
26}
27
28export function subagentThreadTitle(input: {
29 readonly parentTitle: string;
30 readonly title?: string | null;
31 readonly prompt: string;
32 readonly ordinal: number;
33}): string {
34 const detail = trimmed(input.title) ?? trimmed(input.prompt);
35 if (detail === undefined) {
36 return `${input.parentTitle} subagent ${input.ordinal}`;
37 }
38 const clipped = detail.length > 72 ? `${detail.slice(0, 69)}...` : detail;
39 return clipped;
40}
41
42export function makeSubagentChildThread(input: {
43 readonly parentThread: OrchestrationV2AppThread;
44 readonly childThreadId: ThreadId;
45 readonly parentNodeId: NodeId;
46 readonly activeProviderThreadId: ProviderThreadId | null;
47 readonly providerInstanceId: ProviderInstanceId;
48 readonly modelSelection: ModelSelection;
49 readonly title: string;
50 readonly now: DateTime.Utc;
51 readonly createdBy: OrchestrationV2Actor;
52 readonly creationSource: OrchestrationV2CreationSource;
53}): OrchestrationV2AppThread {
54 return {
55 ...input.parentThread,
56 createdBy: input.createdBy,
57 creationSource: input.creationSource,
58 id: input.childThreadId,
59 title: input.title,
60 linkedPullRequest: null,
61 pullRequests: [],
62 historyOrigin: undefined,
63 providerInstanceId: input.providerInstanceId,
64 modelSelection: input.modelSelection,
65 activeProviderThreadId: input.activeProviderThreadId,
66 lineage: {
67 parentThreadId: input.parentThread.id,
68 relationshipToParent: "subagent",
69 rootThreadId: input.parentThread.lineage.rootThreadId,
70 },
71 forkedFrom: {
72 type: "node",
73 nodeId: input.parentNodeId,
74 },
75 createdAt: input.now,
76 updatedAt: input.now,
77 archivedAt: null,
78 settledOverride: null,
79 settledAt: null,
80 snoozedUntil: null,
81 snoozedAt: null,
82 lastVisitedAt: null,
83 deletedAt: null,
84 };
85}
86
87export function makeSubagentConversationArtifacts(input: {
88 readonly messageId: MessageId;
89 readonly senderThreadId?: ThreadId;
90 readonly turnItemId: TurnItemId;
91 readonly threadId: ThreadId;
92 readonly rootNodeId: NodeId;
93 readonly providerThreadId: ProviderThreadId | null;
94 readonly providerTurnId: ProviderTurnId | null;
95 readonly nativeItemRef: OrchestrationV2ProviderRef | null;
96 readonly role: "user" | "assistant";
97 readonly text: string;
98 readonly ordinal: number;
99 readonly now: DateTime.Utc;
100}): {
101 readonly message: OrchestrationV2ConversationMessage;
102 readonly turnItem: OrchestrationV2TurnItem;
103} {
104 const message: OrchestrationV2ConversationMessage = {
105 createdBy: "agent",
106 creationSource: "provider",
107 id: input.messageId,
108 threadId: input.threadId,
109 runId: null,
110 nodeId: input.rootNodeId,
111 role: input.role,
112 ...(input.role === "user" && input.senderThreadId !== undefined
113 ? { senderThreadId: input.senderThreadId }
114 : {}),
115 text: input.text,
116 attachments: [],
117 streaming: false,
118 createdAt: input.now,
119 updatedAt: input.now,
120 };
121 const base = {
122 id: input.turnItemId,
123 threadId: input.threadId,
124 runId: null,
125 nodeId: input.rootNodeId,
126 providerThreadId: input.providerThreadId,
127 providerTurnId: input.providerTurnId,
128 nativeItemRef: input.nativeItemRef,
129 parentItemId: null,
130 ordinal: input.ordinal,
131 status: "completed" as const,
132 title: null,
133 startedAt: input.now,
134 completedAt: input.now,
135 updatedAt: input.now,
136 messageId: input.messageId,
137 text: input.text,
138 };
139 const turnItem: OrchestrationV2TurnItem =
140 input.role === "user"
141 ? {
142 ...base,
143 createdBy: "agent",
144 creationSource: "provider",
145 type: "user_message",
146 ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }),
147 inputIntent: "turn_start",
148 attachments: [],
149 }
150 : {
151 ...base,
152 type: "assistant_message",
153 streaming: false,
154 };
155 return { message, turnItem };
156}
157
158export function subagentResultForRun(
159 projection: Pick<OrchestrationV2ThreadProjection, "messages" | "turnItems">,
160 run: OrchestrationV2Run,
161): {
162 readonly text: string;
163 readonly messageId: OrchestrationV2ConversationMessage["id"] | null;
164 readonly turnItemId: OrchestrationV2TurnItem["id"] | null;
165} {
166 const message =
167 projection.messages
168 .filter(
169 (candidate) =>
170 candidate.runId === run.id &&
171 candidate.role === "assistant" &&
172 candidate.text.trim().length > 0,
173 )
174 .toSorted(
175 (left, right) =>
176 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
177 )[0] ?? null;
178 const turnItem =
179 projection.turnItems
180 .filter(
181 (
182 candidate,
183 ): candidate is Extract<OrchestrationV2TurnItem, { readonly type: "assistant_message" }> =>
184 candidate.runId === run.id &&
185 candidate.type === "assistant_message" &&
186 candidate.text.trim().length > 0,
187 )
188 .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null;
189 const failure =
190 run.status === "failed"
191 ? projection.turnItems
192 .filter((item) => item.runId === run.id && item.type === "error")
193 .toSorted((left, right) => right.ordinal - left.ordinal)[0]
194 : undefined;
195 const text =
196 (failure?.type === "error" ? failure.failure.message : undefined) ??
197 message?.text ??
198 turnItem?.text ??
199 (run.status === "completed"
200 ? "Child task completed without an assistant result."
201 : `Child task ended with status ${run.status}.`);
202 return {
203 text,
204 messageId: failure === undefined ? (message?.id ?? turnItem?.messageId ?? null) : null,
205 turnItemId: failure?.id ?? turnItem?.id ?? null,
206 };
207}
208
209/** A finished turn can still own live children or queued completion follow-ups. */
210export function delegatedTaskProgress(projection: {
211 readonly runs: OrchestrationV2ThreadProjection["runs"];
212 readonly messages: ReadonlyArray<
213 Pick<OrchestrationV2ConversationMessage, "runId" | "notification">
214 >;
215 readonly subagents: ReadonlyArray<
216 Pick<OrchestrationV2ThreadProjection["subagents"][number], "status" | "completionDelivery">
217 >;
218 readonly providerThreads: ReadonlyArray<
219 Pick<OrchestrationV2ThreadProjection["providerThreads"][number], "pendingBackgroundTasks">
220 >;
221}) {
222 const terminal = (status: string) =>
223 ["completed", "failed", "cancelled", "interrupted", "rolled_back"].includes(status);
224 const monitorRuns = new Set(
225 projection.messages
226 .filter((message) => message.notification?.source.kind === "monitor")
227 .map((message) => message.runId),
228 );
229 const workRuns = projection.runs.filter(
230 (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back",
231 );
232 // A held queue waits for the user to resume it (after Stop, a restart, or a
233 // provider failure), so its runs are not work the task still owes.
234 const active = workRuns.some(
235 (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true),
236 );
237 const children =
238 projection.subagents.some(
239 (task) =>
240 isOrchestrationV2WorkActive(task.status) ||
241 // Publishing a child's result precedes scheduling its parent's wake.
242 // The parent still owes that follow-up even between those transactions.
243 task.completionDelivery?.state === "pending" ||
244 task.completionDelivery?.state === "claimed",
245 ) ||
246 projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0);
247 const resultRun = workRuns
248 .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1))
249 .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0];
250 return {
251 state:
252 active || resultRun === undefined
253 ? ("working" as const)
254 : children
255 ? ("waiting_for_children" as const)
256 : ("result_available" as const),
257 resultRun,
258 };
259}
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+1-1
...@@ -17,7 +17,7 @@ import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/Gro...@@ -17,7 +17,7 @@ import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/Gro
17import {17import {
18 OpenCodeAdapterV2Driver,18 OpenCodeAdapterV2Driver,
19 type OpenCodeAdapterV2DriverEnv,19 type OpenCodeAdapterV2DriverEnv,
20} from "./Adapters/OpenCodeAdapterV2.ts";20} from "@t3tools/provider-opencode/server";
21import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server";21import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server";
22import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver";22import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver";
2323
apps/server/src/provider/Drivers/OpenCodeDriver.test.ts deleted-282
...@@ -1,282 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { assert, it } from "@effect/vitest";
8import { ProviderInstanceId, type OpenCodeSettings } from "@t3tools/contracts";
9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as TestClock from "effect/testing/TestClock";
14import { HttpClient } from "effect/http";
15import { ChildProcessSpawner } from "effect/process";
16
17import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
18import * as ServerConfig from "../../config.ts";
19import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
20import * as ServerSettings from "../../serverSettings.ts";
21import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
22import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver";
23import * as OpenCodeRuntime from "../opencodeRuntime.ts";
24import {
25 OPENCODE_2_RESPONSES,
26 OPENCODE_2_WORKSPACE_RESPONSES,
27 replayOpenCodeServer,
28} from "../testFixtures/opencodeProbeResponses.ts";
29import { OpenCodeDriver, openCodeUpdateFor } from "./OpenCodeDriver.ts";
30import * as ProviderHostLive from "../ProviderHostLive.ts";
31
32const serverStarts: Array<string> = [];
33const reachedServer = (operation: string) =>
34 Effect.sync(() => serverStarts.push(operation)).pipe(
35 Effect.andThen(
36 Effect.fail(
37 new OpenCodeRuntime.OpenCodeRuntimeError({
38 operation,
39 detail: "reached a 1.x server path",
40 }),
41 ),
42 ),
43 );
44// Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused.
45const openCode2Runtime = {
46 runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }),
47 startOpenCodeServerProcess: () => reachedServer("start"),
48 connectToOpenCodeServer: () => reachedServer("connect"),
49} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape;
50
51const layerDeps = Layer.mergeAll(
52 ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-" }),
53 IdAllocator.layer,
54 ServerSettings.layerTest(),
55 Layer.mock(BackgroundPolicy.BackgroundPolicy)({}),
56 Layer.succeed(
57 ProviderEventLoggers.ProviderEventLoggers,
58 ProviderEventLoggers.NoOpProviderEventLoggers,
59 ),
60 Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime),
61).pipe(Layer.provideMerge(NodeServices.layer));
62const layer = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps));
63
64const create = (config: Partial<OpenCodeSettings>, http: HttpClient.HttpClient) =>
65 OpenCodeDriver.create({
66 instanceId: ProviderInstanceId.make("opencode-test"),
67 displayName: undefined,
68 environment: [],
69 enabled: true,
70 config: { ...OpenCodeDriver.defaultConfig(), ...config },
71 }).pipe(Effect.provideService(HttpClient.HttpClient, http));
72
73const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP"));
74
75it.layer(layer)("OpenCodeDriver runtime selection", (it) => {
76 it.effect("never starts a 1.x server for an OpenCode 2 instance", () =>
77 Effect.gen(function* () {
78 serverStarts.length = 0;
79 const instance = yield* create({}, noHttp);
80 // Text generation goes to the instance's 2.x server, which this test refuses to start.
81 yield* Effect.flip(
82 instance.textGeneration.generateThreadTitle({
83 cwd: process.cwd(),
84 message: "hello",
85 modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" },
86 }),
87 );
88 assert.deepStrictEqual(serverStarts, ["start"]);
89 }).pipe(Effect.scoped),
90 );
91
92 it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () =>
93 Effect.gen(function* () {
94 serverStarts.length = 0;
95 const requested: Array<string> = [];
96 const server = replayOpenCodeServer(
97 { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES },
98 "secret",
99 requested,
100 );
101 const instance = yield* create(
102 { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" },
103 server,
104 );
105
106 const workspace = yield* instance.snapshotForCwd!("/work");
107 assert.includeMembers(
108 workspace.skills.map((skill) => skill.name),
109 ["plum", "opencode"],
110 );
111 assert.deepStrictEqual(
112 workspace.slashCommands.map((command) => command.name),
113 ["compact", "init", "review", "bee"],
114 );
115 assert.include(requested, "/api/skill");
116 assert.deepStrictEqual(serverStarts, []);
117 }).pipe(Effect.scoped),
118 );
119
120 it.effect("answers capability reads without waiting on an unreachable server", () =>
121 Effect.gen(function* () {
122 const hang = HttpClient.make(() => Effect.never);
123 const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang);
124
125 // No probe has succeeded yet, and the server never answers: the 1.x default applies.
126 const capabilities = yield* instance.orchestrationAdapter
127 .getCapabilities()
128 .pipe(Effect.forkChild);
129 yield* Effect.yieldNow;
130 assert.isDefined(capabilities.pollUnsafe());
131 yield* Fiber.join(capabilities);
132 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
133 );
134});
135
136// npm links `<prefix>/bin/opencode` into the package that installed it:
137// `opencode-ai` for 1.x and `@opencode/cli` (whose bin is `bin/opencode.exe`) for 2.x.
138const npmGlobalInstall = (prefix: string, packagePath: ReadonlyArray<string>, bin: string) => {
139 const target = NodePath.join(prefix, "lib", "node_modules", ...packagePath, "bin", bin);
140 NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true });
141 NodeFS.writeFileSync(target, "#!/bin/sh\n");
142 NodeFS.chmodSync(target, 0o755);
143 const link = NodePath.join(prefix, "bin", "opencode");
144 NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true });
145 NodeFS.symlinkSync(target, link);
146 return link;
147};
148const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) =>
149 ProviderMaintenance.resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation), {
150 binaryPath,
151 env: { PATH: "" },
152 }).pipe(
153 Effect.provideService(
154 ChildProcessSpawner.ChildProcessSpawner,
155 ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")),
156 ),
157 Effect.provide(NodeServices.layer),
158 );
159
160it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
161 "updates an npm-global OpenCode 2 install as @opencode/cli and a 1.x one as opencode-ai",
162 () =>
163 Effect.gen(function* () {
164 const root = NodeFS.realpathSync(
165 NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-update-")),
166 );
167 const v2Prefix = NodePath.join(root, "v2");
168 const v1Prefix = NodePath.join(root, "v1");
169 const v2 = yield* resolveUpdate(
170 "v2",
171 npmGlobalInstall(v2Prefix, ["@opencode", "cli"], "opencode.exe"),
172 );
173 assert.deepStrictEqual(v2.update?.args, [
174 "install",
175 "-g",
176 "--prefix",
177 v2Prefix,
178 "--allow-scripts=@opencode/cli",
179 "@opencode/cli@latest",
180 ]);
181 const v1 = yield* resolveUpdate(
182 "v1",
183 npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"),
184 );
185 assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest");
186 // A 1.x install is never offered 2.x's package, or the reverse.
187 assert.isNull(
188 (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update,
189 );
190 assert.isNull(
191 (yield* resolveUpdate("v1", NodePath.join(v2Prefix, "bin", "opencode"))).update,
192 );
193 }),
194);
195
196// The update the driver offers follows the version the binary reports now.
197const versionOutput: { current: string | undefined } = { current: undefined };
198const versionProbes: Array<string> = [];
199const changingRuntime = {
200 runOpenCodeCommand: ({ binaryPath }: { readonly binaryPath: string }) =>
201 Effect.sync(() => versionProbes.push(binaryPath)).pipe(
202 Effect.andThen(
203 versionOutput.current === undefined
204 ? Effect.fail(
205 new OpenCodeRuntime.OpenCodeRuntimeError({
206 operation: "version",
207 detail: "no version",
208 }),
209 )
210 : Effect.succeed({ stdout: versionOutput.current, stderr: "", code: 0 }),
211 ),
212 ),
213 startOpenCodeServerProcess: () => reachedServer("start"),
214 connectToOpenCodeServer: () => reachedServer("connect"),
215} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape;
216const layerUpdateDeps = Layer.mergeAll(
217 ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-update-" }),
218 IdAllocator.layer,
219 ServerSettings.layerTest(),
220 Layer.mock(BackgroundPolicy.BackgroundPolicy)({}),
221 Layer.succeed(
222 ProviderEventLoggers.ProviderEventLoggers,
223 ProviderEventLoggers.NoOpProviderEventLoggers,
224 ),
225 Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime),
226).pipe(Layer.provideMerge(NodeServices.layer));
227const layerUpdate = ProviderHostLive.layer.pipe(Layer.provideMerge(layerUpdateDeps));
228
229it.layer(layerUpdate)("OpenCodeDriver updates", (it) => {
230 it.effect("never runs the binary for a disabled instance's update check", () =>
231 Effect.gen(function* () {
232 versionProbes.length = 0;
233 versionOutput.current = "opencode v2.0.18\n";
234 const instance = yield* OpenCodeDriver.create({
235 instanceId: ProviderInstanceId.make("opencode-disabled"),
236 displayName: undefined,
237 environment: [],
238 enabled: false,
239 config: { ...OpenCodeDriver.defaultConfig(), binaryPath: "opencode" },
240 }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp));
241 const maintenance = yield* instance.snapshot.resolveMaintenance();
242 yield* instance.snapshot.resolveMaintenance({ fresh: true });
243 assert.isNull(maintenance.update);
244 assert.deepStrictEqual(versionProbes, []);
245 }).pipe(Effect.scoped),
246 );
247
248 it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
249 "offers no package update for an unknown version and follows a changed one on a fresh read",
250 () =>
251 Effect.gen(function* () {
252 const root = NodeFS.realpathSync(
253 NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-driver-update-")),
254 );
255 const binaryPath = npmGlobalInstall(NodePath.join(root, "v1"), ["opencode-ai"], "opencode");
256 const instance = yield* OpenCodeDriver.create({
257 instanceId: ProviderInstanceId.make("opencode-update"),
258 displayName: undefined,
259 environment: [],
260 enabled: true,
261 config: { ...OpenCodeDriver.defaultConfig(), binaryPath },
262 }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp));
263
264 // The version could not be read: which package owns the binary is unknown.
265 versionOutput.current = undefined;
266 const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true });
267 assert.isNull(unknown.update);
268 assert.isNull(unknown.packageName);
269
270 versionOutput.current = "1.18.32\n";
271 const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true });
272 assert.strictEqual(v1.packageName, "opencode-ai");
273
274 // The same path now reports 2.x (reinstalled in place): a fresh read re-probes
275 // and never offers 1.x's package for it.
276 versionOutput.current = "opencode v2.0.18\n";
277 const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true });
278 assert.strictEqual(v2.packageName, "@opencode/cli");
279 assert.isNull(v2.update);
280 }).pipe(Effect.scoped),
281 );
282});
apps/server/src/provider/Drivers/OpenCodeDriver.ts deleted-542
...@@ -1,542 +0,0 @@
1/**
2 * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime.
3 *
4 * Mirrors the Codex / Claude drivers: a plain value whose `create()`
5 * bundles `snapshot` / `adapter` / `textGeneration` closures over the
6 * per-instance `OpenCodeSettings`.
7 *
8 * Two instances with different `serverUrl`s therefore talk to independent
9 * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation
10 * shares spin up their own scoped child processes, and those child
11 * processes are released when the registry scope closes.
12 *
13 * @module provider/Drivers/OpenCodeDriver
14 */
15import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts";
16import * as Crypto from "effect/Crypto";
17import * as Deferred from "effect/Deferred";
18import * as Effect from "effect/Effect";
19import * as Option from "effect/Option";
20import * as FileSystem from "effect/FileSystem";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
24import { HttpClient } from "effect/http";
25import { ChildProcessSpawner } from "effect/process";
26
27import * as OpenCode2TextGeneration from "../../textGeneration/OpenCode2TextGeneration.ts";
28import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";
29import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
30import * as ServerConfig from "../../config.ts";
31import * as OpenCodeAdapterV2 from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts";
32import * as OpenCode2AdapterV2 from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts";
33import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter";
34import type { TextGeneration } from "../../textGeneration/TextGeneration.ts";
35import { ProviderDriverError } from "../Errors.ts";
36import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts";
37import {
38 checkOpenCodeProviderStatus,
39 loadOpenCode2Workspace,
40 makeOpenCode2ModelLoader,
41 makePendingOpenCodeProvider,
42 openCode2CommandsToServerProviderSlashCommands,
43 openCode2SkillsToServerProviderSkills,
44 openCodeSkillsToServerProviderSkills,
45 openCodeCommandsToServerProviderSlashCommands,
46} from "../OpenCodeProvider.ts";
47import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
48import * as OpenCodeRuntime from "../opencodeRuntime.ts";
49import {
50 makeOpenCodeRuntimeProbe,
51 probeOpenCodeRuntime,
52 type ProbedOpenCode,
53} from "../opencodeVersionProbe.ts";
54import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
55import * as OpenCode2Client from "../opencode2/OpenCode2Client.ts";
56import * as OpenCode2Server from "../opencode2/OpenCode2Server.ts";
57import {
58 defaultProviderContinuationIdentity,
59 type ProviderDriver,
60 type ProviderInstance,
61} from "@t3tools/provider-core/server/driver";
62import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
63import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
64import {
65 enrichProviderSnapshotWithVersionAdvisory,
66 makeCachedProviderMaintenanceResolution,
67 makeManualOnlyProviderMaintenanceCapabilities,
68 makePackageManagedProviderMaintenanceResolver,
69 normalizeCommandPath,
70 resolveProviderMaintenanceCapabilitiesEffect,
71} from "@t3tools/provider-core/server/maintenanceResolver";
72import {
73 haveProviderSnapshotSettingsChanged,
74 makeProviderSnapshotSettingsSource,
75 type ProviderSnapshotSettings,
76} from "@t3tools/provider-core/server/snapshotSettings";
77const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
78
79const DRIVER_KIND = ProviderDriverKind.make("opencode");
80
81function isOpenCodeNativeCommandPath(commandPath: string): boolean {
82 const normalized = normalizeCommandPath(commandPath);
83 return (
84 normalized.endsWith("/.opencode/bin/opencode") ||
85 normalized.endsWith("/.opencode/bin/opencode.exe")
86 );
87}
88
89/**
90 * OpenCode 1.x ships as `opencode-ai` and 2.x as `@opencode/cli`. An install is
91 * only ever updated within its own package: T3 never moves a 1.x install onto
92 * 2.x or back, since 2.x converts the shared database in place.
93 */
94export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) =>
95 makePackageManagedProviderMaintenanceResolver({
96 provider: DRIVER_KIND,
97 npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai",
98 nativeUpdate: {
99 args: ["upgrade"],
100 isCommandPath: isOpenCodeNativeCommandPath,
101 },
102 });
103
104type OpenCodeRuntimeProbe = Effect.Success<
105 ReturnType<typeof makeOpenCodeRuntimeProbe<OpenCodeRuntime.OpenCodeRuntimeError>>
106>;
107
108/** Runs `v2` for a 2.x instance and `v1` otherwise; a failed probe keeps 1.x, whose checks report it. */
109function byOpenCodeRuntime<A, E, R, PE>(
110 probed: Effect.Effect<ProbedOpenCode | undefined, PE>,
111 paths: { readonly v1: Effect.Effect<A, E, R>; readonly v2: Effect.Effect<A, E, R> },
112): Effect.Effect<A, E, R> {
113 return probed.pipe(
114 Effect.orElseSucceed(() => undefined),
115 Effect.flatMap((result) => (result?.generation === "v2" ? paths.v2 : paths.v1)),
116 );
117}
118
119/**
120 * Routes each adapter call to the runtime the instance's probe detected. Capability and selection
121 * reads are hot, so they use the last successful probe (1.x before one lands) and never wait on a
122 * slow server. Opening a session waits for a probe, so each server is spoken to in its own protocol.
123 */
124function selectOpenCodeRuntimeAdapter(input: {
125 readonly probe: OpenCodeRuntimeProbe;
126 readonly v1: ProviderAdapterV2Shape;
127 readonly v2: ProviderAdapterV2Shape;
128}): ProviderAdapterV2Shape {
129 const pick = <PE>(probed: Effect.Effect<ProbedOpenCode | undefined, PE>) =>
130 byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) });
131 const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined));
132 return {
133 instanceId: input.v1.instanceId,
134 driver: DRIVER_KIND,
135 getCapabilities: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()),
136 planSelectionTransition: (transition) =>
137 Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)),
138 openSession: (session) =>
139 Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)),
140 };
141}
142
143/** Text generation runs on the server the instance's probe detected, each in its own protocol. */
144function selectOpenCodeRuntimeTextGeneration(
145 probe: OpenCodeRuntimeProbe,
146 v1: TextGeneration["Service"],
147 v2: TextGeneration["Service"],
148): TextGeneration["Service"] {
149 return {
150 generateCommitMessage: (input) =>
151 byOpenCodeRuntime(probe.get, {
152 v1: v1.generateCommitMessage(input),
153 v2: v2.generateCommitMessage(input),
154 }),
155 generatePrContent: (input) =>
156 byOpenCodeRuntime(probe.get, {
157 v1: v1.generatePrContent(input),
158 v2: v2.generatePrContent(input),
159 }),
160 generateBranchName: (input) =>
161 byOpenCodeRuntime(probe.get, {
162 v1: v1.generateBranchName(input),
163 v2: v2.generateBranchName(input),
164 }),
165 generateThreadTitle: (input) =>
166 byOpenCodeRuntime(probe.get, {
167 v1: v1.generateThreadTitle(input),
168 v2: v2.generateThreadTitle(input),
169 }),
170 };
171}
172
173export type OpenCodeDriverEnv =
174 | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv
175 | ProviderHost
176 | ChildProcessSpawner.ChildProcessSpawner
177 | Crypto.Crypto
178 | FileSystem.FileSystem
179 | HttpClient.HttpClient
180 | OpenCodeRuntime.OpenCodeRuntime
181 | Path.Path
182 | ServerConfig.ServerConfig;
183
184export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> = {
185 driverKind: DRIVER_KIND,
186 metadata: {
187 displayName: "OpenCode",
188 supportsMultipleInstances: true,
189 },
190 configSchema: OpenCodeSettings,
191 defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}),
192 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
193 Effect.gen(function* () {
194 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
195 const fileSystem = yield* FileSystem.FileSystem;
196 const pathService = yield* Path.Path;
197 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
198 const serverConfig = yield* ServerConfig.ServerConfig;
199 const httpClient = yield* HttpClient.HttpClient;
200 const crypto = yield* Crypto.Crypto;
201 const host = yield* ProviderHost;
202 const processEnv = mergeProviderInstanceEnvironment(environment);
203 const continuationIdentity = defaultProviderContinuationIdentity({
204 driverKind: DRIVER_KIND,
205 instanceId,
206 });
207 const stampIdentity = withInstanceIdentity({
208 instanceId,
209 driverKind: DRIVER_KIND,
210 displayName,
211 accentColor,
212 continuationGroupKey: continuationIdentity.continuationKey,
213 });
214 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;
215 const runtimeProbe = yield* makeOpenCodeRuntimeProbe(
216 probeOpenCodeRuntime(effectiveConfig, processEnv).pipe(
217 Effect.provideService(HttpClient.HttpClient, httpClient),
218 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
219 ),
220 );
221 // Updates follow the installed package, which the version probe names. An
222 // unknown version offers no package update, since a guess could move a
223 // 2.x install onto 1.x's package or the reverse. A disabled instance never
224 // runs its binary, so it has no version and offers no update.
225 const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({
226 provider: DRIVER_KIND,
227 packageName: null,
228 });
229 const maintenanceFor = (probed: typeof runtimeProbe.get) =>
230 probed.pipe(
231 Effect.map((result) => result.generation),
232 Effect.option,
233 Effect.flatMap((generation) =>
234 Option.isNone(generation)
235 ? Effect.succeed(noUpdate)
236 : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), {
237 binaryPath: effectiveConfig.binaryPath,
238 env: processEnv,
239 }),
240 ),
241 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
242 Effect.provideService(FileSystem.FileSystem, fileSystem),
243 Effect.provideService(Path.Path, pathService),
244 );
245 const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution(
246 maintenanceFor(runtimeProbe.get),
247 );
248 // A fresh read (an update about to run, or a manual refresh) re-probes, so
249 // a binary replaced by the other major version gets its own package.
250 const resolveMaintenance = (options?: { readonly fresh?: boolean }) =>
251 !effectiveConfig.enabled
252 ? Effect.succeed(noUpdate)
253 : options?.fresh === true
254 ? runtimeProbe.refresh.pipe(
255 Effect.ignore,
256 Effect.andThen(cachedMaintenance({ fresh: true })),
257 )
258 : cachedMaintenance();
259 const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({
260 instanceId,
261 displayName,
262 accentColor,
263 environment,
264 enabled,
265 config,
266 }).pipe(
267 Effect.mapError(
268 (cause) =>
269 new ProviderDriverError({
270 driver: DRIVER_KIND,
271 instanceId,
272 detail: "Failed to build OpenCode orchestration adapter.",
273 cause,
274 }),
275 ),
276 );
277 // One OpenCode 2 server per instance, spawned on first use or reached at `serverUrl`.
278 const openCode2Server = yield* OpenCode2Server.make({
279 binaryPath: effectiveConfig.binaryPath,
280 serverUrl: effectiveConfig.serverUrl,
281 serverPassword: effectiveConfig.serverPassword,
282 directory: serverConfig.cwd,
283 environment: processEnv,
284 }).pipe(
285 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
286 Effect.provideService(
287 OpenCode2Client.OpenCode2Client,
288 yield* OpenCode2Client.make.pipe(
289 Effect.provideService(HttpClient.HttpClient, httpClient),
290 ),
291 ),
292 );
293 const orchestrationAdapter = selectOpenCodeRuntimeAdapter({
294 probe: runtimeProbe,
295 v1: openCodeV1Adapter,
296 v2: yield* OpenCode2AdapterV2.make(instanceId).pipe(
297 Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
298 ),
299 });
300 const loadOpenCode2Models = yield* makeOpenCode2ModelLoader(
301 openCode2Server.withConnection((connection) =>
302 connection.client.model.list({ location: { directory: serverConfig.cwd } }).pipe(
303 Effect.map((models) => models.data),
304 Effect.mapError(
305 (cause) =>
306 new OpenCodeRuntime.OpenCodeRuntimeError({
307 operation: "model.list",
308 detail: "The OpenCode server could not list its models.",
309 cause,
310 }),
311 ),
312 ),
313 ),
314 );
315 // A 2.x server lists skills and commands per directory, so one server
316 // answers every workspace. Its event stream says when a directory it had
317 // not served yet finished scanning.
318 const listOpenCode2Workspace = (cwd: string) =>
319 openCode2Server.withConnection(({ client, events }) =>
320 Effect.gen(function* () {
321 const location = { directory: cwd };
322 const scanned = yield* Deferred.make<void>();
323 const pending = new Set(["command.updated", "skill.updated"]);
324 const stream = yield* events.pipe(Effect.option);
325 if (stream._tag === "Some") {
326 yield* stream.value.pipe(
327 Stream.runForEach((event) =>
328 "location" in event &&
329 event.location?.directory === cwd &&
330 pending.delete(event.type) &&
331 pending.size === 0
332 ? Deferred.succeed(scanned, undefined)
333 : Effect.void,
334 ),
335 Effect.ignore,
336 Effect.forkScoped,
337 );
338 }
339 return yield* loadOpenCode2Workspace(
340 Effect.all(
341 {
342 skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)),
343 commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)),
344 },
345 { concurrency: "unbounded" },
346 ),
347 Deferred.await(scanned),
348 );
349 }).pipe(Effect.scoped),
350 );
351 const serverOwner = yield* OpenCodeServerOwner.make({
352 binaryPath: effectiveConfig.binaryPath,
353 directory: serverConfig.cwd,
354 ...(effectiveConfig.serverPassword
355 ? { serverPassword: effectiveConfig.serverPassword }
356 : {}),
357 environment: processEnv,
358 });
359 const textGeneration = selectOpenCodeRuntimeTextGeneration(
360 runtimeProbe,
361 yield* makeOpenCodeTextGeneration(effectiveConfig).pipe(
362 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
363 ),
364 yield* OpenCode2TextGeneration.make().pipe(
365 Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
366 ),
367 );
368
369 const checkProvider = Effect.all(
370 {
371 provider: checkOpenCodeProviderStatus(
372 effectiveConfig,
373 serverConfig.cwd,
374 runtimeProbe.refresh,
375 loadOpenCode2Models,
376 ),
377 usageLimits: readOpenCodeGoUsageLimits({
378 enabled: effectiveConfig.enabled,
379 serverUrl: effectiveConfig.serverUrl,
380 environment: processEnv,
381 }),
382 },
383 { concurrency: "unbounded" },
384 ).pipe(
385 Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })),
386 Effect.map(stampIdentity),
387 Effect.provideService(FileSystem.FileSystem, fileSystem),
388 Effect.provideService(Path.Path, pathService),
389 Effect.provideService(HttpClient.HttpClient, httpClient),
390 Effect.provideService(Crypto.Crypto, crypto),
391 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
392 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
393 );
394 // NOTE: the local branch intentionally uses the shared SDK server
395 // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes
396 // its full JSON inventory to stdout, but the Bun-compiled binary does
397 // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the
398 // piped output arrives truncated and unparseable — which degrades to an
399 // empty skill list and poisons the workspace snapshot the `$` picker
400 // reads. The SDK `app.skills` endpoint honors the per-request directory
401 // and returns complete results regardless of size.
402 const loadWorkspaceInventory = (
403 client: Parameters<typeof OpenCodeRuntime.loadOpenCodeCommands>[0],
404 ) =>
405 Effect.all(
406 {
407 skills: openCodeRuntime.loadOpenCodeSkills(client),
408 commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe(
409 Effect.timeout("10 seconds"),
410 Effect.orElseSucceed(() => []),
411 ),
412 },
413 { concurrency: "unbounded" },
414 );
415 const loadWorkspaceForCwd = (cwd: string) =>
416 effectiveConfig.serverUrl.trim().length > 0
417 ? Effect.scoped(
418 Effect.gen(function* () {
419 const server = yield* openCodeRuntime.connectToOpenCodeServer({
420 binaryPath: effectiveConfig.binaryPath,
421 directory: cwd,
422 serverUrl: effectiveConfig.serverUrl,
423 ...(effectiveConfig.serverPassword
424 ? { serverPassword: effectiveConfig.serverPassword }
425 : {}),
426 environment: processEnv,
427 });
428 const client = openCodeRuntime.createOpenCodeSdkClient({
429 baseUrl: server.url,
430 directory: cwd,
431 ...(effectiveConfig.serverPassword
432 ? { serverPassword: effectiveConfig.serverPassword }
433 : {}),
434 });
435 return yield* loadWorkspaceInventory(client);
436 }),
437 )
438 : serverOwner.withServer((server) =>
439 loadWorkspaceInventory(
440 openCodeRuntime.createOpenCodeSdkClient({
441 baseUrl: server.url,
442 directory: cwd,
443 ...(server.serverPassword !== undefined
444 ? { serverPassword: server.serverPassword }
445 : {}),
446 }),
447 ),
448 );
449
450 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
451 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
452 {
453 resolveMaintenance,
454 getSettings: snapshotSettings.getSettings,
455 streamSettings: snapshotSettings.streamSettings,
456 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
457 checkProviderOnSettingsChange: () => false,
458 refreshOnInterval: false,
459 initialSnapshot: (settings) =>
460 makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
461 checkProvider,
462 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
463 resolveMaintenance().pipe(
464 Effect.flatMap((maintenanceCapabilities) =>
465 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
466 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
467 }),
468 ),
469 Effect.provideService(HttpClient.HttpClient, httpClient),
470 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
471 ),
472 },
473 ).pipe(
474 Effect.mapError(
475 (cause) =>
476 new ProviderDriverError({
477 driver: DRIVER_KIND,
478 instanceId,
479 detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`,
480 cause,
481 }),
482 ),
483 );
484
485 return {
486 instanceId,
487 driverKind: DRIVER_KIND,
488 continuationIdentity,
489 displayName,
490 accentColor,
491 enabled,
492 snapshot,
493 snapshotForCwd: (cwd) =>
494 !effectiveConfig.enabled
495 ? snapshot.getSnapshot
496 : byOpenCodeRuntime(runtimeProbe.get, {
497 v2: Effect.all([
498 snapshot.getSnapshot,
499 listOpenCode2Workspace(cwd).pipe(
500 Effect.timeout("20 seconds"),
501 Effect.mapError(
502 (cause) =>
503 new ProviderDriverError({
504 driver: DRIVER_KIND,
505 instanceId,
506 detail: `Failed to list OpenCode commands and skills for '${cwd}'`,
507 cause,
508 }),
509 ),
510 ),
511 ]).pipe(
512 Effect.map(([machineSnapshot, { skills, commands }]) => ({
513 ...machineSnapshot,
514 skills: openCode2SkillsToServerProviderSkills(skills),
515 slashCommands: openCode2CommandsToServerProviderSlashCommands(commands),
516 })),
517 ),
518 v1: Effect.all([
519 snapshot.getSnapshot,
520 loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")),
521 ]).pipe(
522 Effect.map(([machineSnapshot, { skills, commands }]) => ({
523 ...machineSnapshot,
524 skills: openCodeSkillsToServerProviderSkills(skills),
525 slashCommands: openCodeCommandsToServerProviderSlashCommands(commands),
526 })),
527 Effect.mapError(
528 (cause) =>
529 new ProviderDriverError({
530 driver: DRIVER_KIND,
531 instanceId,
532 detail: `Failed to probe OpenCode commands and skills for '${cwd}'`,
533 cause,
534 }),
535 ),
536 ),
537 }),
538 orchestrationAdapter,
539 textGeneration,
540 } satisfies ProviderInstance;
541 }),
542};
apps/server/src/provider/ModelManifest.ts+1-1
...@@ -33,7 +33,7 @@ import * as Schema from "effect/Schema";...@@ -33,7 +33,7 @@ import * as Schema from "effect/Schema";
33import * as Semaphore from "effect/Semaphore";33import * as Semaphore from "effect/Semaphore";
34import { HttpClient, HttpClientResponse } from "effect/http";34import { HttpClient, HttpClientResponse } from "effect/http";
3535
36import { writeFileStringAtomically } from "../atomicWrite.ts";36import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
37import { ServerConfig } from "../config.ts";37import { ServerConfig } from "../config.ts";
38import * as ServerSettings from "../serverSettings.ts";38import * as ServerSettings from "../serverSettings.ts";
39import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";39import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
apps/server/src/provider/NativeProtocolLogging.ts deleted-40
...@@ -1,40 +0,0 @@
1import { errorTag } from "@t3tools/shared/observability";
2
3export function structuralProtocolMethod(value: string): string {
4 return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown";
5}
6
7/**
8 * Retain only bounded structural metadata for native protocol diagnostics.
9 * Provider payload values may contain prompts, credentials, or arbitrarily
10 * large tool output and must never be copied into the observability stream.
11 */
12export function summarizeNativeProtocolPayload(
13 payload: unknown,
14): Readonly<Record<string, unknown>> {
15 if (payload === null) return { valueType: "null" };
16 if (typeof payload === "string") {
17 return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength };
18 }
19 if (payload instanceof Uint8Array) {
20 return { valueType: "bytes", byteLength: payload.byteLength };
21 }
22 if (Array.isArray(payload)) {
23 return { valueType: "array", itemCount: payload.length };
24 }
25 if (typeof payload !== "object") {
26 return { valueType: typeof payload };
27 }
28
29 try {
30 const record = payload as Record<string, unknown>;
31 return {
32 valueType: "object",
33 fieldCount: Object.keys(record).length,
34 ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}),
35 ...(typeof record.tag === "string" ? { method: structuralProtocolMethod(record.tag) } : {}),
36 };
37 } catch {
38 return { valueType: "object" };
39 }
40}
apps/server/src/provider/OpenCode2Client.events.test.ts created+196
...@@ -0,0 +1,196 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import { TestClock } from "effect/testing";
10import { HttpClient, HttpClientResponse } from "effect/http";
11import { describe } from "vite-plus/test";
12
13import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client";
14
15const RECORDING = new URL(
16 "../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson",
17 import.meta.url,
18);
19const Entry = Schema.Struct({ type: Schema.String, frame: Schema.optional(Schema.Unknown) });
20const decodeEntry = Schema.decodeUnknownSync(Schema.fromJsonString(Entry));
21const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
22
23/** The recorded `simple` turn's events, as a newer server would send them. */
24const newerServerStream = Effect.gen(function* () {
25 const fs = yield* FileSystem.FileSystem;
26 const recorded = (yield* fs.readFileString(RECORDING.pathname))
27 .split("\n")
28 .filter((line) => line.length > 0)
29 .map((line) => decodeEntry(line))
30 .flatMap((entry) => {
31 const frame = entry.frame as { readonly type?: string; readonly event?: unknown } | undefined;
32 return frame?.type === "sdk.event" ? [frame.event as Record<string, unknown>] : [];
33 });
34 const executionStarted = recorded.findIndex(
35 (event) => event.type === "session.execution.started",
36 );
37 const [first] = recorded;
38 return [
39 ...recorded.slice(0, executionStarted),
40 // An event type this build has never heard of.
41 {
42 id: "evt_0000000000newtype",
43 created: 1,
44 type: "session.hologram.projected",
45 data: { beams: 3 },
46 },
47 // A known event carrying a field added after 2.0.18.
48 {
49 ...recorded[executionStarted],
50 data: { ...(recorded[executionStarted]!.data as object), lane: "fast" },
51 },
52 ...recorded.slice(executionStarted + 1),
53 first,
54 ];
55});
56
57const layerServing = (events: ReadonlyArray<unknown>) =>
58 Layer.succeed(
59 HttpClient.HttpClient,
60 HttpClient.make((request) =>
61 Effect.succeed(
62 HttpClientResponse.fromWeb(
63 request,
64 new Response(events.map((event) => `data: ${encodeJson(event)}\n\n`).join(""), {
65 headers: { "content-type": "text/event-stream" },
66 }),
67 ),
68 ),
69 ),
70 );
71
72const connect = Effect.gen(function* () {
73 const opencode = yield* OpenCode2Client.OpenCode2Client;
74 return yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password: "secret" });
75});
76
77/** Sends `events`, then keeps the connection open without another byte, like a frozen server. */
78const layerServingThenSilent = (events: ReadonlyArray<unknown>) =>
79 Layer.succeed(
80 HttpClient.HttpClient,
81 HttpClient.make((request) =>
82 Effect.succeed(
83 HttpClientResponse.fromWeb(
84 request,
85 new Response(
86 new ReadableStream<Uint8Array>({
87 start(controller) {
88 for (const event of events) {
89 controller.enqueue(new TextEncoder().encode(`data: ${encodeJson(event)}\n\n`));
90 }
91 },
92 }),
93 { headers: { "content-type": "text/event-stream" } },
94 ),
95 ),
96 ),
97 ),
98 );
99
100describe("OpenCode2Client events", () => {
101 it.effect("fails a stream that stops sending anything, even heartbeats", () =>
102 Effect.gen(function* () {
103 const { events } = yield* connect.pipe(
104 Effect.provide(
105 OpenCode2Client.layer.pipe(
106 Layer.provide(
107 layerServingThenSilent([{ id: "evt_1", type: "server.connected", data: {} }]),
108 ),
109 ),
110 ),
111 );
112 const drained = yield* (yield* events).pipe(Stream.runDrain, Effect.flip, Effect.forkChild);
113 // Let the first frame arrive and the timer start before the clock moves.
114 for (let i = 0; i < 20; i++) yield* Effect.yieldNow;
115 yield* TestClock.adjust("46 seconds");
116 const failure = yield* Fiber.join(drained);
117 assert.strictEqual(failure._tag, "OpenCode2SilentStreamError");
118 }).pipe(Effect.provide(TestClock.layer())),
119 );
120
121 it.effect(
122 "keeps an execution's end and start it cannot decode, and skips other unknown events",
123 () =>
124 Effect.gen(function* () {
125 const { events } = yield* connect.pipe(
126 Effect.provide(
127 OpenCode2Client.layer.pipe(
128 Layer.provide(
129 layerServing([
130 {
131 id: "evt_1",
132 created: 1,
133 type: "session.hologram.projected",
134 data: { sessionID: "ses_x" },
135 },
136 // A start is kept as a marker; it never ends anything.
137 {
138 id: "evt_0",
139 created: 1,
140 type: "session.execution.started",
141 data: { sessionID: "ses_x", lane: 7 },
142 durable: "not-an-envelope",
143 },
144 {
145 id: "evt_2",
146 created: 1,
147 type: "session.execution.interrupted",
148 data: { sessionID: "ses_x", reason: "budget" },
149 durable: { aggregateID: "ses_x", seq: 1, version: 1 },
150 },
151 ]),
152 ),
153 ),
154 ),
155 );
156 const received = yield* (yield* events).pipe(Stream.runCollect);
157 assert.deepStrictEqual(received, [
158 { type: "unreadable.execution.started", sessionID: "ses_x" },
159 {
160 type: "unreadable.execution.ended",
161 executionType: "session.execution.interrupted",
162 sessionID: "ses_x",
163 },
164 ]);
165 }),
166 );
167
168 it.effect("skips events a newer server adds and still sees the turn end", () =>
169 Effect.gen(function* () {
170 const events = yield* newerServerStream;
171 const { events: subscribe } = yield* connect.pipe(
172 Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))),
173 );
174 const types = yield* (yield* subscribe).pipe(
175 Stream.map((event) => event.type),
176 Stream.runCollect,
177 );
178
179 assert.notInclude(types, "session.hologram.projected");
180 assert.include(types, "session.execution.started");
181 assert.include(types, "session.execution.succeeded");
182 assert.strictEqual(types.length, events.length - 1);
183 }).pipe(Effect.provide(NodeServices.layer)),
184 );
185
186 it.effect("the client's own subscription fails the whole stream on the same events", () =>
187 Effect.gen(function* () {
188 const events = yield* newerServerStream;
189 const { client } = yield* connect.pipe(
190 Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))),
191 );
192 const failure = yield* client.event.subscribe().pipe(Stream.runDrain, Effect.flip);
193 assert.strictEqual(failure._tag, "ClientError");
194 }).pipe(Effect.provide(NodeServices.layer)),
195 );
196});
apps/server/src/provider/OpenCodeProvider.test.ts deleted-873
...@@ -1,873 +0,0 @@
1import * as NodeAssert from "node:assert/strict";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";
11import * as TestClock from "effect/testing/TestClock";
12import { HttpClient, HttpClientResponse } from "effect/http";
13import { beforeEach } from "vite-plus/test";
14
15import { OpenCodeSettings } from "@t3tools/contracts";
16import * as ServerConfig from "../config.ts";
17import * as OpenCodeRuntime from "./opencodeRuntime.ts";
18import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
19import {
20 checkOpenCodeProviderStatus,
21 loadOpenCode2Workspace,
22 makeOpenCode2ModelLoader,
23 type OpenCode2Model,
24 type OpenCode2Workspace,
25 openCode2CommandsToServerProviderSlashCommands,
26 openCode2SkillsToServerProviderSkills,
27 openCodeCommandsToServerProviderSlashCommands,
28} from "./OpenCodeProvider.ts";
29import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts";
30import { probeOpenCodeRuntime } from "./opencodeVersionProbe.ts";
31import {
32 OPENCODE_1_RESPONSES,
33 OPENCODE_2_RESPONSES,
34 replayOpenCodeServer,
35} from "./testFixtures/opencodeProbeResponses.ts";
36const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
37
38const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
39
40it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () =>
41 Effect.gen(function* () {
42 const resetsAt = "2026-09-17T12:00:00.000Z";
43 const limits = yield* readOpenCodeGoUsageLimits({
44 enabled: true,
45 serverUrl: "",
46 environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" },
47 }).pipe(
48 Effect.provideService(
49 FileSystem.FileSystem,
50 FileSystem.makeNoop({
51 readFileString: (path) => {
52 NodeAssert.equal(path, "/instance/data/opencode/auth.json");
53 return Effect.succeed(
54 JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }),
55 );
56 },
57 }),
58 ),
59 Effect.provideService(
60 HttpClient.HttpClient,
61 HttpClient.make((request) => {
62 NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage");
63 NodeAssert.equal(request.headers.authorization, "Bearer instance-key");
64 return Effect.succeed(
65 HttpClientResponse.fromWeb(
66 request,
67 Response.json({
68 usage: {
69 rolling: { percent: 0, resetsAt },
70 weekly: { percent: 10, resetsAt },
71 monthly: { percent: 125, resetsAt },
72 },
73 }),
74 ),
75 );
76 }),
77 ),
78 Effect.provide(NodeServices.layer),
79 );
80 NodeAssert.equal(limits.unavailable, undefined);
81 NodeAssert.equal(
82 limits.credentialFingerprint,
83 // SHA-256 of "opencode-go\0instance-key".
84 "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37",
85 );
86 NodeAssert.deepEqual(
87 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
88 kind,
89 usedPercent,
90 reset,
91 })),
92 [
93 { kind: "session", usedPercent: 0, reset: resetsAt },
94 { kind: "weekly", usedPercent: 10, reset: resetsAt },
95 { kind: "monthly", usedPercent: 100, reset: resetsAt },
96 ],
97 );
98 }),
99);
100
101it.effect("does not read local credentials for external or disabled OpenCode instances", () =>
102 Effect.gen(function* () {
103 for (const settings of [
104 { enabled: true, serverUrl: "https://remote.example" },
105 { enabled: false, serverUrl: "" },
106 ]) {
107 const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe(
108 Effect.provideService(
109 FileSystem.FileSystem,
110 FileSystem.makeNoop({
111 readFileString: () => Effect.die("unexpected credential read"),
112 }),
113 ),
114 Effect.provideService(
115 HttpClient.HttpClient,
116 HttpClient.make(() => Effect.die("unexpected usage request")),
117 ),
118 Effect.provide(NodeServices.layer),
119 );
120 NodeAssert.equal(limits.unavailable?.reason, "unsupported");
121 }
122 }),
123);
124
125it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () =>
126 Effect.gen(function* () {
127 for (const [status, reason] of [
128 [403, "unsupported"],
129 [401, "probeFailed"],
130 [200, "probeFailed"],
131 ] as const) {
132 const limits = yield* readOpenCodeGoUsageLimits({
133 enabled: true,
134 serverUrl: "",
135 environment: {
136 OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}',
137 },
138 }).pipe(
139 Effect.provideService(
140 FileSystem.FileSystem,
141 FileSystem.makeNoop({
142 readFileString: () => Effect.die("inline credentials must bypass disk"),
143 }),
144 ),
145 Effect.provideService(
146 HttpClient.HttpClient,
147 HttpClient.make((request) =>
148 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))),
149 ),
150 ),
151 Effect.provide(NodeServices.layer),
152 );
153 NodeAssert.equal(limits.unavailable?.reason, reason);
154 NodeAssert.equal(limits.credentialFingerprint, undefined);
155 NodeAssert.deepEqual(limits.windows, []);
156 }
157 }),
158);
159
160/**
161 * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag
162 * are deleted. The snapshot-producing logic they wrapped now lives in the
163 * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which
164 * drivers call directly when building their per-instance snapshot
165 * `ServerProviderShape`. Tests mirror that shape: build a settings payload,
166 * invoke the check, assert on the returned snapshot.
167 */
168
169const runtimeMock = {
170 state: {
171 runVersionError: null as Error | null,
172 runVersionPending: false,
173 versionStdout: DEFAULT_VERSION_STDOUT,
174 inventoryError: null as Error | null,
175 connectionError: null as Error | null,
176 inventoryCwd: null as string | null,
177 closeCalls: 0,
178 sdkClientInputs: [] as Array<{
179 baseUrl: string;
180 directory: string;
181 serverPassword?: string;
182 }>,
183 inventory: {
184 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
185 agents: [] as unknown[],
186 skills: [] as unknown[],
187 } as unknown,
188 },
189 reset() {
190 this.state.runVersionError = null;
191 this.state.runVersionPending = false;
192 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
193 this.state.inventoryError = null;
194 this.state.connectionError = null;
195 this.state.inventoryCwd = null;
196 this.state.closeCalls = 0;
197 this.state.sdkClientInputs.length = 0;
198 this.state.inventory = {
199 providerList: { connected: [], all: [] as unknown[], default: {} },
200 agents: [] as unknown[],
201 skills: [] as unknown[],
202 };
203 },
204};
205
206const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
207 startOpenCodeServerProcess: ({ serverPassword, environment }) =>
208 Effect.gen(function* () {
209 yield* Effect.addFinalizer(() =>
210 Effect.sync(() => {
211 runtimeMock.state.closeCalls += 1;
212 }),
213 );
214 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
215 external: false,
216 ...(serverPassword !== undefined ? { serverPassword } : {}),
217 ...(environment !== undefined ? { environment } : {}),
218 });
219 return {
220 url: "http://127.0.0.1:4301",
221 ...(effectiveServerPassword !== undefined
222 ? { serverPassword: effectiveServerPassword }
223 : {}),
224 version: "1.14.19",
225 isRunning: Effect.succeed(true),
226 exitCode: Effect.never,
227 };
228 }),
229 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
230 Effect.gen(function* () {
231 if (runtimeMock.state.connectionError) {
232 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
233 operation: "global.health",
234 detail: runtimeMock.state.connectionError.message,
235 cause: runtimeMock.state.connectionError,
236 });
237 }
238 if (!serverUrl) {
239 yield* Effect.addFinalizer(() =>
240 Effect.sync(() => {
241 runtimeMock.state.closeCalls += 1;
242 }),
243 );
244 }
245 return {
246 url: serverUrl ?? "http://127.0.0.1:4301",
247 ...(serverPassword ? { serverPassword } : {}),
248 version: "1.14.19",
249 exitCode: null,
250 external: Boolean(serverUrl),
251 };
252 }),
253 runOpenCodeCommand: () =>
254 runtimeMock.state.runVersionPending
255 ? Effect.never
256 : runtimeMock.state.runVersionError
257 ? Effect.fail(
258 new OpenCodeRuntime.OpenCodeRuntimeError({
259 operation: "runOpenCodeCommand",
260 detail: runtimeMock.state.runVersionError.message,
261 cause: runtimeMock.state.runVersionError,
262 }),
263 )
264 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
265 createOpenCodeSdkClient: (input) => {
266 runtimeMock.state.sdkClientInputs.push(input);
267 return {} as unknown as ReturnType<
268 OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]
269 >;
270 },
271 loadOpenCodeInventory: () =>
272 runtimeMock.state.inventoryError
273 ? Effect.fail(
274 new OpenCodeRuntime.OpenCodeRuntimeError({
275 operation: "loadOpenCodeInventory",
276 detail: runtimeMock.state.inventoryError.message,
277 cause: runtimeMock.state.inventoryError,
278 }),
279 )
280 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory),
281 loadInventoryFromCli: ({ cwd }) => {
282 runtimeMock.state.inventoryCwd = cwd;
283 return runtimeMock.state.inventoryError
284 ? Effect.fail(
285 new OpenCodeRuntime.OpenCodeRuntimeError({
286 operation: "loadInventoryFromCli",
287 detail: runtimeMock.state.inventoryError.message,
288 cause: runtimeMock.state.inventoryError,
289 }),
290 )
291 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory);
292 },
293 loadOpenCodeSkills: () => Effect.succeed([]),
294 loadSkillsFromCli: () => Effect.succeed([]),
295};
296
297beforeEach(() => {
298 runtimeMock.reset();
299});
300
301it("keeps native and MCP commands while preserving compaction and separate skills", () => {
302 NodeAssert.deepEqual(
303 openCodeCommandsToServerProviderSlashCommands([
304 { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] },
305 { name: "review", source: "command", hints: [] },
306 { name: "compact", source: "command", hints: [] },
307 { name: "skill", source: "skill", hints: [] },
308 { name: "mcp:search", source: "mcp", hints: ["query"] },
309 ]).slice(1),
310 [
311 { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } },
312 { name: "mcp:search", input: { hint: "query" } },
313 ],
314 );
315});
316
317const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(
318 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
319 Layer.provideMerge(NodeServices.layer),
320);
321
322const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings =>
323 decodeOpenCodeSettings({
324 enabled: true,
325 binaryPath: "opencode",
326 serverUrl: "",
327 serverPassword: "",
328 customModels: [],
329 ...overrides,
330 });
331
332const checkProvider = Effect.fn("checkProvider")(function* (
333 settings: OpenCodeSettings,
334 cwd = process.cwd(),
335 environment?: NodeJS.ProcessEnv,
336 server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword),
337 openCode2Models: Effect.Effect<
338 ReadonlyArray<OpenCode2Model>,
339 OpenCodeRuntime.OpenCodeRuntimeError
340 > = Effect.succeed([]),
341) {
342 return yield* Effect.scoped(
343 Effect.gen(function* () {
344 const serverOwner = yield* OpenCodeServerOwner.make({
345 binaryPath: settings.binaryPath,
346 directory: cwd,
347 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
348 ...(environment ? { environment } : {}),
349 });
350 const probe = probeOpenCodeRuntime(settings, environment).pipe(
351 Effect.provideService(HttpClient.HttpClient, server),
352 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble),
353 );
354 return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe(
355 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
356 );
357 }),
358 );
359});
360
361it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
362 it.effect("shows a codex-style missing binary message", () =>
363 Effect.gen(function* () {
364 runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
365 const snapshot = yield* checkProvider(makeOpenCodeSettings());
366
367 NodeAssert.equal(snapshot.status, "error");
368 NodeAssert.equal(snapshot.installed, false);
369 NodeAssert.equal(
370 snapshot.message,
371 "OpenCode CLI (`opencode`) is not installed or not on PATH.",
372 );
373 }),
374 );
375
376 it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () =>
377 Effect.gen(function* () {
378 runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise");
379 const snapshot = yield* checkProvider(makeOpenCodeSettings());
380
381 NodeAssert.equal(snapshot.status, "error");
382 NodeAssert.equal(snapshot.installed, true);
383 NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");
384 }),
385 );
386
387 it.effect("times out a hanging local CLI version probe", () =>
388 Effect.gen(function* () {
389 runtimeMock.state.runVersionPending = true;
390 const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild);
391
392 yield* Effect.yieldNow;
393 yield* TestClock.adjust("4 seconds");
394 const snapshot = yield* Fiber.join(probeFiber);
395
396 NodeAssert.equal(snapshot.status, "error");
397 NodeAssert.equal(snapshot.installed, true);
398 NodeAssert.equal(
399 snapshot.message,
400 "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
401 );
402 }).pipe(Effect.provide(TestClock.layer())),
403 );
404
405 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
406 Effect.gen(function* () {
407 runtimeMock.state.inventory = {
408 providerList: {
409 connected: ["openai"],
410 all: [
411 {
412 id: "openai",
413 name: "OpenAI",
414 models: {
415 "gpt-5.4": {
416 id: "gpt-5.4",
417 name: "GPT-5.4",
418 variants: {
419 none: {},
420 low: {},
421 medium: {},
422 high: {},
423 xhigh: {},
424 },
425 },
426 },
427 },
428 ],
429 default: {},
430 },
431 agents: [
432 { name: "build", hidden: false, mode: "primary" },
433 { name: "plan", hidden: false, mode: "primary" },
434 ],
435 };
436
437 const snapshot = yield* checkProvider(makeOpenCodeSettings());
438 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
439
440 NodeAssert.ok(model);
441 const variantDescriptor = model.capabilities?.optionDescriptors?.find(
442 (descriptor) => descriptor.id === "variant" && descriptor.type === "select",
443 );
444 NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select");
445 NodeAssert.equal(
446 variantDescriptor.options.find((option) => option.isDefault === true)?.id,
447 "medium",
448 );
449 const agentDescriptor = model.capabilities?.optionDescriptors?.find(
450 (descriptor) => descriptor.id === "agent" && descriptor.type === "select",
451 );
452 NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select");
453 NodeAssert.equal(
454 agentDescriptor.options.find((option) => option.isDefault === true)?.id,
455 "build",
456 );
457 }),
458 );
459
460 it.effect("includes OpenCode skills in the provider snapshot", () =>
461 Effect.gen(function* () {
462 runtimeMock.state.inventory = {
463 providerList: {
464 connected: ["openai"],
465 all: [
466 {
467 id: "openai",
468 name: "OpenAI",
469 models: {
470 "gpt-5.4": {
471 id: "gpt-5.4",
472 name: "GPT-5.4",
473 variants: {},
474 },
475 },
476 },
477 ],
478 default: {},
479 },
480 agents: [],
481 skills: [
482 {
483 name: "openclaw-review",
484 description: "Review OpenClaw workflow changes.",
485 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
486 },
487 {
488 name: "openclaw-triage",
489 description: "Triage OpenClaw routing issues.",
490 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
491 },
492 {
493 name: "missing-location",
494 description: "This incomplete SDK row should be skipped.",
495 location: "",
496 },
497 ],
498 };
499
500 const snapshot = yield* checkProvider(makeOpenCodeSettings());
501
502 NodeAssert.deepEqual(
503 snapshot.skills.map((skill) => ({
504 name: skill.name,
505 path: skill.path,
506 enabled: skill.enabled,
507 shortDescription: skill.shortDescription,
508 })),
509 [
510 {
511 name: "openclaw-review",
512 path: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
513 enabled: true,
514 shortDescription: "Review OpenClaw workflow changes.",
515 },
516 {
517 name: "openclaw-triage",
518 path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
519 enabled: true,
520 shortDescription: "Triage OpenClaw routing issues.",
521 },
522 ],
523 );
524 }),
525 );
526
527 it.effect("loads local inventory from a scoped OpenCode server", () =>
528 Effect.gen(function* () {
529 yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" }));
530
531 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
532 {
533 baseUrl: "http://127.0.0.1:4301",
534 directory: process.cwd(),
535 serverPassword: "secret-password",
536 },
537 ]);
538 NodeAssert.equal(runtimeMock.state.closeCalls, 1);
539 NodeAssert.equal(runtimeMock.state.inventoryCwd, null);
540 }),
541 );
542
543 it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () =>
544 Effect.gen(function* () {
545 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
546 const snapshot = yield* checkProvider(
547 makeOpenCodeSettings(),
548 process.cwd(),
549 undefined,
550 undefined,
551 Effect.succeed([
552 { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] },
553 {
554 providerID: "opencode",
555 id: "space-bunny-free",
556 name: "Space Bunny Free",
557 variants: [{ id: "low" }, { id: "medium" }, { id: "high" }],
558 },
559 ]),
560 );
561
562 NodeAssert.equal(snapshot.status, "ready");
563 NodeAssert.equal(snapshot.version, "2.0.18");
564 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
565 "approval-required",
566 "auto-accept-edits",
567 "auto",
568 "full-access",
569 ]);
570 // Plan mode runs as OpenCode's plan agent, and every session can compact.
571 NodeAssert.equal(snapshot.showInteractionModeToggle, true);
572 NodeAssert.deepEqual(
573 snapshot.slashCommands.map((command) => command.name),
574 ["compact"],
575 );
576 NodeAssert.deepEqual(
577 snapshot.models.map((model) => model.slug),
578 ["opencode/big-pickle", "opencode/space-bunny-free"],
579 );
580 const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free")
581 ?.capabilities?.optionDescriptors?.[0];
582 NodeAssert.deepEqual(
583 variant?.type === "select" ? variant.options.map((option) => option.id) : [],
584 ["low", "medium", "high"],
585 );
586 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
587 }),
588 );
589
590 it.effect("reports a failed OpenCode 2 model list without the server's response", () =>
591 Effect.gen(function* () {
592 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
593 const snapshot = yield* checkProvider(
594 makeOpenCodeSettings(),
595 process.cwd(),
596 undefined,
597 undefined,
598 Effect.fail(
599 new OpenCodeRuntime.OpenCodeRuntimeError({
600 operation: "model.list",
601 detail: 'status=500 body={"token":"leaked-response-body"}',
602 }),
603 ),
604 );
605
606 NodeAssert.equal(snapshot.status, "error");
607 NodeAssert.equal(snapshot.message, "OpenCode could not load its model list.");
608 // A failed catalog read still leaves the session able to compact.
609 NodeAssert.deepEqual(
610 snapshot.slashCommands.map((command) => command.name),
611 ["compact"],
612 );
613 }),
614 );
615
616 it.effect("uses an environment-only password for local inventory", () =>
617 Effect.gen(function* () {
618 yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
619 OPENCODE_SERVER_PASSWORD: "environment-password",
620 });
621
622 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
623 {
624 baseUrl: "http://127.0.0.1:4301",
625 directory: process.cwd(),
626 serverPassword: "environment-password",
627 },
628 ]);
629 }),
630 );
631
632 it.effect("uses the settings password when local environment auth differs", () =>
633 Effect.gen(function* () {
634 yield* checkProvider(
635 makeOpenCodeSettings({ serverPassword: "settings-password" }),
636 process.cwd(),
637 { OPENCODE_SERVER_PASSWORD: "environment-password" },
638 );
639
640 NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password");
641 }),
642 );
643
644 it.effect("reports local model inventory failures without treating them as empty", () =>
645 Effect.gen(function* () {
646 runtimeMock.state.inventoryError = new Error("opencode models failed");
647 const snapshot = yield* checkProvider(makeOpenCodeSettings());
648
649 NodeAssert.equal(snapshot.status, "error");
650 NodeAssert.equal(snapshot.installed, true);
651 NodeAssert.equal(snapshot.models.length, 0);
652 NodeAssert.equal(
653 snapshot.message,
654 "Failed to load OpenCode provider inventory: opencode models failed",
655 );
656 }),
657 );
658});
659
660it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => {
661 it.effect("does not send a local environment password to a configured server", () =>
662 Effect.gen(function* () {
663 const snapshot = yield* checkProvider(
664 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
665 process.cwd(),
666 { OPENCODE_SERVER_PASSWORD: "local-secret" },
667 );
668
669 NodeAssert.equal(snapshot.version, "1.14.19");
670 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
671 {
672 baseUrl: "http://127.0.0.1:9999",
673 directory: process.cwd(),
674 },
675 ]);
676 }),
677 );
678
679 it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () =>
680 Effect.gen(function* () {
681 const settings = makeOpenCodeSettings({
682 serverUrl: "http://127.0.0.1:9999",
683 serverPassword: "secret-password",
684 });
685 const snapshot = yield* checkProvider(
686 settings,
687 process.cwd(),
688 undefined,
689 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
690 );
691
692 NodeAssert.equal(snapshot.status, "warning");
693 NodeAssert.equal(snapshot.version, "2.0.18");
694 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
695 "approval-required",
696 "auto-accept-edits",
697 "auto",
698 "full-access",
699 ]);
700 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
701 }),
702 );
703
704 it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () =>
705 Effect.gen(function* () {
706 const snapshot = yield* checkProvider(
707 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }),
708 process.cwd(),
709 undefined,
710 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
711 );
712
713 NodeAssert.equal(snapshot.status, "error");
714 NodeAssert.equal(
715 snapshot.message,
716 "OpenCode server rejected authentication. Check the server URL and password.",
717 );
718 }),
719 );
720
721 it.effect("rejects an unsupported server before loading inventory", () =>
722 Effect.gen(function* () {
723 runtimeMock.state.connectionError = new Error(
724 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
725 );
726 const snapshot = yield* checkProvider(
727 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
728 );
729
730 NodeAssert.equal(snapshot.status, "error");
731 NodeAssert.equal(snapshot.models.length, 0);
732 NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/);
733 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
734 }),
735 );
736
737 it.effect("surfaces a friendly auth error for configured servers", () =>
738 Effect.gen(function* () {
739 runtimeMock.state.connectionError = new Error("401 Unauthorized");
740 const snapshot = yield* checkProvider(
741 makeOpenCodeSettings({
742 serverUrl: "http://127.0.0.1:9999",
743 serverPassword: "secret-password",
744 }),
745 );
746
747 NodeAssert.equal(snapshot.status, "error");
748 NodeAssert.equal(snapshot.installed, true);
749 NodeAssert.equal(
750 snapshot.message,
751 "OpenCode server rejected authentication. Check the server URL and password.",
752 );
753 }),
754 );
755
756 it.effect("surfaces a friendly connection error for configured servers", () =>
757 Effect.gen(function* () {
758 runtimeMock.state.connectionError = new Error(
759 "fetch failed: connect ECONNREFUSED 127.0.0.1:9999",
760 );
761 const snapshot = yield* checkProvider(
762 makeOpenCodeSettings({
763 serverUrl: "http://127.0.0.1:9999",
764 serverPassword: "secret-password",
765 }),
766 );
767
768 NodeAssert.equal(snapshot.status, "error");
769 NodeAssert.equal(snapshot.installed, true);
770 NodeAssert.equal(
771 snapshot.message,
772 "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.",
773 );
774 }),
775 );
776});
777
778const bigPickle: OpenCode2Model = {
779 providerID: "opencode",
780 id: "big-pickle",
781 name: "Big Pickle",
782 variants: [],
783};
784
785it.effect("waits for a fresh OpenCode 2 server to list its models", () =>
786 Effect.gen(function* () {
787 // A fresh server lists nothing until its catalog loads.
788 const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]];
789 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle]));
790 const fiber = yield* load.pipe(Effect.forkChild);
791 yield* TestClock.adjust("1 second");
792 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
793 }).pipe(Effect.provide(TestClock.layer())),
794);
795
796it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () =>
797 Effect.gen(function* () {
798 let listed: ReadonlyArray<OpenCode2Model> = [bigPickle];
799 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed));
800 NodeAssert.deepEqual(yield* load, [bigPickle]);
801 listed = [];
802 const fiber = yield* load.pipe(Effect.forkChild);
803 yield* TestClock.adjust("6 seconds");
804 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
805 }).pipe(Effect.provide(TestClock.layer())),
806);
807
808// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29).
809const builtinCommands = [
810 { name: "init", description: "guided AGENTS.md setup" },
811 { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" },
812];
813const builtinSkills = [
814 { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" },
815 { id: "report", name: "Report", path: "/builtin/report.md" },
816];
817const scanned: OpenCode2Workspace = {
818 commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }],
819 skills: [
820 ...builtinSkills,
821 {
822 id: "greet",
823 name: "greet",
824 path: "/work/.opencode/skills/greet/SKILL.md",
825 description: "Greets the user with the secret word MANGO.",
826 },
827 ],
828};
829
830it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () =>
831 Effect.gen(function* () {
832 // A directory the server has not served yet lists no commands until its scan ends.
833 const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned];
834 const scanEnded = yield* Deferred.make<void>();
835 const load = yield* loadOpenCode2Workspace(
836 Effect.sync(() => reads.shift()!),
837 Deferred.await(scanEnded),
838 ).pipe(Effect.forkChild);
839 yield* Effect.yieldNow;
840 NodeAssert.equal(reads.length, 1);
841 yield* Deferred.succeed(scanEnded, undefined);
842 NodeAssert.deepEqual(yield* Fiber.join(load), scanned);
843 }),
844);
845
846it.effect("takes a served OpenCode 2 directory's first listing as is", () =>
847 Effect.gen(function* () {
848 const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never);
849 NodeAssert.deepEqual(workspace, scanned);
850 }),
851);
852
853it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => {
854 NodeAssert.deepEqual(
855 openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [
856 skill.name,
857 skill.displayName,
858 skill.path,
859 ]),
860 [
861 ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"],
862 ["opencode", "OpenCode", "/builtin/opencode.md"],
863 ["report", "Report", "/builtin/report.md"],
864 ],
865 );
866 NodeAssert.deepEqual(
867 openCode2CommandsToServerProviderSlashCommands([
868 ...scanned.commands,
869 { name: "compact", description: "a workspace command named like T3's own" },
870 ]).map((command) => command.name),
871 ["compact", "init", "review", "hello"],
872 );
873});
apps/server/src/provider/OpenCodeProvider.ts deleted-747
...@@ -1,747 +0,0 @@
1import {
2 type ModelCapabilities,
3 type OpenCodeSettings,
4 type ServerProviderModel,
5 type ServerProviderSkill,
6 type ServerProviderSlashCommand,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Data from "effect/Data";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14import { createModelCapabilities } from "@t3tools/shared/model";
15import { compareSemverVersions } from "@t3tools/shared/semver";
16import {
17 buildServerProvider,
18 COMPACT_SLASH_COMMAND,
19 nonEmptyTrimmed,
20 providerModelsFromSettings,
21 type ServerProviderDraft,
22} from "@t3tools/provider-core/server/snapshotProbe";
23import * as OpenCodeRuntime from "./opencodeRuntime.ts";
24import type { ProbedOpenCode } from "./opencodeVersionProbe.ts";
25import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
26import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
27
28const OPENCODE_PRESENTATION = {
29 displayName: "OpenCode",
30 showInteractionModeToggle: false,
31} as const;
32
33class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
34 readonly cause?: unknown;
35 readonly detail: string;
36}> {}
37
38function normalizeProbeMessage(message: string): string | undefined {
39 const trimmed = message.trim();
40 if (trimmed.length === 0) {
41 return undefined;
42 }
43 if (
44 trimmed === "An error occurred in Effect.tryPromise" ||
45 trimmed === "An error occurred in Effect.try"
46 ) {
47 return undefined;
48 }
49 return trimmed;
50}
51
52function normalizedErrorMessage(cause: unknown): string | undefined {
53 if (cause instanceof OpenCodeProbeError) {
54 return normalizeProbeMessage(cause.detail);
55 }
56
57 if (!(cause instanceof Error)) {
58 return undefined;
59 }
60
61 return normalizeProbeMessage(cause.message);
62}
63
64function formatOpenCodeProbeError(input: {
65 readonly cause: unknown;
66 readonly isExternalServer: boolean;
67 readonly phase: "version" | "inventory";
68 readonly serverUrl: string;
69}): { readonly installed: boolean; readonly message: string } {
70 const detail = normalizedErrorMessage(input.cause);
71 const lower = detail?.toLowerCase() ?? "";
72
73 if (input.isExternalServer) {
74 if (
75 lower.includes("401") ||
76 lower.includes("403") ||
77 lower.includes("unauthorized") ||
78 lower.includes("forbidden")
79 ) {
80 return {
81 installed: true,
82 message: "OpenCode server rejected authentication. Check the server URL and password.",
83 };
84 }
85
86 if (
87 lower.includes("econnrefused") ||
88 lower.includes("enotfound") ||
89 lower.includes("fetch failed") ||
90 lower.includes("networkerror") ||
91 lower.includes("timed out") ||
92 lower.includes("timeout") ||
93 lower.includes("socket hang up")
94 ) {
95 return {
96 installed: true,
97 message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`,
98 };
99 }
100
101 return {
102 installed: true,
103 message: detail ?? "Failed to connect to the configured OpenCode server.",
104 };
105 }
106
107 if (lower.includes("enoent") || lower.includes("notfound")) {
108 return {
109 installed: false,
110 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
111 };
112 }
113
114 if (lower.includes("quarantine")) {
115 return {
116 installed: true,
117 message:
118 "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.",
119 };
120 }
121
122 if (lower.includes("invalid code signature") || lower.includes("corrupted")) {
123 return {
124 installed: true,
125 message:
126 "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.",
127 };
128 }
129
130 const failureLabel =
131 input.phase === "inventory"
132 ? "Failed to load OpenCode provider inventory"
133 : "Failed to execute OpenCode CLI health check";
134 return {
135 installed: true,
136 message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`,
137 };
138}
139
140function titleCaseSlug(value: string): string {
141 const segments: Array<string> = [];
142 for (const segment of value.split(/[-_/]+/)) {
143 if (segment.length > 0) {
144 segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
145 }
146 }
147 return segments.join(" ");
148}
149
150function inferDefaultVariant(
151 providerID: string,
152 variants: ReadonlyArray<string>,
153): string | undefined {
154 if (variants.length === 1) {
155 return variants[0];
156 }
157 if (providerID === "anthropic" || providerID.startsWith("google")) {
158 return variants.includes("high") ? "high" : undefined;
159 }
160 if (providerID === "openai" || providerID === "opencode") {
161 return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined;
162 }
163 return undefined;
164}
165
166function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined {
167 return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined;
168}
169
170const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
171 optionDescriptors: [
172 {
173 id: "variant",
174 label: "Reasoning",
175 type: "select",
176 options: [
177 { id: "low", label: "Low" },
178 { id: "medium", label: "Medium", isDefault: true },
179 { id: "high", label: "High" },
180 { id: "xhigh", label: "Extra High" },
181 ],
182 currentValue: "medium",
183 },
184 {
185 id: "agent",
186 label: "Agent",
187 type: "select",
188 options: [
189 { id: "build", label: "Build", isDefault: true },
190 { id: "plan", label: "Plan" },
191 ],
192 currentValue: "build",
193 },
194 ],
195});
196
197function openCodeCapabilitiesForModel(input: {
198 readonly providerID: string;
199 readonly model: ProviderListResponse["all"][number]["models"][string];
200 readonly agents: ReadonlyArray<Agent>;
201}): ModelCapabilities {
202 const rawVariantValues = Object.keys(input.model.variants ?? {});
203 // When a model advertises no variants, synthesize the standard reasoning
204 // levels so the composer still offers a Reasoning selector (mirrors the
205 // Codex/Grok experience where reasoning is always configurable). The set
206 // covers the common OpenCode variant spectrum; `inferDefaultVariant`
207 // picks the provider-appropriate default (e.g. medium for openai/opencode).
208 const variantValues =
209 rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"];
210 const defaultVariant = inferDefaultVariant(input.providerID, variantValues);
211 const variantOptions = variantValues.map((value) =>
212 defaultVariant === value
213 ? { id: value, label: titleCaseSlug(value), isDefault: true as const }
214 : { id: value, label: titleCaseSlug(value) },
215 );
216 const primaryAgents = input.agents.filter(
217 (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
218 );
219 const defaultAgent = inferDefaultAgent(primaryAgents);
220 const agentOptions = primaryAgents.map((agent) =>
221 defaultAgent === agent.name
222 ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const }
223 : { id: agent.name, label: titleCaseSlug(agent.name) },
224 );
225 return createModelCapabilities({
226 optionDescriptors: [
227 ...(variantOptions.length > 0
228 ? [
229 {
230 id: "variant",
231 label: "Reasoning",
232 type: "select" as const,
233 options: variantOptions,
234 ...(defaultVariant ? { currentValue: defaultVariant } : {}),
235 },
236 ]
237 : []),
238 ...(agentOptions.length > 0
239 ? [
240 {
241 id: "agent",
242 label: "Agent",
243 type: "select" as const,
244 options: agentOptions,
245 ...(defaultAgent ? { currentValue: defaultAgent } : {}),
246 },
247 ]
248 : []),
249 ],
250 });
251}
252
253function flattenOpenCodeModels(
254 input: OpenCodeRuntime.OpenCodeInventory,
255): ReadonlyArray<ServerProviderModel> {
256 const connected = new Set(input.providerList.connected);
257 const models: Array<ServerProviderModel> = [];
258
259 for (const provider of input.providerList.all) {
260 if (!connected.has(provider.id)) {
261 continue;
262 }
263
264 for (const model of Object.values(provider.models)) {
265 const name = nonEmptyTrimmed(model.name);
266 if (!name) {
267 continue;
268 }
269
270 const subProvider = nonEmptyTrimmed(provider.name);
271 models.push({
272 slug: `${provider.id}/${model.id}`,
273 name,
274 ...(subProvider ? { subProvider } : {}),
275 isCustom: false,
276 capabilities: openCodeCapabilitiesForModel({
277 providerID: provider.id,
278 model,
279 agents: input.agents,
280 }),
281 });
282 }
283 }
284
285 return models.toSorted((left, right) => left.name.localeCompare(right.name));
286}
287
288function trimOptional(value: string | null | undefined): string | undefined {
289 const trimmed = value?.trim();
290 return trimmed && trimmed.length > 0 ? trimmed : undefined;
291}
292
293export function openCodeSkillsToServerProviderSkills(
294 input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined,
295): ReadonlyArray<ServerProviderSkill> {
296 const skills: ServerProviderSkill[] = [];
297 for (const skill of input ?? []) {
298 const name = trimOptional(skill.name);
299 const path = trimOptional(skill.location);
300 if (!name || !path) {
301 continue;
302 }
303
304 const description = trimOptional(skill.description);
305 skills.push({
306 name,
307 path,
308 enabled: true,
309 ...(description ? { description, shortDescription: description } : {}),
310 });
311 }
312
313 return skills.toSorted((left, right) => left.name.localeCompare(right.name));
314}
315
316export function openCodeCommandsToServerProviderSlashCommands(
317 input: OpenCodeRuntime.OpenCodeInventory["commands"],
318): ReadonlyArray<ServerProviderSlashCommand> {
319 const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
320 const names = new Set([COMPACT_SLASH_COMMAND.name]);
321 for (const command of input ?? []) {
322 const name = trimOptional(command.name);
323 if (!name || names.has(name) || command.source === "skill") continue;
324 names.add(name);
325 const description = trimOptional(command.description);
326 const hint = trimOptional(command.hints.join(" "));
327 commands.push({
328 name,
329 ...(description ? { description } : {}),
330 ...(hint ? { input: { hint } } : {}),
331 });
332 }
333 return commands;
334}
335
336export const makePendingOpenCodeProvider = (
337 openCodeSettings: OpenCodeSettings,
338): Effect.Effect<ServerProviderDraft> =>
339 Effect.gen(function* () {
340 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
341 const models = providerModelsFromSettings(
342 [],
343 openCodeSettings.customModels,
344 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
345 );
346
347 if (!openCodeSettings.enabled) {
348 return buildServerProvider({
349 presentation: OPENCODE_PRESENTATION,
350 enabled: false,
351 checkedAt,
352 models,
353 probe: {
354 installed: false,
355 version: null,
356 status: "warning",
357 auth: { status: "unknown" },
358 message:
359 openCodeSettings.serverUrl.trim().length > 0
360 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
361 : "OpenCode is disabled in T3 Code settings.",
362 },
363 });
364 }
365
366 return buildServerProvider({
367 presentation: OPENCODE_PRESENTATION,
368 enabled: true,
369 checkedAt,
370 models,
371 probe: {
372 installed: false,
373 version: null,
374 status: "warning",
375 auth: { status: "unknown" },
376 message: "OpenCode provider status has not been checked in this session yet.",
377 },
378 });
379 });
380
381/** One model an OpenCode 2 server lists, as the status check reads it. */
382export interface OpenCode2Model {
383 readonly providerID: string;
384 readonly id: string;
385 readonly name: string;
386 readonly variants: ReadonlyArray<{ readonly id: string }>;
387}
388
389/**
390 * A server loads its catalog lazily and lists nothing for its first few
391 * hundred milliseconds, and a local instance starts a fresh server for each
392 * status check. So an empty list is read again for a while, and the last
393 * non-empty list stands in if the catalog still has not loaded.
394 */
395export const makeOpenCode2ModelLoader = <E>(
396 list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>,
397) =>
398 Effect.sync(() => {
399 let lastLoaded: ReadonlyArray<OpenCode2Model> = [];
400 return list.pipe(
401 Effect.repeat({
402 until: (models) => models.length > 0,
403 schedule: Schedule.spaced("250 millis"),
404 }),
405 Effect.timeoutOption("5 seconds"),
406 Effect.map((loaded) => {
407 if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value;
408 return lastLoaded;
409 }),
410 );
411 });
412
413/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */
414export interface OpenCode2Workspace {
415 readonly skills: ReadonlyArray<{
416 readonly id: string;
417 readonly name: string;
418 readonly path: string;
419 readonly description?: string | undefined;
420 }>;
421 readonly commands: ReadonlyArray<{
422 readonly name: string;
423 readonly description?: string | undefined;
424 }>;
425}
426
427/**
428 * A server scans a directory it has not served yet in the background: its
429 * first read lists no commands at all (not even the built-ins), and
430 * `command.updated` plus `skill.updated` for the directory end the scan. A
431 * directory it already serves lists everything at once. `scanned` must be
432 * listening before `read` runs, since the read is what starts the scan.
433 */
434export const loadOpenCode2Workspace = <E>(
435 read: Effect.Effect<OpenCode2Workspace, E>,
436 scanned: Effect.Effect<void>,
437) =>
438 Effect.gen(function* () {
439 const first = yield* read;
440 if (first.commands.length > 0) return first;
441 yield* scanned.pipe(Effect.timeoutOption("10 seconds"));
442 return yield* read;
443 });
444
445export function openCode2SkillsToServerProviderSkills(
446 skills: OpenCode2Workspace["skills"],
447): ReadonlyArray<ServerProviderSkill> {
448 return skills
449 .map((skill) => {
450 const description = trimOptional(skill.description);
451 return {
452 name: skill.id,
453 path: skill.path,
454 enabled: true,
455 ...(skill.name === skill.id ? {} : { displayName: skill.name }),
456 ...(description ? { description, shortDescription: description } : {}),
457 };
458 })
459 .toSorted((left, right) => left.name.localeCompare(right.name));
460}
461
462export function openCode2CommandsToServerProviderSlashCommands(
463 commands: OpenCode2Workspace["commands"],
464): ReadonlyArray<ServerProviderSlashCommand> {
465 const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
466 const names = new Set([COMPACT_SLASH_COMMAND.name]);
467 for (const command of commands) {
468 const name = trimOptional(command.name);
469 if (!name || names.has(name)) continue;
470 names.add(name);
471 const description = trimOptional(command.description);
472 slashCommands.push({ name, ...(description ? { description } : {}) });
473 }
474 return slashCommands;
475}
476
477/**
478 * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised:
479 * OpenCode has no reviewer that approves routine actions. Plan mode is its
480 * `plan` agent, so the composer's mode toggle drives it.
481 */
482const OPENCODE_2_PRESENTATION = {
483 ...OPENCODE_PRESENTATION,
484 showInteractionModeToggle: true,
485 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"],
486} as const;
487
488function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities {
489 const variants = model.variants.map((variant) => variant.id);
490 const defaultVariant = inferDefaultVariant(model.providerID, variants);
491 return createModelCapabilities({
492 optionDescriptors:
493 variants.length === 0
494 ? []
495 : [
496 {
497 id: "variant",
498 label: "Reasoning",
499 type: "select",
500 options: variants.map((id) => ({
501 id,
502 label: titleCaseSlug(id),
503 ...(id === defaultVariant ? { isDefault: true } : {}),
504 })),
505 ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }),
506 },
507 ],
508 });
509}
510
511const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* (
512 settings: OpenCodeSettings,
513 version: string,
514 checkedAt: string,
515 loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>,
516) {
517 const result = yield* Effect.exit(loadModels);
518 const probe = (status: "ready" | "warning" | "error", message: string) => ({
519 installed: true,
520 version,
521 status,
522 auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) },
523 message,
524 });
525 if (result._tag === "Failure") {
526 // The detail can carry a response body; it stays in the log, not the status.
527 yield* Effect.logWarning("OpenCode 2 model list failed", result.cause);
528 return buildServerProvider({
529 presentation: OPENCODE_2_PRESENTATION,
530 enabled: true,
531 checkedAt,
532 models: providerModelsFromSettings(
533 [],
534 settings.customModels,
535 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
536 ),
537 // Compaction does not depend on the model catalog.
538 slashCommands: [COMPACT_SLASH_COMMAND],
539 probe: probe("error", "OpenCode could not load its model list."),
540 });
541 }
542 const models = providerModelsFromSettings(
543 result.value
544 .map((model) => ({
545 slug: `${model.providerID}/${model.id}`,
546 name: model.name,
547 isCustom: false,
548 capabilities: openCode2ModelCapabilities(model),
549 }))
550 .toSorted((left, right) => left.name.localeCompare(right.name)),
551 settings.customModels,
552 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
553 );
554 return buildServerProvider({
555 presentation: OPENCODE_2_PRESENTATION,
556 enabled: true,
557 checkedAt,
558 models,
559 // Every session can compact; a workspace snapshot adds that directory's commands.
560 slashCommands: [COMPACT_SLASH_COMMAND],
561 probe:
562 result.value.length > 0
563 ? probe(
564 "ready",
565 `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`,
566 )
567 : probe("warning", "OpenCode 2 is running, but it did not list any models yet."),
568 });
569});
570
571/**
572 * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary,
573 * the version endpoints for a configured server. The status check refreshes it, so an in-place
574 * upgrade re-routes the instance.
575 */
576export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (
577 openCodeSettings: OpenCodeSettings,
578 cwd: string,
579 probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>,
580 loadOpenCode2Models: Effect.Effect<
581 ReadonlyArray<OpenCode2Model>,
582 OpenCodeRuntime.OpenCodeRuntimeError
583 >,
584): Effect.fn.Return<
585 ServerProviderDraft,
586 never,
587 OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner
588> {
589 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
590 const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
591 const checkedAt = DateTime.formatIso(yield* DateTime.now);
592 const customModels = openCodeSettings.customModels;
593 const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
594
595 const fallback = (
596 cause: unknown,
597 version: string | null = null,
598 phase: "version" | "inventory" = "version",
599 ) => {
600 const failure = formatOpenCodeProbeError({
601 cause,
602 isExternalServer,
603 phase,
604 serverUrl: openCodeSettings.serverUrl,
605 });
606 return buildServerProvider({
607 presentation: OPENCODE_PRESENTATION,
608 enabled: openCodeSettings.enabled,
609 checkedAt,
610 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
611 probe: {
612 installed: failure.installed,
613 version,
614 status: "error",
615 auth: { status: "unknown" },
616 message: failure.message,
617 },
618 });
619 };
620
621 if (!openCodeSettings.enabled) {
622 return buildServerProvider({
623 presentation: OPENCODE_PRESENTATION,
624 enabled: false,
625 checkedAt,
626 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
627 probe: {
628 installed: false,
629 version: null,
630 status: "warning",
631 auth: { status: "unknown" },
632 message: isExternalServer
633 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
634 : "OpenCode is disabled in T3 Code settings.",
635 },
636 });
637 }
638
639 const probedExit = yield* Effect.exit(
640 probeRuntime.pipe(
641 Effect.mapError(
642 (cause) =>
643 new OpenCodeProbeError({
644 cause,
645 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
646 }),
647 ),
648 ),
649 );
650 if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause));
651 const probed = probedExit.value;
652 if (probed.generation === "v2") {
653 return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models);
654 }
655 let version: string | null = probed.version;
656 if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) {
657 return buildServerProvider({
658 presentation: OPENCODE_PRESENTATION,
659 enabled: openCodeSettings.enabled,
660 checkedAt,
661 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
662 probe: {
663 installed: true,
664 version: probed.version,
665 status: "error",
666 auth: { status: "unknown" },
667 message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
668 },
669 });
670 }
671
672 const loadInventory = (server: {
673 readonly url: string;
674 readonly serverPassword?: string;
675 readonly version: string;
676 }) =>
677 openCodeRuntime
678 .loadOpenCodeInventory(
679 openCodeRuntime.createOpenCodeSdkClient({
680 baseUrl: server.url,
681 directory: cwd,
682 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
683 }),
684 )
685 .pipe(Effect.map((inventory) => ({ inventory, version: server.version })));
686 const inventoryEffect = isExternalServer
687 ? openCodeRuntime
688 .connectToOpenCodeServer({
689 binaryPath: openCodeSettings.binaryPath,
690 directory: cwd,
691 serverUrl: openCodeSettings.serverUrl,
692 ...(openCodeSettings.serverPassword
693 ? { serverPassword: openCodeSettings.serverPassword }
694 : {}),
695 })
696 .pipe(Effect.flatMap(loadInventory), Effect.scoped)
697 : serverOwner.withServer(loadInventory);
698 const inventoryExit = yield* Effect.exit(
699 inventoryEffect.pipe(
700 Effect.mapError(
701 (cause) =>
702 new OpenCodeProbeError({
703 cause,
704 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
705 }),
706 ),
707 ),
708 );
709 if (inventoryExit._tag === "Failure") {
710 return fallback(Cause.squash(inventoryExit.cause), version, "inventory");
711 }
712
713 version = inventoryExit.value.version;
714
715 const models = providerModelsFromSettings(
716 flattenOpenCodeModels(inventoryExit.value.inventory),
717 customModels,
718 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
719 );
720 const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills);
721 const connectedCount = inventoryExit.value.inventory.providerList.connected.length;
722 return buildServerProvider({
723 presentation: OPENCODE_PRESENTATION,
724 enabled: true,
725 checkedAt,
726 models,
727 skills,
728 slashCommands: openCodeCommandsToServerProviderSlashCommands(
729 inventoryExit.value.inventory.commands,
730 ),
731 probe: {
732 installed: true,
733 version,
734 status: connectedCount > 0 ? "ready" : "warning",
735 auth: {
736 status: connectedCount > 0 ? "authenticated" : "unknown",
737 type: "opencode",
738 },
739 message:
740 connectedCount > 0
741 ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.`
742 : isExternalServer
743 ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers."
744 : "OpenCode is available, but it did not report any connected upstream providers.",
745 },
746 });
747});
apps/server/src/provider/OpenCodeServerLedger.test.ts deleted-244
...@@ -1,244 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, expect, it } from "@effect/vitest";
6import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";
10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as Scope from "effect/Scope";
15import { ChildProcessSpawner } from "effect/process";
16
17import * as OpenCodeRuntime from "./opencodeRuntime.ts";
18import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
19
20const SERVE_ARGS = ["serve", "--hostname=127.0.0.1", "--port=4096"];
21
22const groupExists = (pgid: number) => {
23 try {
24 process.kill(-pgid, 0);
25 return true;
26 } catch {
27 return false;
28 }
29};
30
31const killGroup = (pgid: number) =>
32 Effect.sync(() => {
33 if (groupExists(pgid)) process.kill(-pgid, "SIGKILL");
34 });
35
36/**
37 * Spawns a throwaway process group shaped like `opencode serve`: by default a
38 * shell leader whose argv ends in the serve arguments, plus a `sleep` member.
39 */
40const spawnGroup = (args: ReadonlyArray<string>, script = "sleep 600 & wait") =>
41 Effect.acquireRelease(
42 Effect.gen(function* () {
43 const child = NodeChildProcess.spawn("/bin/sh", ["-c", script, ...args], {
44 detached: true,
45 stdio: "ignore",
46 });
47 const exited = yield* Deferred.make<NodeJS.Signals | null>();
48 child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal)));
49 const pid = child.pid;
50 if (pid === undefined) return yield* Effect.die("spawn failed");
51 return { pid, exited: Deferred.await(exited) };
52 }),
53 ({ pid }) => killGroup(pid),
54 );
55
56/** A T3 server that recorded its OpenCode server and then died without cleanup. */
57const recordFromDeadServer = (stateDir: string, server: { readonly pid: number }) =>
58 Effect.gen(function* () {
59 const previousServer = yield* spawnGroup([]);
60 const previousLedger = yield* OpenCodeServerLedger.make({
61 stateDir,
62 ownerPid: previousServer.pid,
63 });
64 // The previous server never gets to forget its entry.
65 yield* Effect.asVoid(previousLedger.track({ pid: server.pid, port: 4096, args: SERVE_ARGS }));
66 process.kill(-previousServer.pid, "SIGKILL");
67 yield* previousServer.exited;
68 });
69
70const hostPlatform = HostProcessPlatform.defaultValue();
71// procps accepts the same `ps` flags as macOS, so Linux also covers the macOS path.
72const observedPlatforms: ReadonlyArray<NodeJS.Platform> =
73 hostPlatform === "linux" ? ["linux", "darwin"] : hostPlatform === "darwin" ? ["darwin"] : [];
74
75describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platform) => {
76 const provideHost = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
77 effect.pipe(
78 Effect.provideService(HostProcessPlatform, platform),
79 Effect.provide(NodeServices.layer),
80 );
81
82 it.live("stops an OpenCode server group left by a server that died", () =>
83 Effect.gen(function* () {
84 const fs = yield* FileSystem.FileSystem;
85 const path = yield* Path.Path;
86 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
87 const orphan = yield* spawnGroup(SERVE_ARGS);
88 yield* recordFromDeadServer(stateDir, orphan);
89 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1);
90
91 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
92 yield* restarted.reapOrphans;
93
94 expect(yield* orphan.exited).toBe("SIGTERM");
95 expect(groupExists(orphan.pid)).toBe(false);
96 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
97 }).pipe(provideHost),
98 );
99
100 it.live("records a group whose spawned wrapper already exited", () =>
101 Effect.gen(function* () {
102 const fs = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
105 // The wrapper starts the serve process in its group and exits.
106 const wrapper = yield* spawnGroup(
107 SERVE_ARGS,
108 '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0',
109 );
110 yield* wrapper.exited;
111 expect(groupExists(wrapper.pid)).toBe(true);
112
113 yield* recordFromDeadServer(stateDir, wrapper);
114 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1);
115
116 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
117 yield* restarted.reapOrphans;
118
119 expect(groupExists(wrapper.pid)).toBe(false);
120 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
121 }).pipe(provideHost),
122 );
123
124 it.live("leaves a recycled pid alone when its start time does not match", () =>
125 Effect.gen(function* () {
126 const fs = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
129 const unrelated = yield* spawnGroup(SERVE_ARGS);
130 yield* recordFromDeadServer(stateDir, unrelated);
131 const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`);
132 // Same pid, a different process: what a recycled pid looks like.
133 const entry = yield* fs.readFileString(entryPath);
134 yield* fs.writeFileString(entryPath, entry.replace(/"startTime":"[^"]+"/, '"startTime":"1"'));
135
136 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
137 yield* restarted.reapOrphans;
138
139 expect(groupExists(unrelated.pid)).toBe(true);
140 expect(yield* fs.exists(entryPath)).toBe(false);
141 }).pipe(provideHost),
142 );
143
144 it.live("leaves a serve process on another port alone", () =>
145 Effect.gen(function* () {
146 const fs = yield* FileSystem.FileSystem;
147 const path = yield* Path.Path;
148 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
149 const unrelated = yield* spawnGroup(SERVE_ARGS);
150 yield* recordFromDeadServer(stateDir, unrelated);
151 const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`);
152 // Same pid and start second, but not the server T3 started.
153 const entry = yield* fs.readFileString(entryPath);
154 yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097"));
155
156 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
157 yield* restarted.reapOrphans;
158
159 expect(groupExists(unrelated.pid)).toBe(true);
160 expect(yield* fs.exists(entryPath)).toBe(false);
161 }).pipe(provideHost),
162 );
163
164 it.live("leaves the servers of a running T3 server alone", () =>
165 Effect.gen(function* () {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
169 const running = yield* OpenCodeServerLedger.make({ stateDir });
170 const server = yield* spawnGroup(SERVE_ARGS);
171 const forget = yield* running.track({ pid: server.pid, port: 4096, args: SERVE_ARGS });
172
173 const other = yield* OpenCodeServerLedger.make({ stateDir });
174 yield* other.reapOrphans;
175 expect(groupExists(server.pid)).toBe(true);
176
177 yield* forget;
178 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
179 }).pipe(provideHost),
180 );
181});
182
183describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => {
184 it.live("stops the group when interrupted while the server is being recorded", () =>
185 Effect.gen(function* () {
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-startup-" });
189 // A wrapper that leaves the serve process running in its group and exits 0,
190 // which the spawner's own cleanup does not stop.
191 const binaryPath = path.join(tempDir, "opencode");
192 yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 600 &\nexit 0\n");
193 yield* fs.chmod(binaryPath, 0o755);
194
195 const spawned = yield* Deferred.make<ChildProcessSpawner.ChildProcessHandle>();
196 const recording = yield* Deferred.make<number>();
197 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
198 const layerRuntime = OpenCodeRuntime.layer.pipe(
199 Layer.provide(
200 Layer.succeed(
201 ChildProcessSpawner.ChildProcessSpawner,
202 ChildProcessSpawner.make((command) =>
203 spawner
204 .spawn(command)
205 .pipe(Effect.tap((handle) => Deferred.succeed(spawned, handle))),
206 ),
207 ),
208 ),
209 Layer.provide(
210 Layer.succeed(
211 OpenCodeServerLedger.OpenCodeServerLedger,
212 OpenCodeServerLedger.OpenCodeServerLedger.of({
213 // Recording stalls until the scope is interrupted, after the wrapper exited.
214 track: (server) =>
215 Effect.gen(function* () {
216 yield* (yield* Deferred.await(spawned)).exitCode.pipe(Effect.ignore);
217 yield* Deferred.succeed(recording, server.pid);
218 return yield* Effect.never;
219 }),
220 }),
221 ),
222 ),
223 );
224
225 const serverScope = yield* Scope.make();
226 const starting = yield* OpenCodeRuntime.OpenCodeRuntime.pipe(
227 Effect.flatMap((runtime) =>
228 runtime.startOpenCodeServerProcess({ binaryPath, directory: tempDir, port: 4096 }),
229 ),
230 Effect.provideService(Scope.Scope, serverScope),
231 Effect.provide(layerRuntime),
232 Effect.forkChild,
233 );
234 const pgid = yield* Deferred.await(recording);
235 yield* Effect.addFinalizer(() => killGroup(pgid));
236 expect(groupExists(pgid)).toBe(true);
237
238 yield* Fiber.interrupt(starting);
239 yield* Scope.close(serverScope, Exit.void);
240
241 expect(groupExists(pgid)).toBe(false);
242 }).pipe(Effect.provide(NodeServices.layer)),
243 );
244});
apps/server/src/provider/OpenCodeServerLedger.ts deleted-339
...@@ -1,339 +0,0 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import { ChildProcess, ChildProcessSpawner } from "effect/process";
10
11import { writeFileStringAtomically } from "../atomicWrite.ts";
12import * as ServerConfig from "../config.ts";
13import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
14
15const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String });
16type ProcessIdentity = typeof ProcessIdentity.Type;
17
18const OpenCodeServerEntry = Schema.Struct({
19 version: Schema.Literal(1),
20 /** The spawned group. Its id is the pid T3 spawned, which also names the entry file. */
21 pgid: Schema.Int,
22 /**
23 * A group member seen at spawn: the spawned process, or a process it left in
24 * the group if it had already exited. Its pid, start time, and command line
25 * must all still match before the group is signalled.
26 */
27 pid: Schema.Int,
28 /** Linux `/proc/<pid>/stat` start ticks, or macOS `ps` lstart (whole seconds). */
29 startTime: Schema.String,
30 command: Schema.String,
31 port: Schema.Int,
32 /** Entries copied along with a state directory are dropped, never acted on. */
33 stateDir: Schema.String,
34 /** The T3 server that spawned it. Entries of a live owner are never touched. */
35 owner: ProcessIdentity,
36});
37type OpenCodeServerEntry = typeof OpenCodeServerEntry.Type;
38const OpenCodeServerEntryJson = Schema.fromJsonString(OpenCodeServerEntry);
39const decodeEntry = Schema.decodeUnknownOption(OpenCodeServerEntryJson);
40const encodeEntry = Schema.encodeEffect(OpenCodeServerEntryJson);
41
42/**
43 * Local `opencode serve` processes run in their own process group so T3 can
44 * stop the whole group, which also means they outlive a T3 server that is
45 * SIGKILLed or crashes. Each spawn is recorded under the state directory and
46 * removed on a graceful stop; the next server start stops whatever a dead
47 * server left behind.
48 */
49export class OpenCodeServerLedger extends Context.Service<
50 OpenCodeServerLedger,
51 {
52 /** Records a spawned server group and returns the effect that forgets it after a graceful stop. */
53 readonly track: (server: {
54 readonly pid: number;
55 readonly port: number;
56 /** The argv after the binary, e.g. `["serve", "--hostname=127.0.0.1", "--port=4096"]`. */
57 readonly args: ReadonlyArray<string>;
58 }) => Effect.Effect<Effect.Effect<void>>;
59 }
60>()("t3/provider/OpenCodeServerLedger") {}
61
62const ENTRY_DIRECTORY = "opencode-servers";
63const ENTRY_FILE = /^\d+\.json$/;
64const STOP_POLL_INTERVAL = "50 millis";
65const STOP_POLL_ATTEMPTS = 40;
66
67interface ObservedProcess {
68 readonly pid: number;
69 readonly pgid: number;
70 readonly startTime: string;
71 readonly command: string;
72 readonly zombie: boolean;
73}
74
75// `ps -o lstart` prints e.g. `Sat Sep 6 20:51:57 2026`; C locale and UTC keep it stable.
76const DARWIN_PS_LINE =
77 /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4})\s+(.*)$/;
78
79const parseDarwinPs = (output: string): ReadonlyArray<ObservedProcess> =>
80 output.split("\n").flatMap((line) => {
81 const match = DARWIN_PS_LINE.exec(line);
82 if (match === null) return [];
83 return [
84 {
85 pid: Number(match[1]),
86 pgid: Number(match[2]),
87 zombie: match[3]!.startsWith("Z"),
88 startTime: match[4]!,
89 command: match[5]!.trimEnd(),
90 },
91 ];
92 });
93
94const signalGroup = (pgid: number, signal: NodeJS.Signals) => {
95 try {
96 signalProcessGroup(pgid, signal);
97 } catch {
98 // The group may already be gone.
99 }
100};
101
102const groupExists = (pgid: number) => {
103 try {
104 signalProcessGroup(pgid, 0);
105 return true;
106 } catch (cause) {
107 return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH";
108 }
109};
110
111/**
112 * Builds a ledger for one state directory. `ownerPid` is the T3 server that
113 * owns the servers it tracks; it defaults to this process.
114 */
115export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: {
116 readonly stateDir: string;
117 readonly ownerPid?: number;
118}) {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
122 const platform = yield* HostProcessPlatform;
123 const directory = path.join(input.stateDir, ENTRY_DIRECTORY);
124 // Windows servers are not detached and are never recorded.
125 const recordable = platform === "linux" || platform === "darwin";
126
127 /** Observes `pid`, or nothing when it is not in `group` (if given). */
128 const observeLinux = (pid: number, group?: number) =>
129 Effect.gen(function* () {
130 const stat = yield* fs.readFileString(`/proc/${pid}/stat`);
131 // After the parenthesized comm: state, ppid, pgrp, session, … starttime (index 19).
132 const fields = stat
133 .slice(stat.lastIndexOf(")") + 2)
134 .trim()
135 .split(/\s+/);
136 const pgid = Number(fields[2]);
137 const startTime = fields[19];
138 if (!Number.isSafeInteger(pgid) || startTime === undefined) return undefined;
139 if (group !== undefined && pgid !== group) return undefined;
140 const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`);
141 return {
142 pid,
143 pgid,
144 startTime,
145 command: cmdline.replace(/\0$/, "").replaceAll("\0", " "),
146 zombie: fields[0] === "Z",
147 } satisfies ObservedProcess;
148 });
149
150 const psDarwin = (selection: ReadonlyArray<string>) =>
151 spawner
152 .string(
153 ChildProcess.make(
154 "/bin/ps",
155 ["-ww", "-o", "pid=,pgid=,stat=,lstart=,args=", ...selection],
156 {
157 env: { LC_ALL: "C", TZ: "UTC" },
158 extendEnv: true,
159 stdin: "ignore",
160 stderr: "ignore",
161 },
162 ),
163 )
164 .pipe(Effect.timeout("5 seconds"), Effect.map(parseDarwinPs));
165
166 const observe = (pid: number): Effect.Effect<ObservedProcess | undefined> => {
167 if (platform === "linux") return observeLinux(pid).pipe(Effect.orElseSucceed(() => undefined));
168 if (platform === "darwin") {
169 return psDarwin(["-p", String(pid)]).pipe(
170 Effect.map((found) => found.find((candidate) => candidate.pid === pid)),
171 Effect.orElseSucceed(() => undefined),
172 );
173 }
174 return Effect.succeed(undefined);
175 };
176
177 // Scans every process, so it is only used when the spawned process is gone.
178 const observeGroup = (pgid: number): Effect.Effect<ReadonlyArray<ObservedProcess>> => {
179 if (platform === "linux") {
180 return fs.readDirectory("/proc").pipe(
181 Effect.flatMap((names) =>
182 Effect.forEach(
183 names.filter((name) => /^\d+$/.test(name)),
184 (name) => observeLinux(Number(name), pgid).pipe(Effect.orElseSucceed(() => undefined)),
185 { concurrency: 16 },
186 ),
187 ),
188 Effect.map((found) => found.filter((candidate) => candidate !== undefined)),
189 Effect.orElseSucceed(() => []),
190 );
191 }
192 if (platform === "darwin") {
193 return psDarwin(["-A"]).pipe(
194 Effect.map((found) => found.filter((candidate) => candidate.pgid === pgid)),
195 Effect.orElseSucceed(() => []),
196 );
197 }
198 return Effect.succeed([]);
199 };
200
201 const isRunning = (identity: ProcessIdentity) =>
202 observe(identity.pid).pipe(
203 Effect.map(
204 (observed) =>
205 observed !== undefined && !observed.zombie && observed.startTime === identity.startTime,
206 ),
207 );
208
209 const ownerPid = input.ownerPid ?? process.pid;
210 const ownerProcess = yield* observe(ownerPid);
211 const owner =
212 ownerProcess === undefined ? undefined : { pid: ownerPid, startTime: ownerProcess.startTime };
213
214 const track: OpenCodeServerLedger["Service"]["track"] = (server) =>
215 Effect.gen(function* () {
216 if (!recordable) return Effect.void;
217 const pgid = server.pid;
218 // The spawned process leads the group. If it already exited (a wrapper
219 // that left the server running), any live member identifies the group:
220 // a group id is not reused while the group has members.
221 const leader = yield* observe(pgid);
222 const members =
223 leader !== undefined && !leader.zombie && leader.pgid === pgid
224 ? [leader]
225 : (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie);
226 const member =
227 members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ??
228 members[0];
229 if (owner === undefined || member === undefined) {
230 if (groupExists(pgid)) {
231 yield* Effect.logWarning(
232 "Could not record an OpenCode server; it will keep running if this server crashes",
233 { pid: pgid, port: server.port },
234 );
235 }
236 return Effect.void;
237 }
238 const entryPath = path.join(directory, `${pgid}.json`);
239 const entry: OpenCodeServerEntry = {
240 version: 1,
241 pgid,
242 pid: member.pid,
243 startTime: member.startTime,
244 command: member.command,
245 port: server.port,
246 stateDir: input.stateDir,
247 owner,
248 };
249 yield* writeFileStringAtomically({
250 filePath: entryPath,
251 contents: yield* encodeEntry(entry),
252 }).pipe(
253 Effect.provideService(FileSystem.FileSystem, fs),
254 Effect.provideService(Path.Path, path),
255 );
256 return fs
257 .remove(entryPath, { force: true })
258 .pipe(
259 Effect.catchCause((cause) =>
260 Effect.logWarning("Could not forget a stopped OpenCode server", { cause }),
261 ),
262 );
263 }).pipe(
264 Effect.catchCause((cause) =>
265 Effect.logWarning("Could not record an OpenCode server process", { cause }).pipe(
266 Effect.as(Effect.void),
267 ),
268 ),
269 );
270
271 // Signals only a group whose recorded member still has the same pid, start
272 // time, group, and full command line, so a recycled pid is never touched.
273 const stopOrphan = (entry: OpenCodeServerEntry) =>
274 Effect.gen(function* () {
275 const observed = yield* observe(entry.pid);
276 if (
277 observed === undefined ||
278 observed.zombie ||
279 observed.startTime !== entry.startTime ||
280 observed.pgid !== entry.pgid ||
281 observed.command !== entry.command
282 ) {
283 return;
284 }
285 yield* Effect.logInfo("Stopping an OpenCode server left by a previous T3 Code server", {
286 pid: entry.pgid,
287 port: entry.port,
288 });
289 signalGroup(entry.pgid, "SIGTERM");
290 for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && groupExists(entry.pgid); attempt++) {
291 yield* Effect.sleep(STOP_POLL_INTERVAL);
292 }
293 // The group never emptied, so its pgid cannot have been reused.
294 if (groupExists(entry.pgid)) signalGroup(entry.pgid, "SIGKILL");
295 });
296
297 const reapEntry = (entryPath: string) =>
298 Effect.gen(function* () {
299 const entry = decodeEntry(yield* fs.readFileString(entryPath));
300 if (Option.isSome(entry) && entry.value.stateDir === input.stateDir) {
301 if (yield* isRunning(entry.value.owner)) return;
302 yield* stopOrphan(entry.value);
303 }
304 yield* fs.remove(entryPath, { force: true });
305 }).pipe(
306 Effect.catchCause((cause) =>
307 Effect.logWarning("Could not clean up a recorded OpenCode server", { entryPath, cause }),
308 ),
309 );
310
311 /** Stops recorded servers whose owning T3 server is gone and drops stale entries. */
312 const reapOrphans = Effect.gen(function* () {
313 const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
314 yield* Effect.forEach(
315 names.filter((name) => ENTRY_FILE.test(name)),
316 (name) => reapEntry(path.join(directory, name)),
317 { concurrency: "unbounded", discard: true },
318 );
319 });
320
321 return { track, reapOrphans };
322});
323
324export const layer = Layer.effect(
325 OpenCodeServerLedger,
326 Effect.gen(function* () {
327 const config = yield* ServerConfig.ServerConfig;
328 const ledger = yield* make({ stateDir: config.stateDir });
329 // Reaping waits for orphans to exit, so it must not hold up startup.
330 yield* ledger.reapOrphans.pipe(Effect.forkScoped);
331 return OpenCodeServerLedger.of({ track: ledger.track });
332 }),
333);
334
335/** Records nothing. For tests that start OpenCode servers without a state directory. */
336export const layerTest = Layer.succeed(
337 OpenCodeServerLedger,
338 OpenCodeServerLedger.of({ track: () => Effect.succeed(Effect.void) }),
339);
apps/server/src/provider/OpenCodeServerOwner.test.ts deleted-289
...@@ -1,289 +0,0 @@
1import { it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";
8import { expect } from "vite-plus/test";
9
10import * as OpenCodeRuntime from "./opencodeRuntime.ts";
11import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
12
13const unusedRuntimeMethod = () =>
14 Effect.fail(
15 new OpenCodeRuntime.OpenCodeRuntimeError({
16 operation: "unused",
17 detail: "unused test method",
18 }),
19 );
20
21const makeRuntime = Effect.gen(function* () {
22 const starts = yield* Ref.make(0);
23 const closes = yield* Ref.make(0);
24 const failNextStart = yield* Ref.make(false);
25 const started = yield* Deferred.make<void>();
26 const closed = yield* Deferred.make<void>();
27 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
28 startOpenCodeServerProcess: () =>
29 Effect.gen(function* () {
30 if (yield* Ref.getAndSet(failNextStart, false)) {
31 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
32 operation: "startOpenCodeServerProcess",
33 detail: "start failed",
34 });
35 }
36 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
37 yield* Deferred.succeed(started, undefined).pipe(Effect.ignore);
38 yield* Effect.addFinalizer(() =>
39 Ref.update(closes, (count) => count + 1).pipe(
40 Effect.andThen(Deferred.succeed(closed, undefined)),
41 Effect.ignore,
42 ),
43 );
44 return {
45 url: `http://127.0.0.1:${index}`,
46 version: "1.14.19",
47 isRunning: Effect.succeed(true),
48 exitCode: Effect.never,
49 };
50 }),
51 connectToOpenCodeServer: unusedRuntimeMethod,
52 runOpenCodeCommand: unusedRuntimeMethod,
53 createOpenCodeSdkClient: () => ({}) as never,
54 loadOpenCodeInventory: unusedRuntimeMethod,
55 loadOpenCodeSkills: unusedRuntimeMethod,
56 loadInventoryFromCli: unusedRuntimeMethod,
57 loadSkillsFromCli: unusedRuntimeMethod,
58 };
59 return { runtime, starts, closes, failNextStart, started, closed };
60});
61
62it.effect("shares concurrent borrowers and closes after the idle TTL", () =>
63 Effect.gen(function* () {
64 const testRuntime = yield* makeRuntime;
65 const release = yield* Deferred.make<void>();
66 yield* Effect.scoped(
67 Effect.gen(function* () {
68 const owner = yield* OpenCodeServerOwner.make({
69 binaryPath: "opencode",
70 directory: "/project",
71 });
72 const useServer = owner.withServer((server) =>
73 Deferred.await(release).pipe(Effect.as(server.url)),
74 );
75 const fibers = yield* Effect.all([useServer, useServer], {
76 concurrency: "unbounded",
77 }).pipe(Effect.forkChild);
78 yield* Deferred.await(testRuntime.started);
79 expect(yield* Ref.get(testRuntime.starts)).toBe(1);
80 yield* Deferred.succeed(release, undefined);
81 expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]);
82 yield* TestClock.adjust(Duration.seconds(31));
83 yield* Deferred.await(testRuntime.closed);
84 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
85 }),
86 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
87 }).pipe(Effect.provide(TestClock.layer())),
88);
89
90it.effect("retries a failed start and closes on owner scope shutdown", () =>
91 Effect.gen(function* () {
92 const testRuntime = yield* makeRuntime;
93 yield* Ref.set(testRuntime.failNextStart, true);
94 yield* Effect.scoped(
95 Effect.gen(function* () {
96 const owner = yield* OpenCodeServerOwner.make({
97 binaryPath: "opencode",
98 directory: "/project",
99 });
100 expect(
101 (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag,
102 ).toBe("Failure");
103 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
104 "http://127.0.0.1:1",
105 );
106 }),
107 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
108 expect(yield* Ref.get(testRuntime.starts)).toBe(1);
109 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
110 }),
111);
112
113it.effect("invalidates an exited process so the next borrower starts a new one", () =>
114 Effect.gen(function* () {
115 const starts = yield* Ref.make(0);
116 const processExits: Array<Deferred.Deferred<number>> = [];
117 const processClosed = yield* Deferred.make<void>();
118 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
119 startOpenCodeServerProcess: () =>
120 Effect.gen(function* () {
121 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
122 const exitCode = yield* Deferred.make<number>();
123 processExits.push(exitCode);
124 yield* Effect.addFinalizer(() =>
125 Deferred.succeed(processClosed, undefined).pipe(Effect.ignore),
126 );
127 return {
128 url: `http://127.0.0.1:${index}`,
129 version: "1.14.19",
130 isRunning: Effect.succeed(true),
131 exitCode: Deferred.await(exitCode),
132 };
133 }),
134 connectToOpenCodeServer: unusedRuntimeMethod,
135 runOpenCodeCommand: unusedRuntimeMethod,
136 createOpenCodeSdkClient: () => ({}) as never,
137 loadOpenCodeInventory: unusedRuntimeMethod,
138 loadOpenCodeSkills: unusedRuntimeMethod,
139 loadInventoryFromCli: unusedRuntimeMethod,
140 loadSkillsFromCli: unusedRuntimeMethod,
141 };
142
143 yield* Effect.scoped(
144 Effect.gen(function* () {
145 const owner = yield* OpenCodeServerOwner.make({
146 binaryPath: "opencode",
147 directory: "/project",
148 });
149 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
150 "http://127.0.0.1:1",
151 );
152 yield* Deferred.succeed(processExits[0]!, 1);
153 yield* Deferred.await(processClosed);
154 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
155 "http://127.0.0.1:2",
156 );
157 }),
158 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
159 expect(yield* Ref.get(starts)).toBe(2);
160 }),
161);
162
163it.effect("replaces a dead cached process before its exit watcher runs", () =>
164 Effect.gen(function* () {
165 const starts = yield* Ref.make(0);
166 const closes = yield* Ref.make(0);
167 const processRunning: Array<Ref.Ref<boolean>> = [];
168 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
169 startOpenCodeServerProcess: () =>
170 Effect.gen(function* () {
171 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
172 const isRunning = yield* Ref.make(true);
173 processRunning.push(isRunning);
174 yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1));
175 return {
176 url: `http://127.0.0.1:${index}`,
177 version: "1.14.19",
178 isRunning: Ref.get(isRunning),
179 exitCode: Effect.never,
180 };
181 }),
182 connectToOpenCodeServer: unusedRuntimeMethod,
183 runOpenCodeCommand: unusedRuntimeMethod,
184 createOpenCodeSdkClient: () => ({}) as never,
185 loadOpenCodeInventory: unusedRuntimeMethod,
186 loadOpenCodeSkills: unusedRuntimeMethod,
187 loadInventoryFromCli: unusedRuntimeMethod,
188 loadSkillsFromCli: unusedRuntimeMethod,
189 };
190
191 yield* Effect.scoped(
192 Effect.gen(function* () {
193 const owner = yield* OpenCodeServerOwner.make({
194 binaryPath: "opencode",
195 directory: "/project",
196 });
197 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
198 "http://127.0.0.1:1",
199 );
200 yield* Ref.set(processRunning[0]!, false);
201
202 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
203 "http://127.0.0.1:2",
204 );
205 expect(yield* Ref.get(starts)).toBe(2);
206 expect(yield* Ref.get(closes)).toBe(1);
207 }),
208 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
209 }),
210);
211
212it.effect("cleans up an interrupted startup and allows a retry", () =>
213 Effect.gen(function* () {
214 const starts = yield* Ref.make(0);
215 const firstStartEntered = yield* Deferred.make<void>();
216 const firstStartClosed = yield* Deferred.make<void>();
217 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
218 startOpenCodeServerProcess: () =>
219 Effect.gen(function* () {
220 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
221 yield* Effect.addFinalizer(() =>
222 index === 1
223 ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore)
224 : Effect.void,
225 );
226 if (index === 1) {
227 yield* Deferred.succeed(firstStartEntered, undefined);
228 return yield* Effect.never;
229 }
230 return {
231 url: `http://127.0.0.1:${index}`,
232 version: "1.14.19",
233 isRunning: Effect.succeed(true),
234 exitCode: Effect.never,
235 };
236 }),
237 connectToOpenCodeServer: unusedRuntimeMethod,
238 runOpenCodeCommand: unusedRuntimeMethod,
239 createOpenCodeSdkClient: () => ({}) as never,
240 loadOpenCodeInventory: unusedRuntimeMethod,
241 loadOpenCodeSkills: unusedRuntimeMethod,
242 loadInventoryFromCli: unusedRuntimeMethod,
243 loadSkillsFromCli: unusedRuntimeMethod,
244 };
245
246 yield* Effect.scoped(
247 Effect.gen(function* () {
248 const owner = yield* OpenCodeServerOwner.make({
249 binaryPath: "opencode",
250 directory: "/project",
251 });
252 const firstBorrower = yield* owner
253 .withServer((server) => Effect.succeed(server.url))
254 .pipe(Effect.forkChild);
255 yield* Deferred.await(firstStartEntered);
256 yield* Fiber.interrupt(firstBorrower);
257 yield* Deferred.await(firstStartClosed);
258 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
259 "http://127.0.0.1:2",
260 );
261 }),
262 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
263 }),
264);
265
266it.effect("releases an interrupted borrower and closes after the idle TTL", () =>
267 Effect.gen(function* () {
268 const testRuntime = yield* makeRuntime;
269 const borrowerEntered = yield* Deferred.make<void>();
270 yield* Effect.scoped(
271 Effect.gen(function* () {
272 const owner = yield* OpenCodeServerOwner.make({
273 binaryPath: "opencode",
274 directory: "/project",
275 });
276 const borrower = yield* owner
277 .withServer(() =>
278 Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)),
279 )
280 .pipe(Effect.forkChild);
281 yield* Deferred.await(borrowerEntered);
282 yield* Fiber.interrupt(borrower);
283 yield* TestClock.adjust(Duration.seconds(31));
284 yield* Deferred.await(testRuntime.closed);
285 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
286 }),
287 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
288 }).pipe(Effect.provide(TestClock.layer())),
289);
apps/server/src/provider/OpenCodeServerOwner.ts deleted-188
...@@ -1,188 +0,0 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Scope from "effect/Scope";
7import * as Semaphore from "effect/Semaphore";
8
9import * as OpenCodeRuntime from "./opencodeRuntime.ts";
10
11const OPENCODE_SERVER_IDLE_TTL = "30 seconds";
12
13interface OpenCodeServerOwnerState {
14 server: OpenCodeRuntime.OpenCodeServerProcess | null;
15 serverScope: Scope.Closeable | null;
16 borrowers: number;
17 idleCloseFiber: Fiber.Fiber<void, never> | null;
18}
19
20export class OpenCodeServerOwner extends Context.Service<
21 OpenCodeServerOwner,
22 {
23 readonly withServer: <A, E, R>(
24 use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect<A, E, R>,
25 ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
26 }
27>()("t3/provider/OpenCodeServerOwner") {}
28
29/** Owns the lazy local OpenCode server shared by one provider instance. */
30export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: {
31 readonly binaryPath: string;
32 readonly directory: string;
33 readonly serverPassword?: string;
34 readonly environment?: NodeJS.ProcessEnv;
35 readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>;
36}) {
37 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
38 const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
39 Scope.close(scope, Exit.void),
40 );
41 const mutex = yield* Semaphore.make(1);
42 const state: OpenCodeServerOwnerState = {
43 server: null,
44 serverScope: null,
45 borrowers: 0,
46 idleCloseFiber: null,
47 };
48
49 const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () {
50 const fiber = state.idleCloseFiber;
51 state.idleCloseFiber = null;
52 if (fiber !== null) {
53 yield* Fiber.interrupt(fiber).pipe(Effect.ignore);
54 }
55 });
56
57 const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* (
58 expected?: OpenCodeRuntime.OpenCodeServerProcess,
59 ) {
60 if (expected !== undefined && state.server !== expected) {
61 return;
62 }
63 const scope = state.serverScope;
64 state.server = null;
65 state.serverScope = null;
66 if (scope !== null) {
67 yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
68 }
69 });
70
71 const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* (
72 server: OpenCodeRuntime.OpenCodeServerProcess,
73 ) {
74 yield* server.exitCode;
75 yield* mutex.withPermit(
76 Effect.gen(function* () {
77 if (state.server !== server) {
78 return;
79 }
80 yield* cancelIdleClose();
81 yield* closeServer(server);
82 }),
83 );
84 });
85
86 const acquireServer = mutex.withPermit(
87 Effect.gen(function* () {
88 yield* cancelIdleClose();
89 if (state.server !== null) {
90 if (yield* state.server.isRunning) {
91 state.borrowers += 1;
92 return state.server;
93 }
94 yield* closeServer(state.server);
95 }
96
97 return yield* Effect.uninterruptibleMask((restore) =>
98 Effect.gen(function* () {
99 const serverScope = yield* Scope.make();
100 const started = yield* Effect.exit(
101 restore(
102 runtime
103 .startOpenCodeServerProcess({
104 binaryPath: input.binaryPath,
105 directory: input.directory,
106 ...(input.serverPassword !== undefined
107 ? { serverPassword: input.serverPassword }
108 : {}),
109 ...(input.environment ? { environment: input.environment } : {}),
110 ...(input.verify ? { verify: input.verify } : {}),
111 })
112 .pipe(Effect.provideService(Scope.Scope, serverScope)),
113 ),
114 );
115 if (Exit.isFailure(started)) {
116 yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
117 return yield* Effect.failCause(started.cause);
118 }
119
120 const server = started.value;
121 state.server = server;
122 state.serverScope = serverScope;
123 state.borrowers = 1;
124 yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope));
125 return server;
126 }),
127 );
128 }),
129 );
130
131 const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) =>
132 mutex.withPermit(
133 Effect.gen(function* () {
134 if (state.server !== server) {
135 return;
136 }
137 state.borrowers = Math.max(0, state.borrowers - 1);
138 if (state.borrowers > 0) {
139 return;
140 }
141 yield* cancelIdleClose();
142 state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe(
143 Effect.andThen(
144 mutex.withPermit(
145 Effect.gen(function* () {
146 if (state.server !== server || state.borrowers > 0) {
147 return;
148 }
149 state.idleCloseFiber = null;
150 yield* closeServer(server);
151 }),
152 ),
153 ),
154 Effect.forkIn(ownerScope),
155 );
156 }),
157 );
158
159 yield* Effect.addFinalizer(() =>
160 mutex.withPermit(
161 Effect.gen(function* () {
162 yield* cancelIdleClose();
163 state.borrowers = 0;
164 yield* closeServer();
165 }),
166 ),
167 );
168
169 return OpenCodeServerOwner.of({
170 withServer: (use) =>
171 Effect.uninterruptibleMask((restore) =>
172 restore(acquireServer).pipe(
173 Effect.flatMap((server) =>
174 restore(use(server)).pipe(Effect.ensuring(releaseServer(server))),
175 ),
176 ),
177 ),
178 });
179});
180
181/** @public Service construction is part of the canonical Effect module API. */
182export const layer = (input: {
183 readonly binaryPath: string;
184 readonly directory: string;
185 readonly serverPassword?: string;
186 readonly environment?: NodeJS.ProcessEnv;
187 readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>;
188}) => Layer.effect(OpenCodeServerOwner, make(input));
apps/server/src/provider/ProviderInstanceRegistry.test.ts+4-4
...@@ -33,7 +33,6 @@ import {...@@ -33,7 +33,6 @@ import {
33 type CodexSettings,33 type CodexSettings,
34 type CursorSettings,34 type CursorSettings,
35 type GrokSettings,35 type GrokSettings,
36 type OpenCodeSettings,
37 ProviderDriverKind,36 ProviderDriverKind,
38 type ProviderInstanceConfigMap,37 type ProviderInstanceConfigMap,
39 ProviderInstanceId,38 ProviderInstanceId,
...@@ -56,10 +55,11 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";...@@ -56,10 +55,11 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
56import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";55import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
57import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";56import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
58import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";57import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
59import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";58import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";
59import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings";
60import * as ModelManifest from "./ModelManifest.ts";60import * as ModelManifest from "./ModelManifest.ts";
61import * as OpenCodeRuntime from "./opencodeRuntime.ts";61import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
62import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";62import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
63import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";63import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
64import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";64import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
65import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts";65import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts";
apps/server/src/provider/ProviderRegistry.test.ts+2-2
...@@ -45,8 +45,8 @@ import * as AntigravityInstallation from "./AntigravityInstallation.ts";...@@ -45,8 +45,8 @@ import * as AntigravityInstallation from "./AntigravityInstallation.ts";
45import * as ModelManifest from "./ModelManifest.ts";45import * as ModelManifest from "./ModelManifest.ts";
46import { applyProviderCompatibility } from "./providerCompatibility.ts";46import { applyProviderCompatibility } from "./providerCompatibility.ts";
47import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";47import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
48import * as OpenCodeRuntime from "./opencodeRuntime.ts";48import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
49import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";49import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
50import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";50import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
51import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts";51import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts";
52import * as ServerConfig from "../config.ts";52import * as ServerConfig from "../config.ts";
apps/server/src/provider/acp/AcpNativeLogging.ts+1-1
...@@ -10,7 +10,7 @@ import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts";...@@ -10,7 +10,7 @@ import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts";
10import {10import {
11 structuralProtocolMethod,11 structuralProtocolMethod,
12 summarizeNativeProtocolPayload,12 summarizeNativeProtocolPayload,
13} from "../NativeProtocolLogging.ts";13} from "@t3tools/provider-core/server/nativeProtocolLogging";
14import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";14import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
1515
16const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);16const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts+1-1
...@@ -12,7 +12,7 @@ import * as Ref from "effect/Ref";...@@ -12,7 +12,7 @@ import * as Ref from "effect/Ref";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13import * as Semaphore from "effect/Semaphore";13import * as Semaphore from "effect/Semaphore";
1414
15import { writeFileStringAtomically } from "../../atomicWrite.ts";15import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
1616
17const decodeState = Schema.decodeUnknownEffect(17const decodeState = Schema.decodeUnknownEffect(
18 Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })),18 Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })),
apps/server/src/provider/builtInDrivers.ts+1-1
...@@ -26,7 +26,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";...@@ -26,7 +26,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
26import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";26import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
27import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";27import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
28import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";28import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
29import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";29import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";
30import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";30import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";
31import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server";31import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server";
32import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver";32import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver";
apps/server/src/provider/openCodeUsageLimits.ts deleted-121
...@@ -1,121 +0,0 @@
1import * as NodeOS from "node:os";
2
3import type { ServerProviderUsageWindow } from "@t3tools/contracts";
4import * as Crypto from "effect/Crypto";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as Hex from "effect/encoding/Hex";
12import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
13
14import {
15 clampPercent,
16 makeUnavailableUsageLimits,
17 makeUsageLimits,
18} from "@t3tools/provider-core/server/usageLimits";
19
20const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) });
21const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String });
22const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile));
23const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth);
24const UsageWindow = Schema.Struct({
25 percent: Schema.Finite,
26 resetsAt: Schema.DateTimeUtcFromString,
27});
28const UsageResponse = Schema.Struct({
29 usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }),
30});
31
32/** External OpenCode servers own their credentials; never read the host's account for them. */
33export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: {
34 readonly enabled: boolean;
35 readonly serverUrl: string;
36 readonly environment: NodeJS.ProcessEnv;
37}) {
38 const checkedAt = DateTime.formatIso(yield* DateTime.now);
39 const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
40 if (!input.enabled || input.serverUrl.trim()) return unsupported;
41
42 return yield* Effect.gen(function* () {
43 const fs = yield* FileSystem.FileSystem;
44 const path = yield* Path.Path;
45 const env = input.environment;
46 const dataHome =
47 env.XDG_DATA_HOME ||
48 path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share");
49 const authPath = path.join(dataHome, "opencode", "auth.json");
50 const contents =
51 env.OPENCODE_AUTH_CONTENT ||
52 (yield* fs.readFileString(authPath).pipe(
53 Effect.catchTags({
54 PlatformError: (error) =>
55 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
56 }),
57 ));
58 const auth = yield* decodeAuthFile(contents);
59 const apiAuth = decodeApiAuth(auth["opencode-go"]);
60 // OpenCode overlays stored API credentials after environment credentials.
61 const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim();
62 if (!apiKey) return unsupported;
63
64 const client = yield* HttpClient.HttpClient;
65 const response = yield* client.execute(
66 HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe(
67 HttpClientRequest.bearerToken(apiKey),
68 ),
69 );
70 // A valid Zen key can exist without a Go subscription.
71 if (response.status === 403) return unsupported;
72 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
73 Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)),
74 );
75 const windows: ServerProviderUsageWindow[] = [
76 {
77 id: "go_rolling",
78 kind: "session",
79 label: "Go · Session",
80 windowDurationMins: 5 * 60,
81 usedPercent: clampPercent(body.usage.rolling.percent),
82 resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt),
83 },
84 {
85 id: "go_weekly",
86 kind: "weekly",
87 label: "Go · Weekly",
88 windowDurationMins: 7 * 24 * 60,
89 usedPercent: clampPercent(body.usage.weekly.percent),
90 resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt),
91 },
92 {
93 id: "go_monthly",
94 kind: "monthly",
95 label: "Go · Monthly",
96 usedPercent: clampPercent(body.usage.monthly.percent),
97 resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt),
98 },
99 ];
100 const crypto = yield* Crypto.Crypto;
101 // Go's usage response has no account ID. An unkeyed hash matches across
102 // environments without a shared secret. It permits offline guesses, but
103 // Go keys are randomly generated.
104 const credentialFingerprint = yield* crypto
105 .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`))
106 .pipe(Effect.map(Hex.encode), Effect.orDie);
107 return {
108 ...makeUsageLimits({ checkedAt, windows }),
109 credentialFingerprint,
110 };
111 }).pipe(
112 Effect.timeout("5 seconds"),
113 Effect.orElseSucceed(() =>
114 makeUnavailableUsageLimits({
115 checkedAt,
116 reason: "probeFailed",
117 message: "OpenCode Go could not read usage.",
118 }),
119 ),
120 );
121});
apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts deleted-196
...@@ -1,196 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import { TestClock } from "effect/testing";
10import { HttpClient, HttpClientResponse } from "effect/http";
11import { describe } from "vite-plus/test";
12
13import * as OpenCode2Client from "./OpenCode2Client.ts";
14
15const RECORDING = new URL(
16 "../../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson",
17 import.meta.url,
18);
19const Entry = Schema.Struct({ type: Schema.String, frame: Schema.optional(Schema.Unknown) });
20const decodeEntry = Schema.decodeUnknownSync(Schema.fromJsonString(Entry));
21const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
22
23/** The recorded `simple` turn's events, as a newer server would send them. */
24const newerServerStream = Effect.gen(function* () {
25 const fs = yield* FileSystem.FileSystem;
26 const recorded = (yield* fs.readFileString(RECORDING.pathname))
27 .split("\n")
28 .filter((line) => line.length > 0)
29 .map((line) => decodeEntry(line))
30 .flatMap((entry) => {
31 const frame = entry.frame as { readonly type?: string; readonly event?: unknown } | undefined;
32 return frame?.type === "sdk.event" ? [frame.event as Record<string, unknown>] : [];
33 });
34 const executionStarted = recorded.findIndex(
35 (event) => event.type === "session.execution.started",
36 );
37 const [first] = recorded;
38 return [
39 ...recorded.slice(0, executionStarted),
40 // An event type this build has never heard of.
41 {
42 id: "evt_0000000000newtype",
43 created: 1,
44 type: "session.hologram.projected",
45 data: { beams: 3 },
46 },
47 // A known event carrying a field added after 2.0.18.
48 {
49 ...recorded[executionStarted],
50 data: { ...(recorded[executionStarted]!.data as object), lane: "fast" },
51 },
52 ...recorded.slice(executionStarted + 1),
53 first,
54 ];
55});
56
57const layerServing = (events: ReadonlyArray<unknown>) =>
58 Layer.succeed(
59 HttpClient.HttpClient,
60 HttpClient.make((request) =>
61 Effect.succeed(
62 HttpClientResponse.fromWeb(
63 request,
64 new Response(events.map((event) => `data: ${encodeJson(event)}\n\n`).join(""), {
65 headers: { "content-type": "text/event-stream" },
66 }),
67 ),
68 ),
69 ),
70 );
71
72const connect = Effect.gen(function* () {
73 const opencode = yield* OpenCode2Client.OpenCode2Client;
74 return yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password: "secret" });
75});
76
77/** Sends `events`, then keeps the connection open without another byte, like a frozen server. */
78const layerServingThenSilent = (events: ReadonlyArray<unknown>) =>
79 Layer.succeed(
80 HttpClient.HttpClient,
81 HttpClient.make((request) =>
82 Effect.succeed(
83 HttpClientResponse.fromWeb(
84 request,
85 new Response(
86 new ReadableStream<Uint8Array>({
87 start(controller) {
88 for (const event of events) {
89 controller.enqueue(new TextEncoder().encode(`data: ${encodeJson(event)}\n\n`));
90 }
91 },
92 }),
93 { headers: { "content-type": "text/event-stream" } },
94 ),
95 ),
96 ),
97 ),
98 );
99
100describe("OpenCode2Client events", () => {
101 it.effect("fails a stream that stops sending anything, even heartbeats", () =>
102 Effect.gen(function* () {
103 const { events } = yield* connect.pipe(
104 Effect.provide(
105 OpenCode2Client.layer.pipe(
106 Layer.provide(
107 layerServingThenSilent([{ id: "evt_1", type: "server.connected", data: {} }]),
108 ),
109 ),
110 ),
111 );
112 const drained = yield* (yield* events).pipe(Stream.runDrain, Effect.flip, Effect.forkChild);
113 // Let the first frame arrive and the timer start before the clock moves.
114 for (let i = 0; i < 20; i++) yield* Effect.yieldNow;
115 yield* TestClock.adjust("46 seconds");
116 const failure = yield* Fiber.join(drained);
117 assert.strictEqual(failure._tag, "OpenCode2SilentStreamError");
118 }).pipe(Effect.provide(TestClock.layer())),
119 );
120
121 it.effect(
122 "keeps an execution's end and start it cannot decode, and skips other unknown events",
123 () =>
124 Effect.gen(function* () {
125 const { events } = yield* connect.pipe(
126 Effect.provide(
127 OpenCode2Client.layer.pipe(
128 Layer.provide(
129 layerServing([
130 {
131 id: "evt_1",
132 created: 1,
133 type: "session.hologram.projected",
134 data: { sessionID: "ses_x" },
135 },
136 // A start is kept as a marker; it never ends anything.
137 {
138 id: "evt_0",
139 created: 1,
140 type: "session.execution.started",
141 data: { sessionID: "ses_x", lane: 7 },
142 durable: "not-an-envelope",
143 },
144 {
145 id: "evt_2",
146 created: 1,
147 type: "session.execution.interrupted",
148 data: { sessionID: "ses_x", reason: "budget" },
149 durable: { aggregateID: "ses_x", seq: 1, version: 1 },
150 },
151 ]),
152 ),
153 ),
154 ),
155 );
156 const received = yield* (yield* events).pipe(Stream.runCollect);
157 assert.deepStrictEqual(received, [
158 { type: "unreadable.execution.started", sessionID: "ses_x" },
159 {
160 type: "unreadable.execution.ended",
161 executionType: "session.execution.interrupted",
162 sessionID: "ses_x",
163 },
164 ]);
165 }),
166 );
167
168 it.effect("skips events a newer server adds and still sees the turn end", () =>
169 Effect.gen(function* () {
170 const events = yield* newerServerStream;
171 const { events: subscribe } = yield* connect.pipe(
172 Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))),
173 );
174 const types = yield* (yield* subscribe).pipe(
175 Stream.map((event) => event.type),
176 Stream.runCollect,
177 );
178
179 assert.notInclude(types, "session.hologram.projected");
180 assert.include(types, "session.execution.started");
181 assert.include(types, "session.execution.succeeded");
182 assert.strictEqual(types.length, events.length - 1);
183 }).pipe(Effect.provide(NodeServices.layer)),
184 );
185
186 it.effect("the client's own subscription fails the whole stream on the same events", () =>
187 Effect.gen(function* () {
188 const events = yield* newerServerStream;
189 const { client } = yield* connect.pipe(
190 Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))),
191 );
192 const failure = yield* client.event.subscribe().pipe(Stream.runDrain, Effect.flip);
193 assert.strictEqual(failure._tag, "ClientError");
194 }).pipe(Effect.provide(NodeServices.layer)),
195 );
196});
apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts deleted-151
...@@ -1,151 +0,0 @@
1/**
2 * Live contract check for `@opencode/client` against a real OpenCode 2 server.
3 * It re-proves that OpenCode's schemas decode under the workspace Effect
4 * version, which the pnpm overrides force. Run it after bumping either side:
5 *
6 * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Client.live.test.ts
7 *
8 * The turn uses the free `opencode/big-pickle` model.
9 */
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect";
12import { assert, it } from "@effect/vitest";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Base64Url from "effect/encoding/Base64Url";
16import * as Fiber from "effect/Fiber";
17import * as FileSystem from "effect/FileSystem";
18import * as Filter from "effect/Filter";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as Stream from "effect/Stream";
23import { FetchHttpClient } from "effect/http";
24import { ChildProcess, ChildProcessSpawner } from "effect/process";
25import { describe } from "vite-plus/test";
26
27import * as OpenCode2Client from "./OpenCode2Client.ts";
28
29const binaryPath = process.env.OPENCODE2_BIN;
30
31const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (binary: string) {
32 const fs = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;
34 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
35 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-live-" });
36 const directory = path.join(root, "work");
37 yield* fs.makeDirectory(directory);
38 // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8.
39 const crypto = yield* Crypto.Crypto;
40 const password = `${Base64Url.encode(yield* crypto.randomBytes(32))}-pässwörd€`;
41 const child = yield* spawner.spawn(
42 ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], {
43 cwd: directory,
44 env: {
45 PATH: process.env.PATH,
46 HOME: root,
47 XDG_CONFIG_HOME: path.join(root, "config"),
48 XDG_DATA_HOME: path.join(root, "data"),
49 XDG_STATE_HOME: path.join(root, "state"),
50 XDG_CACHE_HOME: path.join(root, "cache"),
51 OPENCODE_PASSWORD: password,
52 },
53 }),
54 );
55 // Stop by the PID captured at spawn, before the temp directory is removed.
56 yield* Effect.addFinalizer(() =>
57 Effect.sync(() => {
58 try {
59 process.kill(Number(child.pid), "SIGTERM");
60 } catch {
61 // Already exited.
62 }
63 }).pipe(Effect.andThen(child.exitCode), Effect.timeout("10 seconds"), Effect.ignore),
64 );
65 const baseUrl = yield* child.stdout.pipe(
66 Stream.decodeText(),
67 Stream.splitLines,
68 Stream.filterMap(
69 Filter.fromPredicateOption((line: string) =>
70 Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]),
71 ),
72 ),
73 Stream.runHead,
74 Effect.flatMap(Effect.fromOption),
75 Effect.timeout("30 seconds"),
76 );
77 return { baseUrl, password, directory, pid: Number(child.pid) };
78});
79
80describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => {
81 it.live(
82 "reads server info, runs one turn, and pages the history",
83 () =>
84 Effect.gen(function* () {
85 const server = yield* startServer(binaryPath!);
86 const opencode = yield* OpenCode2Client.OpenCode2Client;
87 const { client, events } = yield* opencode.connect({
88 baseUrl: server.baseUrl,
89 password: server.password,
90 });
91
92 const info = yield* client.server.info();
93 assert.strictEqual(info.version, "2.0.23");
94 assert.strictEqual(info.pid, server.pid);
95
96 const session = yield* client.session.create({
97 title: "t3 client live check",
98 location: Location.PublicRef.make({ directory: AbsolutePath.make(server.directory) }),
99 model: Model.Ref.make({
100 providerID: Provider.ID.make("opencode"),
101 id: Model.ID.make("big-pickle"),
102 }),
103 });
104 const finished = yield* (yield* events).pipe(
105 Stream.filter(
106 (event) =>
107 event.type !== "unreadable.execution.ended" &&
108 event.type !== "unreadable.execution.started" &&
109 (event.data as { readonly sessionID?: string }).sessionID === session.id &&
110 event.type.startsWith("session.execution.") &&
111 event.type !== "session.execution.started",
112 ),
113 Stream.runHead,
114 Effect.forkChild,
115 );
116 yield* client.session.prompt({
117 sessionID: session.id,
118 text: "Reply with exactly: OPENCODE2_CLIENT_OK",
119 });
120 const terminal = yield* Fiber.join(finished).pipe(
121 Effect.flatMap(Effect.fromOption),
122 Effect.timeout("120 seconds"),
123 );
124 assert.strictEqual(terminal.type, "session.execution.succeeded");
125
126 // One message per page makes every request after the first send only a cursor.
127 const history = yield* OpenCode2Client.paginate(
128 { sessionID: session.id, order: "asc", limit: 1 },
129 client.message.list,
130 ).pipe(Stream.runCollect);
131 assert.deepStrictEqual(
132 history.map((message) => message.type),
133 ["user", "assistant", "idle"],
134 );
135 const assistant = history[1];
136 assert(assistant?.type === "assistant");
137 const reply = assistant.content.flatMap((part) =>
138 part.type === "text" ? [part.text] : [],
139 );
140 assert.include(reply.join(""), "OPENCODE2_CLIENT_OK");
141 }).pipe(
142 Effect.scoped,
143 Effect.provide(
144 OpenCode2Client.layer.pipe(
145 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
146 ),
147 ),
148 ),
149 180_000,
150 );
151});
apps/server/src/provider/opencode2/OpenCode2Client.test.ts deleted-48
...@@ -1,48 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5import { HttpClient, HttpClientResponse } from "effect/http";
6import { describe } from "vite-plus/test";
7
8import * as OpenCode2Client from "./OpenCode2Client.ts";
9
10const INFO_BODY = '{"version":"2.0.18","pid":4242,"urls":[],"paths":{"tmp":"/tmp"}}';
11
12/** Sends one request through the client and returns the Authorization header it carried. */
13const authorizationFor = (password: string | Redacted.Redacted) => {
14 const seen: Array<string | undefined> = [];
15 const layerCapturing = Layer.succeed(
16 HttpClient.HttpClient,
17 HttpClient.make((request) =>
18 Effect.sync(() => {
19 seen.push(request.headers.authorization);
20 return HttpClientResponse.fromWeb(
21 request,
22 new Response(INFO_BODY, { headers: { "content-type": "application/json" } }),
23 );
24 }),
25 ),
26 );
27 return Effect.gen(function* () {
28 const opencode = yield* OpenCode2Client.OpenCode2Client;
29 const { client } = yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password });
30 yield* client.server.info();
31 return seen[0];
32 }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerCapturing))));
33};
34
35const utf8Basic = (password: string) =>
36 `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
37
38describe("OpenCode2Client authorization", () => {
39 // OpenCode decodes Basic credentials as UTF-8. Latin-1 (`btoa`) encoding gets
40 // `pässwörd` rejected with a 401 and throws on characters such as `€`.
41 it.effect("encodes non-ASCII passwords as UTF-8", () =>
42 Effect.gen(function* () {
43 assert.strictEqual(yield* authorizationFor("pässwörd"), utf8Basic("pässwörd"));
44 assert.strictEqual(yield* authorizationFor("pass€word"), utf8Basic("pass€word"));
45 assert.strictEqual(yield* authorizationFor(Redacted.make("pässwörd")), utf8Basic("pässwörd"));
46 }),
47 );
48});
apps/server/src/provider/opencode2/OpenCode2Client.ts deleted-223
...@@ -1,223 +0,0 @@
1import { OpenCode, type OpenCodeClient } from "@opencode/client/effect";
2import { OpenCodeEvent } from "@opencode/protocol/groups/event";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Redacted from "effect/Redacted";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10import * as Stream from "effect/Stream";
11import * as Sse from "effect/encoding/Sse";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientError from "effect/http/HttpClientError";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import * as HttpClientResponse from "effect/http/HttpClientResponse";
16
17/** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */
18const OPENCODE_USERNAME = "opencode";
19
20/**
21 * A session's execution ended, but this build could not decode the event (a
22 * newer server added a field or a value). Only the type and session survive,
23 * so the turn still ends.
24 */
25export interface OpenCode2UnreadableTerminal {
26 readonly type: "unreadable.execution.ended";
27 readonly executionType: (typeof EXECUTION_ENDS)[number];
28 readonly sessionID: string;
29}
30
31/**
32 * A session's execution started, but this build could not decode the event.
33 * It marks where a new execution begins and never ends a turn.
34 */
35export interface OpenCode2UnreadableStart {
36 readonly type: "unreadable.execution.started";
37 readonly sessionID: string;
38}
39
40/** The events that end an execution; any other undecodable event is skipped. */
41const EXECUTION_ENDS = [
42 "session.execution.succeeded",
43 "session.execution.failed",
44 "session.execution.interrupted",
45] as const;
46const isExecutionEnd = (type: string): type is (typeof EXECUTION_ENDS)[number] =>
47 (EXECUTION_ENDS as ReadonlyArray<string>).includes(type);
48
49/** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */
50export class OpenCode2SilentStreamError extends Schema.TaggedError<OpenCode2SilentStreamError>()(
51 "OpenCode2SilentStreamError",
52 {},
53) {
54 override get message(): string {
55 return "The OpenCode server stopped sending events.";
56 }
57}
58
59export type OpenCode2StreamEvent =
60 | OpenCodeEvent
61 | OpenCode2UnreadableTerminal
62 | OpenCode2UnreadableStart;
63
64/**
65 * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The
66 * client's own `event.subscribe` fails the whole stream on the first event a
67 * newer server adds, so `events` decodes each frame on its own and skips the
68 * ones this build does not know, except an execution's end or start, which it
69 * keeps as an {@link OpenCode2UnreadableTerminal} or {@link OpenCode2UnreadableStart}.
70 * `events` succeeds once the server accepted the subscription: the stream is
71 * volatile, so callers subscribe before they start work whose events they need.
72 */
73export interface OpenCode2Api {
74 readonly client: OpenCodeClient;
75 readonly events: Effect.Effect<
76 Stream.Stream<
77 OpenCode2StreamEvent,
78 HttpClientError.HttpClientError | Sse.Retry | Sse.SseError | OpenCode2SilentStreamError
79 >,
80 HttpClientError.HttpClientError
81 >;
82}
83
84/** Builds clients for OpenCode 2 servers, one per base URL and password. */
85export class OpenCode2Client extends Context.Service<
86 OpenCode2Client,
87 {
88 readonly connect: (input: {
89 readonly baseUrl: string;
90 readonly password: string | Redacted.Redacted;
91 }) => Effect.Effect<OpenCode2Api>;
92 }
93>()("t3/provider/opencode2/OpenCode2Client") {}
94
95/**
96 * OpenCode writes a `: heartbeat` comment every 10 to 15 seconds, so this long
97 * without a single byte means the server is stuck, not idle.
98 */
99const SILENT_STREAM_TIMEOUT = "45 seconds";
100
101const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent));
102// Just enough of an event to route it, for frames the full schema rejects.
103const decodeEnvelope = Schema.decodeUnknownOption(
104 Schema.fromJsonString(
105 Schema.Struct({
106 type: Schema.String,
107 data: Schema.optional(Schema.Struct({ sessionID: Schema.optional(Schema.String) })),
108 }),
109 ),
110);
111
112const undecodable = (data: string) =>
113 Effect.gen(function* () {
114 const envelope = decodeEnvelope(data);
115 const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : "<unreadable>";
116 const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined;
117 if (isExecutionEnd(type) && sessionID !== undefined) {
118 yield* Effect.logWarning(
119 "Ended an OpenCode execution from an event this build cannot decode.",
120 {
121 type,
122 },
123 );
124 return Result.succeed<OpenCode2StreamEvent>({
125 type: "unreadable.execution.ended",
126 executionType: type,
127 sessionID,
128 });
129 }
130 if (type === "session.execution.started" && sessionID !== undefined) {
131 yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode.");
132 return Result.succeed<OpenCode2StreamEvent>({
133 type: "unreadable.execution.started",
134 sessionID,
135 });
136 }
137 yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type });
138 return Result.failVoid;
139 });
140
141/** Subscribes to `/api/event`, then streams every frame this build can route. */
142const readEvents = (httpClient: HttpClient.HttpClient) =>
143 httpClient.get("/api/event", { headers: { accept: "text/event-stream" } }).pipe(
144 Effect.flatMap(HttpClientResponse.filterStatusOk),
145 Effect.map((response) =>
146 response.stream.pipe(
147 Stream.timeoutOrElse({
148 duration: SILENT_STREAM_TIMEOUT,
149 orElse: () => Stream.fail(new OpenCode2SilentStreamError()),
150 }),
151 Stream.decodeText,
152 Stream.pipeThroughChannel(Sse.decode()),
153 Stream.filterMapEffect((frame) => {
154 const decoded = decodeEvent(frame.data);
155 return Result.isSuccess(decoded)
156 ? Effect.succeed(Result.succeed<OpenCode2StreamEvent>(decoded.success))
157 : undecodable(frame.data);
158 }),
159 ),
160 ),
161 );
162
163/**
164 * OpenCode decodes Basic credentials as UTF-8. `HttpClientRequest.basicAuth`
165 * encodes them as Latin-1 (`btoa`), which gets non-ASCII passwords rejected.
166 */
167const basicAuthorization = (password: string | Redacted.Redacted) => {
168 const plain = Redacted.isRedacted(password) ? Redacted.value(password) : password;
169 return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`;
170};
171
172/** @public Service construction is part of the canonical Effect module API. */
173export const make = Effect.gen(function* () {
174 const httpClient = yield* HttpClient.HttpClient;
175 return OpenCode2Client.of({
176 connect: ({ baseUrl, password }) => {
177 const authenticated = HttpClient.mapRequest(
178 httpClient,
179 HttpClientRequest.setHeader("Authorization", basicAuthorization(password)),
180 );
181 return OpenCode.make({ baseUrl }).pipe(
182 Effect.provideService(HttpClient.HttpClient, authenticated),
183 Effect.map((client) => ({
184 client,
185 events: readEvents(
186 HttpClient.mapRequest(authenticated, HttpClientRequest.prependUrl(baseUrl)),
187 ),
188 })),
189 );
190 },
191 });
192});
193
194export const layer = Layer.effect(OpenCode2Client, make);
195
196/**
197 * Streams every item of a cursor-paged OpenCode 2 list. The first request
198 * carries the caller's input (including `order`); later requests send only
199 * the cursor, because OpenCode rejects a cursor combined with `order`.
200 */
201export const paginate = <Input extends { readonly cursor?: unknown }, Item, E, R>(
202 input: Input,
203 list: (
204 input: Input,
205 ) => Effect.Effect<
206 { readonly data: ReadonlyArray<Item>; readonly cursor: { readonly next?: Input["cursor"] } },
207 E,
208 R
209 >,
210): Stream.Stream<Item, E, R> =>
211 Stream.paginate(input, (request) =>
212 list(request).pipe(
213 Effect.map(
214 (page) =>
215 [
216 page.data,
217 page.data.length === 0 || page.cursor.next === undefined
218 ? Option.none()
219 : Option.some({ ...request, order: undefined, cursor: page.cursor.next }),
220 ] as const,
221 ),
222 ),
223 );
apps/server/src/provider/opencode2/OpenCode2Server.live.test.ts deleted-238
...@@ -1,238 +0,0 @@
1/**
2 * Live check of the OpenCode 2 server lifecycle against a real binary:
3 *
4 * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Server.live.test.ts
5 *
6 * No model is called. The server runs with isolated HOME and XDG directories.
7 */
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { AbsolutePath, Location } from "@opencode/client/effect";
10import { assert, it } from "@effect/vitest";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as FileSystem from "effect/FileSystem";
15import * as Filter from "effect/Filter";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as Redacted from "effect/Redacted";
20import * as Scope from "effect/Scope";
21import * as Stream from "effect/Stream";
22import { FetchHttpClient } from "effect/http";
23import { ChildProcess, ChildProcessSpawner } from "effect/process";
24import { describe } from "vite-plus/test";
25
26import * as OpenCodeRuntime from "../opencodeRuntime.ts";
27import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts";
28import * as OpenCode2Client from "./OpenCode2Client.ts";
29import * as OpenCode2Server from "./OpenCode2Server.ts";
30
31const binaryPath = process.env.OPENCODE2_BIN;
32
33// A negative pid probes the whole process group, which T3 stops as one unit.
34const isAlive = (pid: number) => {
35 try {
36 process.kill(pid, 0);
37 return true;
38 } catch {
39 return false;
40 }
41};
42
43/**
44 * Stops a server the test spawned itself: SIGTERM, then SIGKILL if it is still
45 * alive after five seconds. Keyed on the PID captured at spawn, never on a
46 * pattern. Servers T3 spawns are stopped by T3, through the instance scope.
47 */
48const stopByPid = (pid: number) =>
49 Effect.gen(function* () {
50 const signal = (name: NodeJS.Signals) => {
51 try {
52 process.kill(pid, name);
53 } catch {
54 // Already exited.
55 }
56 };
57 signal("SIGTERM");
58 for (let attempt = 0; attempt < 50 && isAlive(pid); attempt++) {
59 yield* Effect.sleep("100 millis");
60 }
61 if (isAlive(pid)) signal("SIGKILL");
62 });
63
64/**
65 * Starts `opencode serve` the way a user would run it themselves. It is stopped
66 * by its PID when the calling scope closes, whether or not the test passed.
67 */
68const startExternalServer = Effect.fn("OpenCode2ServerLive.startExternalServer")(function* (input: {
69 readonly environment: NodeJS.ProcessEnv;
70 readonly directory: string;
71}) {
72 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
73 const child = yield* spawner.spawn(
74 ChildProcess.make(binaryPath!, ["serve", "--hostname=127.0.0.1", "--port=0"], {
75 cwd: input.directory,
76 env: input.environment,
77 }),
78 );
79 const pid = Number(child.pid);
80 yield* Effect.addFinalizer(() => stopByPid(pid));
81 const url = yield* child.stdout.pipe(
82 Stream.decodeText(),
83 Stream.splitLines,
84 Stream.filterMap(
85 Filter.fromPredicateOption((line: string) =>
86 Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]),
87 ),
88 ),
89 Stream.runHead,
90 Effect.flatMap(Effect.fromOption),
91 Effect.timeout("30 seconds"),
92 );
93 return { url, pid };
94});
95
96describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => {
97 it.live(
98 "spawns one authenticated server per instance and stops it by PID",
99 () =>
100 Effect.gen(function* () {
101 const fs = yield* FileSystem.FileSystem;
102 const path = yield* Path.Path;
103 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-server-live-" });
104 const first = path.join(root, "first");
105 const second = path.join(root, "second");
106 yield* fs.makeDirectory(first);
107 yield* fs.makeDirectory(second);
108 const environment = {
109 PATH: process.env.PATH,
110 HOME: root,
111 XDG_CONFIG_HOME: path.join(root, "config"),
112 XDG_DATA_HOME: path.join(root, "data"),
113 XDG_STATE_HOME: path.join(root, "state"),
114 XDG_CACHE_HOME: path.join(root, "cache"),
115 // Ignored: the T3 password is the only one the server sees.
116 OPENCODE_SERVER_PASSWORD: "ambient-password",
117 };
118
119 // The instance owns its server: building the layer spawns nothing, and
120 // closing its scope stops whatever it spawned. The scope is released
121 // even when an assertion below fails; it is closed early on purpose.
122 const instanceScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
123 Scope.close(scope, Exit.void),
124 );
125 const server = Context.get(
126 yield* Layer.buildWithScope(
127 OpenCode2Server.layer({
128 binaryPath: binaryPath!,
129 serverUrl: "",
130 serverPassword: "",
131 directory: first,
132 environment,
133 }),
134 instanceScope,
135 ),
136 OpenCode2Server.OpenCode2Server,
137 );
138
139 const spawned = yield* server.withConnection((connection) =>
140 Effect.gen(function* () {
141 const info = yield* connection.client.server.info();
142 const session = yield* connection.client.session.create({
143 title: "first location",
144 location: Location.PublicRef.make({ directory: AbsolutePath.make(first) }),
145 });
146 return { ...connection, pid: info.pid, session };
147 }),
148 );
149 assert.strictEqual(spawned.version, "2.0.18");
150 assert.isFalse(spawned.external);
151 assert.isTrue(isAlive(spawned.pid));
152
153 // A second location is served by the same process.
154 const secondLocation = yield* server.withConnection((connection) =>
155 Effect.gen(function* () {
156 const info = yield* connection.client.server.info();
157 const session = yield* connection.client.session.create({
158 title: "second location",
159 location: Location.PublicRef.make({ directory: AbsolutePath.make(second) }),
160 });
161 return { url: connection.url, pid: info.pid, session };
162 }),
163 );
164 assert.strictEqual(secondLocation.url, spawned.url);
165 assert.strictEqual(secondLocation.pid, spawned.pid);
166 assert.strictEqual(secondLocation.session.location.directory, second);
167 assert.strictEqual(spawned.session.location.directory, first);
168
169 // External URL: the configured password works, a wrong one is a 401.
170 const external = yield* OpenCode2Server.make({
171 binaryPath: binaryPath!,
172 serverUrl: spawned.url,
173 serverPassword: "wrong-password",
174 directory: first,
175 environment,
176 });
177 const rejected = yield* external.withConnection(() => Effect.void).pipe(Effect.flip);
178 assert.include(rejected.detail, "rejected the server password");
179 const ambient = yield* OpenCode2Server.make({
180 binaryPath: binaryPath!,
181 serverUrl: spawned.url,
182 serverPassword: environment.OPENCODE_SERVER_PASSWORD,
183 directory: first,
184 environment,
185 });
186 const ambientRejected = yield* ambient.withConnection(() => Effect.void).pipe(Effect.flip);
187 assert.include(ambientRejected.detail, "rejected the server password");
188
189 // An external server whose password is not ASCII: OpenCode decodes Basic
190 // credentials as UTF-8, so the configured password must be sent that way.
191 const utf8Password = "pässwörd€";
192 const externalServer = yield* startExternalServer({
193 environment: OpenCode2Server.serverEnvironment(environment, Redacted.make(utf8Password)),
194 directory: second,
195 });
196 const utf8 = yield* OpenCode2Server.make({
197 binaryPath: binaryPath!,
198 serverUrl: externalServer.url,
199 serverPassword: utf8Password,
200 directory: second,
201 environment,
202 });
203 const utf8Connection = yield* utf8.withConnection((connection) =>
204 Effect.succeed(connection),
205 );
206 assert.strictEqual(utf8Connection.version, "2.0.18");
207 assert.isTrue(utf8Connection.external);
208 const asciiOnly = yield* OpenCode2Server.make({
209 binaryPath: binaryPath!,
210 serverUrl: externalServer.url,
211 serverPassword: "passwrd",
212 directory: second,
213 environment,
214 });
215 const asciiRejected = yield* asciiOnly.withConnection(() => Effect.void).pipe(Effect.flip);
216 assert.include(asciiRejected.detail, "rejected the server password");
217
218 // Closing the instance stops the server it spawned.
219 yield* Scope.close(instanceScope, Exit.void);
220 for (let attempt = 0; attempt < 50 && isAlive(-spawned.pid); attempt++) {
221 yield* Effect.sleep("100 millis");
222 }
223 assert.isFalse(isAlive(spawned.pid), `opencode serve ${spawned.pid} outlived its instance`);
224 assert.isFalse(isAlive(-spawned.pid), `a process in group ${spawned.pid} outlived it`);
225 const unreachable = yield* external.withConnection(() => Effect.void).pipe(Effect.flip);
226 assert.include(unreachable.detail, "Could not reach the OpenCode server");
227 }).pipe(
228 Effect.scoped,
229 Effect.provide(
230 Layer.mergeAll(
231 OpenCode2Client.layer,
232 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
233 ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))),
234 ),
235 ),
236 60_000,
237 );
238});
apps/server/src/provider/opencode2/OpenCode2Server.test.ts deleted-259
...@@ -1,259 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 HostProcessEnvironment,
5 HostProcessExecutablePath,
6 HostProcessPlatform,
7} from "@t3tools/shared/hostProcess";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Redacted from "effect/Redacted";
14import * as TestClock from "effect/testing/TestClock";
15import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
16import { describe } from "vite-plus/test";
17
18import * as OpenCodeRuntime from "../opencodeRuntime.ts";
19import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts";
20import * as OpenCode2Client from "./OpenCode2Client.ts";
21import * as OpenCode2Server from "./OpenCode2Server.ts";
22
23// Shapes and content types observed on `opencode serve` 2.0.18 (pid and port replaced). 1.x
24// answers /api/info with its web UI: 200 text/html.
25const INFO_BODY =
26 '{"version":"2.0.18","pid":4242,"urls":["http://127.0.0.1:4096"],"paths":{"tmp":"/tmp/opencode"}}';
27const UNAUTHORIZED_BODY = '{"_tag":"UnauthorizedError","message":"Authentication required"}';
28const SPA_BODY = "<!doctype html><html><head><title>OpenCode</title></head></html>";
29
30const layerServerReplying = (reply: {
31 readonly status: number;
32 readonly contentType?: string;
33 readonly body: string;
34}) =>
35 Layer.succeed(
36 HttpClient.HttpClient,
37 HttpClient.make((request) =>
38 Effect.succeed(
39 HttpClientResponse.fromWeb(
40 request,
41 new Response(reply.body, {
42 status: reply.status,
43 headers: reply.contentType === undefined ? {} : { "content-type": reply.contentType },
44 }),
45 ),
46 ),
47 ),
48 );
49
50const verify = (httpClient: Layer.Layer<HttpClient.HttpClient>, url = "http://127.0.0.1:4096") =>
51 Effect.gen(function* () {
52 const opencode = yield* OpenCode2Client.OpenCode2Client;
53 const { client } = yield* opencode.connect({ baseUrl: url, password: "secret" });
54 return yield* OpenCode2Server.verifyServer(client);
55 }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(httpClient))));
56
57describe("OpenCode2Server.verifyServer", () => {
58 it.effect("returns the version of an authenticated OpenCode 2 server", () =>
59 Effect.gen(function* () {
60 const version = yield* verify(
61 layerServerReplying({ status: 200, contentType: "application/json", body: INFO_BODY }),
62 );
63 assert.strictEqual(version, "2.0.18");
64 }),
65 );
66
67 it.effect("reports a rejected password, not a wrong server", () =>
68 Effect.gen(function* () {
69 const error = yield* verify(
70 layerServerReplying({
71 status: 401,
72 contentType: "application/json",
73 body: UNAUTHORIZED_BODY,
74 }),
75 ).pipe(Effect.flip);
76 assert.include(error.detail, "rejected the server password");
77 }),
78 );
79
80 // 1.x rejects a wrong password with an empty 401, which the client cannot decode.
81 it.effect("reports an empty-body 401 as a rejected password", () =>
82 Effect.gen(function* () {
83 const error = yield* verify(layerServerReplying({ status: 401, body: "" })).pipe(Effect.flip);
84 assert.include(error.detail, "rejected the server password");
85 }),
86 );
87
88 it.effect("reports a server error as a server error, not as unreachable", () =>
89 Effect.gen(function* () {
90 for (const status of [500, 502]) {
91 const error = yield* verify(
92 layerServerReplying({ status, contentType: "text/plain", body: "upstream failed" }),
93 ).pipe(Effect.flip);
94 assert.include(error.detail, `returned HTTP ${status}`);
95 }
96 }),
97 );
98
99 // 1.x (and the 2.x web UI on unknown paths) answers /api/info with 200 HTML.
100 it.effect("rejects a server that answers with the web UI's HTML", () =>
101 Effect.gen(function* () {
102 const error = yield* verify(
103 layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }),
104 ).pipe(Effect.flip);
105 assert.include(error.detail, "is not an OpenCode 2 server");
106 }),
107 );
108
109 it.effect("reports an unreachable server as unreachable", () =>
110 Effect.gen(function* () {
111 const error = yield* verify(FetchHttpClient.layer, "http://127.0.0.1:1").pipe(Effect.flip);
112 assert.include(error.detail, "Could not reach the OpenCode server");
113 }),
114 );
115});
116
117describe("OpenCode2Server error details", () => {
118 // `detail` reaches clients through the provider status message, and a
119 // `serverUrl` can carry credentials in its userinfo or query.
120 const serverUrl = "http://user:url-secret@127.0.0.1:1/?token=query-secret";
121 const detailFor = (httpClient: Layer.Layer<HttpClient.HttpClient>) =>
122 Effect.gen(function* () {
123 const server = yield* OpenCode2Server.make({
124 binaryPath: "opencode",
125 serverUrl,
126 serverPassword: "secret",
127 directory: "/project",
128 environment: {},
129 });
130 const error = yield* server.withConnection(() => Effect.void).pipe(Effect.flip);
131 return error.detail;
132 }).pipe(
133 Effect.provide(
134 Layer.mergeAll(
135 OpenCode2Client.layer.pipe(Layer.provide(httpClient)),
136 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
137 ).pipe(Layer.provideMerge(NodeServices.layer)),
138 ),
139 );
140
141 it.effect("never include the server URL", () =>
142 Effect.gen(function* () {
143 const layerHanging = Layer.succeed(
144 HttpClient.HttpClient,
145 HttpClient.make(() => Effect.never),
146 );
147 const timedOut = yield* detailFor(layerHanging).pipe(Effect.forkChild);
148 yield* TestClock.adjust("10 seconds");
149 const details = [
150 yield* detailFor(FetchHttpClient.layer),
151 yield* detailFor(layerServerReplying({ status: 401, body: "" })),
152 yield* detailFor(layerServerReplying({ status: 502, contentType: "text/plain", body: "" })),
153 yield* detailFor(
154 layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }),
155 ),
156 yield* Fiber.join(timedOut),
157 ];
158 assert.deepStrictEqual(details, [
159 "Could not reach the OpenCode server.",
160 "The OpenCode server rejected the server password.",
161 "The OpenCode server returned HTTP 502.",
162 "The server is not an OpenCode 2 server.",
163 "Timed out waiting for the OpenCode server.",
164 ]);
165 for (const detail of details) {
166 assert.notInclude(detail, "url-secret");
167 assert.notInclude(detail, "query-secret");
168 assert.notInclude(detail, "127.0.0.1");
169 }
170 }).pipe(Effect.provide(TestClock.layer())),
171 );
172});
173
174describe("OpenCode2Server passwords", () => {
175 it.effect("generates a distinct 256-bit password per server", () =>
176 Effect.gen(function* () {
177 const first = yield* OpenCode2Server.generatePassword;
178 const second = yield* OpenCode2Server.generatePassword;
179 assert.match(Redacted.value(first), /^[A-Za-z0-9_-]{43}$/);
180 assert.notStrictEqual(Redacted.value(first), Redacted.value(second));
181 assert.notInclude(String(first), Redacted.value(first));
182 }).pipe(Effect.provide(NodeServices.layer)),
183 );
184
185 it("hands the spawned server only the T3 password", () => {
186 const password = Redacted.make("t3-generated");
187 const environment = OpenCode2Server.serverEnvironment(
188 { PATH: "/bin", OPENCODE_SERVER_PASSWORD: "ambient", OPENCODE_PASSWORD: "ambient" },
189 password,
190 );
191 assert.deepStrictEqual(environment, { PATH: "/bin", OPENCODE_PASSWORD: "t3-generated" });
192 });
193});
194
195// Serves /api/info only with the password from OPENCODE_PASSWORD, like 2.x, and
196// prints the 2.x banner (plus the generated-password line 2.x prints when no
197// password is set, which T3 must never need).
198const FAKE_SERVER = `import { createServer } from "node:http";
199const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64");
200const server = createServer((request, response) => {
201 if (request.headers.authorization !== expected) {
202 response.writeHead(401, { "content-type": "application/json", "www-authenticate": "Basic" });
203 response.end(${JSON.stringify(UNAUTHORIZED_BODY)});
204 return;
205 }
206 response.writeHead(200, { "content-type": "application/json" });
207 response.end(JSON.stringify({ version: "2.0.18", pid: process.pid, urls: [], paths: { tmp: "/tmp" } }));
208});
209server.listen(0, "127.0.0.1", () => {
210 process.stdout.write("server listening on http://127.0.0.1:" + server.address().port + "\\n");
211 process.stdout.write("server password not-the-t3-password\\n");
212});
213`;
214
215describe("OpenCode2Server spawned server", () => {
216 it.live(
217 "is ready once /api/info accepts the generated password",
218 () =>
219 Effect.gen(function* () {
220 const fs = yield* FileSystem.FileSystem;
221 const path = yield* Path.Path;
222 const platform = yield* HostProcessPlatform;
223 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-fake-" });
224 const isWindows = platform === "win32";
225 const binaryPath = path.join(directory, isWindows ? "opencode.cmd" : "opencode");
226 const scriptPath = path.join(directory, "opencode.mjs");
227 yield* fs.writeFileString(scriptPath, FAKE_SERVER);
228 yield* fs.writeFileString(
229 binaryPath,
230 isWindows
231 ? `@echo off\r\n"${yield* HostProcessExecutablePath}" "${scriptPath}" %*\r\n`
232 : `#!/bin/sh\nexec "${yield* HostProcessExecutablePath}" "${scriptPath}" "$@"\n`,
233 );
234 if (!isWindows) yield* fs.chmod(binaryPath, 0o755);
235
236 const server = yield* OpenCode2Server.make({
237 binaryPath,
238 serverUrl: "",
239 serverPassword: "",
240 directory,
241 environment: { ...(yield* HostProcessEnvironment), OPENCODE_SERVER_PASSWORD: "ambient" },
242 });
243 const first = yield* server.withConnection((connection) => Effect.succeed(connection));
244 const second = yield* server.withConnection((connection) => Effect.succeed(connection));
245 assert.strictEqual(first.version, "2.0.18");
246 assert.strictEqual(first.external, false);
247 assert.strictEqual(second.client, first.client);
248 }).pipe(
249 Effect.scoped,
250 Effect.provide(
251 Layer.mergeAll(
252 OpenCode2Client.layer,
253 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
254 ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))),
255 ),
256 ),
257 15_000,
258 );
259});
apps/server/src/provider/opencode2/OpenCode2Server.ts deleted-198
...@@ -1,198 +0,0 @@
1/**
2 * The OpenCode 2 server behind one provider instance. A 2.x server serves every
3 * location from one process, so an instance shares one server across all of its
4 * threads and directories.
5 *
6 * @module provider/opencode2/OpenCode2Server
7 */
8import type { OpenCodeClient } from "@opencode/client/effect";
9import * as Context from "effect/Context";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as Base64Url from "effect/encoding/Base64Url";
13import * as Layer from "effect/Layer";
14import * as P from "effect/Predicate";
15import * as Redacted from "effect/Redacted";
16import * as HttpClientError from "effect/http/HttpClientError";
17
18import { OpenCodeRuntimeError } from "../opencodeRuntime.ts";
19import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
20import * as OpenCode2Client from "./OpenCode2Client.ts";
21
22const INFO_TIMEOUT = "5 seconds";
23
24export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api {
25 readonly url: string;
26 readonly version: string;
27 readonly external: boolean;
28}
29
30export class OpenCode2Server extends Context.Service<
31 OpenCode2Server,
32 {
33 /** Runs `use` against the instance's server, spawning it first when T3 owns it. */
34 readonly withConnection: <A, E, R>(
35 use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>,
36 ) => Effect.Effect<A, E | OpenCodeRuntimeError, R>;
37 }
38>()("t3/provider/opencode2/OpenCode2Server") {}
39
40/**
41 * A fresh password for a spawned server. OpenCode 2 always requires one and
42 * prints a generated one to stdout otherwise, so T3 supplies its own and keeps
43 * it in memory.
44 */
45export const generatePassword = Effect.gen(function* () {
46 const crypto = yield* Crypto.Crypto;
47 const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie);
48 return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" });
49});
50
51/**
52 * The environment for a spawned 2.x server. `OPENCODE_PASSWORD` wins over
53 * `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so the inherited 1.x variable is
54 * dropped to keep the T3 password the only one in play.
55 */
56export const serverEnvironment = (
57 environment: NodeJS.ProcessEnv,
58 password: Redacted.Redacted,
59): NodeJS.ProcessEnv => {
60 const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment;
61 return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) };
62};
63
64/** The client wraps HTTP failures in a `ClientError`; this unwraps either shape. */
65const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => {
66 if (HttpClientError.isHttpClientError(cause)) return cause;
67 if (
68 P.isTagged(cause, "ClientError") &&
69 P.hasProperty(cause, "cause") &&
70 HttpClientError.isHttpClientError(cause.cause)
71 ) {
72 return cause.cause;
73 }
74 return undefined;
75};
76
77/**
78 * Describes a failed `/api/info` call. Only a transport failure means the
79 * server is unreachable; a 401 without the 2.x error body is still a rejected
80 * password (1.x sends it empty), and any other failed status is a server error.
81 * A 2xx the client cannot decode is not OpenCode 2 (1.x answers with HTML).
82 */
83const describeInfoFailure = (cause: unknown) => {
84 const failure = httpFailureOf(cause);
85 if (failure?.reason._tag === "TransportError") {
86 return "Could not reach the OpenCode server.";
87 }
88 const status = failure?.response?.status;
89 if (status === 401) return "The OpenCode server rejected the server password.";
90 if (status !== undefined && (status < 200 || status >= 300)) {
91 return `The OpenCode server returned HTTP ${status}.`;
92 }
93 return "The server is not an OpenCode 2 server.";
94};
95
96/**
97 * Confirms a server is an authenticated OpenCode 2 server through `/api/info`
98 * and returns its version. `/health` and friends answer 200 with the web UI's
99 * HTML on every version, so only this endpoint proves readiness. Details are
100 * fixed text because they reach clients and a `serverUrl` can carry
101 * credentials; the underlying failure stays in `cause`.
102 */
103export const verifyServer = (client: OpenCodeClient) =>
104 client.server.info().pipe(
105 Effect.timeoutOrElse({
106 duration: INFO_TIMEOUT,
107 orElse: () =>
108 Effect.fail(
109 new OpenCodeRuntimeError({
110 operation: "server.info",
111 detail: "Timed out waiting for the OpenCode server.",
112 }),
113 ),
114 }),
115 Effect.catchTags({
116 UnauthorizedError: (cause) =>
117 Effect.fail(
118 new OpenCodeRuntimeError({
119 operation: "server.info",
120 detail: "The OpenCode server rejected the server password.",
121 cause,
122 }),
123 ),
124 }),
125 Effect.mapError((cause) =>
126 OpenCodeRuntimeError.is(cause)
127 ? cause
128 : new OpenCodeRuntimeError({
129 operation: "server.info",
130 detail: describeInfoFailure(cause),
131 cause,
132 }),
133 ),
134 Effect.map((info) => info.version),
135 );
136
137/**
138 * One server per provider instance. With a `serverUrl` it connects to that
139 * server with the configured password; otherwise it spawns `binaryPath serve`
140 * with a generated password through {@link OpenCodeServerOwner}, which shares
141 * the process between borrowers and stops it after an idle period. Clients are
142 * built once per server; a failed check is not remembered.
143 */
144export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
145 readonly binaryPath: string;
146 readonly serverUrl: string;
147 readonly serverPassword: string;
148 readonly directory: string;
149 readonly environment: NodeJS.ProcessEnv;
150}) {
151 const opencode = yield* OpenCode2Client.OpenCode2Client;
152 const connectTo = (url: string, password: Redacted.Redacted, external: boolean) =>
153 Effect.gen(function* () {
154 const api = yield* opencode.connect({ baseUrl: url, password });
155 const version = yield* verifyServer(api.client);
156 return { ...api, url, version, external } satisfies OpenCode2Connection;
157 });
158 let latest: OpenCode2Connection | undefined;
159 const remember = (connection: OpenCode2Connection) =>
160 Effect.sync(() => {
161 latest = connection;
162 return connection.version;
163 });
164
165 const serverUrl = input.serverUrl.trim();
166 if (serverUrl.length > 0) {
167 const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe(
168 Effect.tap(remember),
169 );
170 return OpenCode2Server.of({
171 withConnection: (use) =>
172 Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe(
173 Effect.flatMap(use),
174 ),
175 });
176 }
177
178 const password = yield* generatePassword;
179 const owner = yield* OpenCodeServerOwner.make({
180 binaryPath: input.binaryPath,
181 directory: input.directory,
182 environment: serverEnvironment(input.environment, password),
183 verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)),
184 });
185 return OpenCode2Server.of({
186 withConnection: (use) =>
187 owner.withServer((server) =>
188 // The owner verifies every server it starts before lending it out.
189 latest?.url === server.url
190 ? use(latest)
191 : Effect.die(new Error("OpenCode 2 server was lent before verification.")),
192 ),
193 });
194});
195
196/** Built once per provider instance from its settings; closing it stops a spawned server. */
197export const layer = (input: Parameters<typeof make>[0]) =>
198 Layer.effect(OpenCode2Server, make(input));
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts deleted-335
...@@ -1,335 +0,0 @@
1import * as NodeAssert from "node:assert/strict";
2
3import { describe, it } from "vite-plus/test";
4
5import {
6 parseAgentListCliOutput,
7 parseModelsCliOutput,
8 parseSkillsCliOutput,
9 toOpenCodeFileParts,
10} from "./opencodeRuntime.ts";
11
12describe("parseModelsCliOutput", () => {
13 it("parses a single model from a single provider", () => {
14 const stdout = [
15 "anthropic/claude-sonnet-4-5",
16 JSON.stringify({
17 id: "claude-sonnet-4-5",
18 providerID: "anthropic",
19 name: "Claude Sonnet 4.5",
20 capabilities: { temperature: true, reasoning: true, toolcall: true },
21 cost: { input: 3, output: 15 },
22 limit: { context: 200000, output: 8192 },
23 status: "active",
24 options: {},
25 headers: {},
26 release_date: "2025-01-01",
27 }),
28 ].join("\n");
29
30 const result = parseModelsCliOutput(stdout);
31 NodeAssert.equal(result.providers.size, 1);
32 NodeAssert.equal(result.connected.length, 1);
33 NodeAssert.equal(result.connected[0], "anthropic");
34
35 const provider = result.providers.get("anthropic")!;
36 NodeAssert.ok(provider);
37 NodeAssert.equal(provider.id, "anthropic");
38 NodeAssert.equal(provider.name, "anthropic");
39 NodeAssert.equal(Object.keys(provider.models).length, 1);
40
41 const model = provider.models["claude-sonnet-4-5"]!;
42 NodeAssert.ok(model);
43 NodeAssert.equal(model.id, "claude-sonnet-4-5");
44 NodeAssert.equal(model.providerID, "anthropic");
45 NodeAssert.equal(model.name, "Claude Sonnet 4.5");
46 });
47
48 it("parses multiple models from multiple providers", () => {
49 const stdout = [
50 "anthropic/claude-sonnet-4-5",
51 JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }),
52 "anthropic/claude-haiku-4-5",
53 JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }),
54 "openai/gpt-4o",
55 JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }),
56 ].join("\n");
57
58 const result = parseModelsCliOutput(stdout);
59 NodeAssert.equal(result.providers.size, 2);
60 NodeAssert.equal(result.connected.length, 2);
61 NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai");
62 NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2);
63 NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1);
64 });
65
66 it("handles empty input", () => {
67 const result = parseModelsCliOutput("");
68 NodeAssert.equal(result.providers.size, 0);
69 NodeAssert.equal(result.connected.length, 0);
70 });
71
72 it("skips unparseable JSON blocks", () => {
73 const stdout = [
74 "anthropic/claude-sonnet-4-5",
75 "this is not valid json {{{",
76 "anthropic/claude-haiku-4-5",
77 JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }),
78 ].join("\n");
79
80 const result = parseModelsCliOutput(stdout);
81 NodeAssert.equal(result.providers.size, 1);
82 const provider = result.providers.get("anthropic")!;
83 NodeAssert.equal(Object.keys(provider.models).length, 1);
84 NodeAssert.ok(provider.models["claude-haiku-4-5"]);
85 });
86
87 it("handles Windows-style CRLF line endings", () => {
88 const stdout =
89 "anthropic/claude-sonnet-4-5\r\n" +
90 JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) +
91 "\r\n";
92
93 const result = parseModelsCliOutput(stdout);
94 NodeAssert.equal(result.providers.size, 1);
95 NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]);
96 });
97
98 it("handles model JSON with variants and nested fields", () => {
99 const stdout = [
100 "opencode/gpt-5.4",
101 JSON.stringify({
102 id: "gpt-5.4",
103 providerID: "opencode",
104 name: "GPT-5.4",
105 family: "gpt",
106 capabilities: {
107 temperature: true,
108 reasoning: true,
109 attachment: false,
110 toolcall: true,
111 input: { text: true, audio: false, image: false, video: false, pdf: false },
112 output: { text: true, audio: false, image: false, video: false, pdf: false },
113 interleaved: false,
114 },
115 cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
116 limit: { context: 200000, input: 160000, output: 32000 },
117 status: "active",
118 options: {},
119 headers: {},
120 release_date: "2025-01-01",
121 variants: { none: {}, low: {}, medium: {}, high: {} },
122 }),
123 ].join("\n");
124
125 const result = parseModelsCliOutput(stdout);
126 const model = result.providers.get("opencode")!.models["gpt-5.4"]!;
127 NodeAssert.ok(model);
128 NodeAssert.ok(model.capabilities);
129 NodeAssert.equal(model.capabilities!.reasoning, true);
130 NodeAssert.ok(model.variants);
131 NodeAssert.equal(model.variants!["medium"] !== undefined, true);
132 });
133
134 it("keeps a model whose JSON body has a slash and no interior whitespace", () => {
135 // OpenRouter-style: the model id contains a `/` and no string value has a
136 // space, so the JSON body line itself matches the slug regex. It must still
137 // be treated as the body of the preceding slug, not a new slug.
138 const stdout = [
139 "openrouter/qwen/qwen3-coder",
140 JSON.stringify({
141 id: "qwen/qwen3-coder",
142 providerID: "openrouter",
143 name: "qwen3-coder",
144 status: "active",
145 }),
146 ].join("\n");
147
148 const result = parseModelsCliOutput(stdout);
149 NodeAssert.equal(result.providers.size, 1);
150 NodeAssert.deepEqual([...result.connected], ["openrouter"]);
151 const provider = result.providers.get("openrouter")!;
152 NodeAssert.ok(provider);
153 const model = provider.models["qwen/qwen3-coder"]!;
154 NodeAssert.ok(model);
155 NodeAssert.equal(model.id, "qwen/qwen3-coder");
156 NodeAssert.equal(model.providerID, "openrouter");
157 });
158});
159
160describe("parseAgentListCliOutput", () => {
161 it("parses a single agent", () => {
162 const stdout = [
163 "build (primary)",
164 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
165 ].join("\n");
166
167 const result = parseAgentListCliOutput(stdout);
168 NodeAssert.equal(result.length, 1);
169 NodeAssert.equal(result[0]!.name, "build");
170 NodeAssert.equal(result[0]!.mode, "primary");
171 NodeAssert.equal(result[0]!.permission.length, 1);
172 });
173
174 it("parses multiple agents", () => {
175 const stdout = [
176 "build (primary)",
177 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
178 "explore (subagent)",
179 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
180 "plan (primary)",
181 " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]),
182 ].join("\n");
183
184 const result = parseAgentListCliOutput(stdout);
185 NodeAssert.equal(result.length, 3);
186 NodeAssert.equal(result[0]!.name, "build");
187 NodeAssert.equal(result[0]!.mode, "primary");
188 NodeAssert.equal(result[1]!.name, "explore");
189 NodeAssert.equal(result[1]!.mode, "subagent");
190 NodeAssert.equal(result[2]!.name, "plan");
191 NodeAssert.equal(result[2]!.mode, "primary");
192 });
193
194 it("handles empty input", () => {
195 const result = parseAgentListCliOutput("");
196 NodeAssert.equal(result.length, 0);
197 });
198
199 it("skips agents with unparseable permission JSON", () => {
200 const stdout = [
201 "build (primary)",
202 " not valid json {",
203 "explore (subagent)",
204 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
205 ].join("\n");
206
207 const result = parseAgentListCliOutput(stdout);
208 NodeAssert.equal(result.length, 1);
209 NodeAssert.equal(result[0]!.name, "explore");
210 });
211
212 it("handles real-world permission blocks with nested paths", () => {
213 const permissions = [
214 { permission: "*", action: "allow", pattern: "*" },
215 {
216 permission: "external_directory",
217 pattern: "C:\\Users\\test\\.local\\*",
218 action: "allow",
219 },
220 { permission: "read", pattern: "*.env", action: "ask" },
221 ];
222 const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n");
223
224 const result = parseAgentListCliOutput(stdout);
225 NodeAssert.equal(result.length, 1);
226 NodeAssert.equal(result[0]!.permission.length, 3);
227 NodeAssert.equal(result[0]!.permission[0]!.action, "allow");
228 NodeAssert.equal(result[0]!.permission[2]!.action, "ask");
229 });
230
231 it("handles agent names with spaces", () => {
232 const stdout = [
233 "code reviewer (subagent)",
234 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
235 "my custom agent (primary)",
236 " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]),
237 ].join("\n");
238
239 const result = parseAgentListCliOutput(stdout);
240 NodeAssert.equal(result.length, 2);
241 NodeAssert.equal(result[0]!.name, "code reviewer");
242 NodeAssert.equal(result[0]!.mode, "subagent");
243 NodeAssert.equal(result[1]!.name, "my custom agent");
244 NodeAssert.equal(result[1]!.mode, "primary");
245 });
246
247 it("marks known hidden agents", () => {
248 const stdout = [
249 "compaction (primary)",
250 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
251 "build (primary)",
252 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
253 ].join("\n");
254
255 const result = parseAgentListCliOutput(stdout);
256 NodeAssert.equal(result[0]!.hidden, true);
257 NodeAssert.equal(result[1]!.hidden, false);
258 });
259});
260
261describe("parseSkillsCliOutput", () => {
262 it("parses only skill metadata from the CLI JSON output", () => {
263 const result = parseSkillsCliOutput(
264 JSON.stringify([
265 {
266 name: "review-pr",
267 description: "Review a pull request.",
268 location: "/tmp/review-pr/SKILL.md",
269 content: "---\nname: review-pr\n---\n",
270 },
271 ]),
272 );
273
274 NodeAssert.deepEqual(result, [
275 {
276 name: "review-pr",
277 description: "Review a pull request.",
278 location: "/tmp/review-pr/SKILL.md",
279 },
280 ]);
281 });
282
283 it("degrades malformed output to an empty skill list", () => {
284 NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []);
285 });
286});
287
288describe("toOpenCodeFileParts", () => {
289 const attachment = (mimeType: string, sizeBytes = 12) => ({
290 type: "file" as const,
291 id: "thread-1-00000000-0000-4000-8000-000000000001-bin",
292 name: "attachment",
293 mimeType,
294 sizeBytes,
295 });
296
297 it("sends supported images, text, and PDFs natively and skips what models reject", () => {
298 const parts = toOpenCodeFileParts({
299 attachments: [
300 attachment("application/pdf"),
301 attachment("text/markdown"),
302 attachment("image/png"),
303 // A ZIP file part makes OpenCode's Anthropic path throw before the
304 // turn starts; it must ride only as the prompt's file path line.
305 attachment("application/zip"),
306 attachment("application/octet-stream"),
307 // Image formats the model APIs reject stay on the fallback path too.
308 attachment("image/bmp"),
309 attachment("image/svg+xml"),
310 // Over the direct-attachment limit: path fallback even for a PDF.
311 attachment("application/pdf", 21 * 1024 * 1024),
312 ],
313 resolveAttachmentPath: () => "/tmp/attachment",
314 });
315
316 NodeAssert.deepEqual(
317 parts.map((part) => part.mime),
318 ["application/pdf", "text/markdown", "image/png"],
319 );
320 });
321
322 it("keeps folded clipboard text on the lazy path fallback", () => {
323 const parts = toOpenCodeFileParts({
324 attachments: [
325 {
326 ...attachment("text/plain"),
327 source: { _tag: "pasted-text" as const },
328 },
329 ],
330 resolveAttachmentPath: () => "/tmp/pasted-text.txt",
331 });
332
333 NodeAssert.deepEqual(parts, []);
334 });
335});
apps/server/src/provider/opencodeRuntime.environment.test.ts deleted-239
...@@ -1,239 +0,0 @@
1import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it as effectIt } from "@effect/vitest";
4import {
5 HostProcessEnvironment,
6 HostProcessExecutablePath,
7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as TestClock from "effect/testing/TestClock";
15import { FetchHttpClient, HttpClient } from "effect/http";
16import { describe, expect, it } from "vite-plus/test";
17
18import * as OpenCodeRuntime from "./opencodeRuntime.ts";
19import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
20
21describe("resolveOpenCodeConfigContent", () => {
22 it("prefers the caller environment over the inherited environment", () => {
23 expect(
24 OpenCodeRuntime.resolveOpenCodeConfigContent(
25 { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' },
26 { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' },
27 ),
28 ).toBe('{"source":"caller"}');
29 });
30
31 it("falls back to the inherited environment and then an empty config", () => {
32 expect(
33 OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {
34 OPENCODE_CONFIG_CONTENT: '{"source":"process"}',
35 }),
36 ).toBe('{"source":"process"}');
37 expect(OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {})).toBe("{}");
38 });
39});
40
41describe("resolveOpenCodeServerPassword", () => {
42 it("uses the local environment password when settings do not provide one", () => {
43 expect(
44 OpenCodeRuntime.resolveOpenCodeServerPassword(
45 { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } },
46 {},
47 ),
48 ).toBe(" env password ");
49 });
50
51 it("uses the settings password for a local server", () => {
52 expect(
53 OpenCodeRuntime.resolveOpenCodeServerPassword(
54 { external: false, serverPassword: " settings password " },
55 {},
56 ),
57 ).toBe(" settings password ");
58 });
59
60 it("uses the settings password when local settings and environment differ", () => {
61 expect(
62 OpenCodeRuntime.resolveOpenCodeServerPassword(
63 {
64 external: false,
65 serverPassword: "settings-password",
66 environment: { OPENCODE_SERVER_PASSWORD: "environment-password" },
67 },
68 {},
69 ),
70 ).toBe("settings-password");
71 });
72
73 it("does not send an inherited local password to an external server", () => {
74 expect(
75 OpenCodeRuntime.resolveOpenCodeServerPassword(
76 { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } },
77 { OPENCODE_SERVER_PASSWORD: "inherited-secret" },
78 ),
79 ).toBeUndefined();
80 });
81});
82
83function makeHealthClient(
84 result: (options?: { readonly signal?: AbortSignal }) => Promise<unknown>,
85): OpencodeClient {
86 return {
87 global: {
88 health: result,
89 },
90 } as unknown as OpencodeClient;
91}
92
93describe("verifyOpenCodeServerVersion", () => {
94 effectIt.effect("accepts a supported server version", () =>
95 Effect.gen(function* () {
96 const version = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
97 makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })),
98 );
99 expect(version).toBe("1.14.19");
100 }),
101 );
102
103 effectIt.effect("rejects a server below the supported version", () =>
104 Effect.gen(function* () {
105 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
106 makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })),
107 ).pipe(Effect.flip);
108 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
109 expect(error.detail).toContain("v1.14.18 is too old");
110 }),
111 );
112
113 for (const data of [
114 { healthy: true },
115 { healthy: true, version: "not-a-version" },
116 { healthy: false, version: "1.14.19" },
117 ]) {
118 effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () =>
119 Effect.gen(function* () {
120 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
121 makeHealthClient(() => Promise.resolve({ data })),
122 ).pipe(Effect.flip);
123 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
124 expect(error.detail).toContain("requires OpenCode v1.14.19 or newer");
125 }),
126 );
127 }
128
129 effectIt.effect("preserves an unauthorized health error", () =>
130 Effect.gen(function* () {
131 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
132 makeHealthClient(() =>
133 Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }),
134 ),
135 ).pipe(Effect.flip);
136 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
137 expect(error.detail).toContain("status=401");
138 expect(error.detail).toContain("Unauthorized");
139 }),
140 );
141
142 effectIt.effect("aborts a health request when the version check times out", () =>
143 Effect.gen(function* () {
144 let requestSignal: AbortSignal | undefined;
145 const checkFiber = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
146 makeHealthClient((options) => {
147 requestSignal = options?.signal;
148 return new Promise(() => undefined);
149 }),
150 ).pipe(Effect.flip, Effect.forkChild);
151
152 yield* Effect.yieldNow;
153 expect(requestSignal).toBeDefined();
154 yield* TestClock.adjust("6 seconds");
155
156 const error = yield* Fiber.join(checkFiber);
157 expect(error.detail).toBe("Timed out while checking the OpenCode server version.");
158 expect(requestSignal?.aborted).toBe(true);
159 }).pipe(Effect.provide(TestClock.layer())),
160 );
161});
162
163describe("OpenCode server output", () => {
164 effectIt.live(
165 "drains stdout and stderr after startup so server requests can finish",
166 () =>
167 Effect.gen(function* () {
168 const fs = yield* FileSystem.FileSystem;
169 const path = yield* Path.Path;
170 const environment = yield* HostProcessEnvironment;
171 const executablePath = yield* HostProcessExecutablePath;
172 const platform = yield* HostProcessPlatform;
173 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" });
174 const isWindows = platform === "win32";
175 const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode");
176 const scriptPath = path.join(tempDir, "opencode.mjs");
177
178 yield* fs.writeFileString(
179 scriptPath,
180 `import { createServer } from "node:http";
181const writeOutput = (stream) => new Promise((resolve, reject) => {
182 stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve());
183});
184const server = createServer(async (request, response) => {
185 if (request.url.startsWith("/global/health")) {
186 response.setHeader("Content-Type", "application/json");
187 response.end(JSON.stringify({ healthy: true, version: "1.14.19" }));
188 return;
189 }
190 await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]);
191 response.end("drained");
192});
193server.listen(0, "127.0.0.1", () => {
194 process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n");
195});
196`,
197 );
198 yield* fs.writeFileString(
199 binaryPath,
200 [
201 ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]),
202 isWindows
203 ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*'
204 : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"',
205 "",
206 ].join("\n"),
207 );
208 if (!isWindows) {
209 yield* fs.chmod(binaryPath, 0o755);
210 }
211
212 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
213 const server = yield* runtime.startOpenCodeServerProcess({
214 binaryPath,
215 directory: tempDir,
216 port: 0,
217 environment: {
218 ...environment,
219 T3_TEST_NODE_BINARY: executablePath,
220 T3_TEST_OPENCODE_SCRIPT: scriptPath,
221 },
222 });
223 const response = yield* HttpClient.get(`${server.url}/output`);
224
225 expect(yield* response.text).toBe("drained");
226 expect(yield* server.isRunning).toBe(true);
227 }).pipe(
228 Effect.scoped,
229 Effect.provide([
230 OpenCodeRuntime.layer.pipe(
231 Layer.provide(OpenCodeServerLedger.layerTest),
232 Layer.provideMerge(NodeServices.layer),
233 ),
234 FetchHttpClient.layer,
235 ]),
236 ),
237 10_000,
238 );
239});
apps/server/src/provider/opencodeRuntime.inventory.test.ts deleted-290
...@@ -1,290 +0,0 @@
1import * as NodeAssert from "node:assert/strict";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
5import { it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Path from "effect/Path";
11import * as Queue from "effect/Queue";
12import {
13 HostProcessEnvironment,
14 HostProcessExecutablePath,
15 HostProcessPlatform,
16} from "@t3tools/shared/hostProcess";
17
18import * as OpenCodeRuntime from "./opencodeRuntime.ts";
19import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
20
21const layerTest = OpenCodeRuntime.layer.pipe(
22 Layer.provide(OpenCodeServerLedger.layerTest),
23 Layer.provideMerge(NodeServices.layer),
24);
25
26it.layer(layerTest)("OpenCodeRuntime inventory", (it) => {
27 it.effect("aborts pending SDK requests when inventory loading is interrupted", () =>
28 Effect.gen(function* () {
29 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
30 const started = yield* Queue.make<void>();
31 const aborted = yield* Queue.make<string>();
32 const client = createOpencodeClient({
33 baseUrl: "http://opencode.test",
34 fetch: Object.assign(
35 (input: string | Request | URL) => {
36 const request = input instanceof Request ? input : new Request(input.toString());
37 return new Promise<Response>((_resolve, reject) => {
38 request.signal.addEventListener(
39 "abort",
40 () => {
41 Queue.offerUnsafe(aborted, new URL(request.url).pathname);
42 reject(request.signal.reason);
43 },
44 { once: true },
45 );
46 Queue.offerUnsafe(started, undefined);
47 });
48 },
49 { preconnect: () => undefined },
50 ),
51 });
52
53 const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild);
54 yield* Queue.takeN(started, 4);
55 yield* Fiber.interrupt(inventoryFiber);
56
57 NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [
58 "/agent",
59 "/command",
60 "/provider",
61 "/skill",
62 ]);
63 }),
64 );
65
66 it.effect("discovers directory-scoped commands without retaining prompt templates", () =>
67 Effect.gen(function* () {
68 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
69 const requests: Request[] = [];
70 const client = createOpencodeClient({
71 baseUrl: "http://opencode.test",
72 directory: "/workspace/project",
73 fetch: Object.assign(
74 async (input: string | Request | URL) => {
75 const request = input instanceof Request ? input : new Request(input.toString());
76 requests.push(request);
77 const route = new URL(request.url).pathname;
78 return Response.json(
79 route === "/provider"
80 ? { connected: ["openai"], all: [], default: {} }
81 : route === "/command"
82 ? [
83 {
84 name: "review",
85 description: "Review changes",
86 source: "command",
87 hints: ["$ARGUMENTS"],
88 template: "private native template",
89 },
90 ]
91 : [],
92 );
93 },
94 { preconnect: () => undefined },
95 ),
96 });
97 const inventory = yield* runtime.loadOpenCodeInventory(client);
98 NodeAssert.deepEqual(inventory.commands, [
99 {
100 name: "review",
101 description: "Review changes",
102 source: "command",
103 hints: ["$ARGUMENTS"],
104 },
105 ]);
106 const commandRequest = requests.find(
107 (request) => new URL(request.url).pathname === "/command",
108 );
109 NodeAssert.ok(commandRequest);
110 NodeAssert.equal(
111 new URL(commandRequest.url).searchParams.get("directory") ??
112 decodeURIComponent(commandRequest.headers.get("x-opencode-directory") ?? ""),
113 "/workspace/project",
114 );
115 }),
116 );
117
118 it.effect("keeps provider inventory when agent discovery fails", () =>
119 Effect.gen(function* () {
120 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
121 const client = {
122 provider: {
123 list: () =>
124 Promise.resolve({
125 data: {
126 connected: ["openai"],
127 all: [],
128 default: {},
129 },
130 }),
131 },
132 app: {
133 agents: () => Promise.reject(new Error("agents endpoint unavailable")),
134 skills: () => Promise.resolve({ data: [] }),
135 },
136 } as unknown as OpencodeClient;
137
138 const inventory = yield* runtime.loadOpenCodeInventory(client);
139
140 NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
141 NodeAssert.deepEqual(inventory.agents, []);
142 NodeAssert.deepEqual(inventory.skills, []);
143 }),
144 );
145
146 it.effect("keeps provider inventory when skill discovery fails", () =>
147 Effect.gen(function* () {
148 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
149 const client = {
150 provider: {
151 list: () =>
152 Promise.resolve({
153 data: {
154 connected: ["openai"],
155 all: [],
156 default: {},
157 },
158 }),
159 },
160 app: {
161 agents: () => Promise.resolve({ data: [] }),
162 skills: () => Promise.reject(new Error("skills endpoint unavailable")),
163 },
164 } as unknown as OpencodeClient;
165
166 const inventory = yield* runtime.loadOpenCodeInventory(client);
167
168 NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
169 NodeAssert.deepEqual(inventory.agents, []);
170 NodeAssert.deepEqual(inventory.skills, []);
171 }),
172 );
173
174 it.effect("keeps only SDK skill metadata in inventory", () =>
175 Effect.gen(function* () {
176 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
177 const client = {
178 provider: {
179 list: () =>
180 Promise.resolve({
181 data: {
182 connected: ["openai"],
183 all: [],
184 default: {},
185 },
186 }),
187 },
188 app: {
189 agents: () => Promise.resolve({ data: [] }),
190 skills: () =>
191 Promise.resolve({
192 data: [
193 {
194 name: "review",
195 description: "Review code changes",
196 location: "/skills/review/SKILL.md",
197 content: "unused skill content",
198 },
199 ],
200 }),
201 },
202 } as unknown as OpencodeClient;
203
204 const inventory = yield* runtime.loadOpenCodeInventory(client);
205
206 NodeAssert.deepEqual(inventory.skills, [
207 {
208 name: "review",
209 description: "Review code changes",
210 location: "/skills/review/SKILL.md",
211 },
212 ]);
213 }),
214 );
215
216 it.effect("drops oversized CLI skill output without losing the model inventory", () =>
217 Effect.gen(function* () {
218 const fs = yield* FileSystem.FileSystem;
219 const path = yield* Path.Path;
220 const hostEnvironment = yield* HostProcessEnvironment;
221 const executablePath = yield* HostProcessExecutablePath;
222 const hostPlatform = yield* HostProcessPlatform;
223 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" });
224 const isWindows = hostPlatform === "win32";
225 const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode");
226 const scriptPath = path.join(tempDir, "opencode.mjs");
227 const oversizedContentBytes = 8 * 1024 * 1024 + 1;
228
229 yield* fs.writeFileString(
230 scriptPath,
231 [
232 'if (process.argv[2] === "models") {',
233 ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);',
234 '} else if (process.argv[2] === "debug") {',
235 ` const content = "x".repeat(${oversizedContentBytes});`,
236 ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);',
237 "}",
238 "",
239 ].join("\n"),
240 );
241 yield* fs.writeFileString(
242 binaryPath,
243 [
244 ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]),
245 isWindows
246 ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*'
247 : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"',
248 "",
249 ].join("\n"),
250 );
251 if (!isWindows) {
252 yield* fs.chmod(binaryPath, 0o755);
253 }
254
255 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
256 const inventory = yield* runtime.loadInventoryFromCli({
257 binaryPath,
258 cwd: tempDir,
259 environment: {
260 ...hostEnvironment,
261 T3_TEST_NODE_BINARY: executablePath,
262 T3_TEST_OPENCODE_SCRIPT: scriptPath,
263 },
264 });
265
266 NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
267 NodeAssert.equal(inventory.skills.length, 0);
268 }),
269 );
270
271 it.effect("caps and drains command stdout and stderr when requested", () =>
272 Effect.gen(function* () {
273 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
274 const executablePath = yield* HostProcessExecutablePath;
275 const outputBytes = 2 * 1024 * 1024;
276 const result = yield* runtime.runOpenCodeCommand({
277 binaryPath: executablePath,
278 args: [
279 "-e",
280 `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`,
281 ],
282 maxOutputBytes: 64,
283 });
284
285 NodeAssert.equal(result.stdout, "o".repeat(64));
286 NodeAssert.equal(result.stderr, "e".repeat(64));
287 NodeAssert.equal(result.code, 0);
288 }),
289 );
290});
apps/server/src/provider/opencodeRuntime.permissions.test.ts deleted-89
...@@ -1,89 +0,0 @@
1import * as NodeAssert from "node:assert/strict";
2
3import * as RegExpUtils from "effect/RegExp";
4import { describe, it } from "vite-plus/test";
5
6import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts";
7
8function actionFor(
9 runtimeMode: Parameters<typeof buildOpenCodePermissionRules>[0],
10 permission: string,
11 target = "*",
12) {
13 // OpenCode uses the last matching rule. Its wildcards match directory separators.
14 return buildOpenCodePermissionRules(runtimeMode).findLast(
15 (rule) =>
16 (rule.permission === "*" || rule.permission === permission) &&
17 new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target),
18 )?.action;
19}
20
21describe("buildOpenCodePermissionRules", () => {
22 it("pre-approves edits once the user has chosen to auto-accept them", () => {
23 NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow");
24 });
25
26 it("still asks before editing when approval is required", () => {
27 NodeAssert.equal(actionFor("approval-required", "edit"), "ask");
28 });
29
30 // Documented in docs/user/permission-modes.md: providers without an AI
31 // reviewer, OpenCode among them, fall back to Supervised for "auto".
32 it("leaves auto asking, as the docs say it does without a reviewer", () => {
33 NodeAssert.equal(actionFor("auto", "edit"), "ask");
34 });
35
36 it("allows workspace reads and task updates without asking in supervised modes", () => {
37 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
38 for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) {
39 NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow");
40 }
41 }
42 });
43
44 it("preserves OpenCode's environment-file approval rules", () => {
45 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
46 for (const target of [
47 ".env",
48 ".env.local",
49 "config/service.env",
50 "config/service.env.local",
51 ]) {
52 NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask");
53 }
54 for (const target of [".env.example", "config/service.env.example"]) {
55 NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow");
56 }
57 }
58 });
59
60 it("still asks before commands, network access, external directories and unknown tools", () => {
61 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
62 NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask");
63 NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask");
64 NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask");
65 NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask");
66 NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask");
67 NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask");
68 }
69 });
70
71 it("allows everything only under full access", () => {
72 NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [
73 { permission: "*", pattern: "*", action: "allow" },
74 { permission: "external_directory", pattern: "*", action: "allow" },
75 ]);
76 });
77});
78
79describe("toOpenCodePermissionReply", () => {
80 it.each([
81 ["accept", "once"],
82 ["acceptForSession", "always"],
83 ["acceptAlways", "always"],
84 ["decline", "reject"],
85 ["cancel", "reject"],
86 ] as const)("maps %s to %s", (decision, reply) => {
87 NodeAssert.equal(toOpenCodePermissionReply(decision), reply);
88 });
89});
apps/server/src/provider/opencodeRuntime.ts deleted-1111
...@@ -1,1111 +0,0 @@
1import * as NodeURL from "node:url";
2
3import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";
4import {
5 createOpencodeClient,
6 type Agent,
7 type Command,
8 type FilePartInput,
9 type Model,
10 type OpencodeClient,
11 type PermissionRuleset,
12 type ProviderListResponse,
13 type QuestionAnswer,
14 type QuestionRequest,
15} from "@opencode-ai/sdk/v2";
16import * as Cause from "effect/Cause";
17import * as Context from "effect/Context";
18import * as Data from "effect/Data";
19import * as Deferred from "effect/Deferred";
20import * as Effect from "effect/Effect";
21import * as Exit from "effect/Exit";
22import * as Fiber from "effect/Fiber";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import * as P from "effect/Predicate";
26import * as Ref from "effect/Ref";
27import * as Result from "effect/Result";
28import * as Scope from "effect/Scope";
29import * as Schema from "effect/Schema";
30import * as Stream from "effect/Stream";
31import { ChildProcess, ChildProcessSpawner } from "effect/process";
32
33import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
34import { isWindowsCommandNotFound } from "../processRunner.ts";
35import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
36import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe";
37import * as NetService from "@t3tools/shared/Net";
38import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
39import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
40import { resolveSpawnCommand } from "@t3tools/shared/shell";
41const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
42const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";
43
44export const MINIMUM_OPENCODE_VERSION = "1.14.19";
45const OPENCODE_HEALTH_TIMEOUT = "5 seconds";
46
47const OpenCodeHealthSchema = Schema.Struct({
48 healthy: Schema.Literal(true),
49 version: Schema.String,
50});
51const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema);
52
53export function resolveOpenCodeConfigContent(
54 inputEnvironment: Readonly<Record<string, string | undefined>> | undefined,
55 inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,
56): string {
57 return (
58 inputEnvironment?.OPENCODE_CONFIG_CONTENT ??
59 inheritedEnvironment.OPENCODE_CONFIG_CONTENT ??
60 OPENCODE_EMPTY_CONFIG_CONTENT
61 );
62}
63
64export function resolveOpenCodeServerPassword(
65 input: {
66 readonly external: boolean;
67 readonly serverPassword?: string;
68 readonly environment?: Readonly<Record<string, string | undefined>>;
69 },
70 inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,
71): string | undefined {
72 if (input.serverPassword !== undefined) {
73 return input.serverPassword;
74 }
75 if (input.external) {
76 return undefined;
77 }
78 return input.environment === undefined
79 ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD
80 : input.environment.OPENCODE_SERVER_PASSWORD;
81}
82
83const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
84const DEFAULT_HOSTNAME = "127.0.0.1";
85const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024;
86const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
87export interface OpenCodeServerProcess {
88 readonly url: string;
89 readonly serverPassword?: string;
90 readonly version: string;
91 readonly isRunning: Effect.Effect<boolean>;
92 readonly exitCode: Effect.Effect<number, never>;
93}
94
95export interface OpenCodeServerConnection {
96 readonly url: string;
97 readonly serverPassword?: string;
98 readonly version: string;
99 readonly exitCode: Effect.Effect<number, never> | null;
100 readonly external: boolean;
101}
102
103const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";
104export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{
105 readonly operation: string;
106 readonly cause?: unknown;
107 readonly detail: string;
108}> {
109 static readonly is = (u: unknown): u is OpenCodeRuntimeError =>
110 P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);
111}
112
113function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
114 const result = encodeUnknownJsonStringExit(input);
115 return Exit.isSuccess(result) ? result.value : undefined;
116}
117
118export function openCodeRuntimeErrorDetail(cause: unknown): string {
119 if (OpenCodeRuntimeError.is(cause)) return cause.detail;
120 if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim();
121 if (cause && typeof cause === "object") {
122 // SDK v2 throws { response, request, error? } shapes — extract what's useful
123 const anyCause = cause as Record<string, unknown>;
124 const status = (anyCause.response as { status?: number } | undefined)?.status;
125 const body = anyCause.error ?? anyCause.data ?? anyCause.body;
126 const encodedBody = encodeJsonStringForDiagnostics(body ?? cause);
127 if (encodedBody) {
128 return `status=${status ?? "?"} body=${encodedBody}`;
129 }
130 }
131 return String(cause);
132}
133
134export const runOpenCodeSdk = <A>(
135 operation: string,
136 fn: (signal: AbortSignal) => Promise<A>,
137): Effect.Effect<A, OpenCodeRuntimeError> =>
138 Effect.tryPromise({
139 try: fn,
140 catch: (cause) =>
141 new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }),
142 }).pipe(Effect.withSpan(`opencode.${operation}`));
143
144export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* (
145 client: OpencodeClient,
146) {
147 const healthOption = yield* runOpenCodeSdk("global.health", (signal) =>
148 client.global.health({ signal }),
149 ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT));
150 if (Option.isNone(healthOption)) {
151 return yield* new OpenCodeRuntimeError({
152 operation: "global.health",
153 detail: "Timed out while checking the OpenCode server version.",
154 });
155 }
156
157 const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe(
158 Effect.mapError(
159 (cause) =>
160 new OpenCodeRuntimeError({
161 operation: "global.health",
162 detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,
163 cause,
164 }),
165 ),
166 );
167 if (parseSemver(health.version) === null) {
168 return yield* new OpenCodeRuntimeError({
169 operation: "global.health",
170 detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,
171 });
172 }
173 if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) {
174 return yield* new OpenCodeRuntimeError({
175 operation: "global.health",
176 detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`,
177 });
178 }
179 return health.version;
180});
181
182export interface OpenCodeCommandResult {
183 readonly stdout: string;
184 readonly stderr: string;
185 readonly code: number;
186}
187
188export interface OpenCodeInventory {
189 readonly providerList: ProviderListResponse;
190 readonly agents: ReadonlyArray<Agent>;
191 readonly skills: ReadonlyArray<OpenCodeSkill>;
192 readonly commands?: ReadonlyArray<OpenCodeSlashCommand>;
193}
194
195export type OpenCodeSlashCommand = Pick<Command, "name" | "description" | "source" | "hints">;
196
197/** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */
198export const loadOpenCodeCommands = (client: OpencodeClient) =>
199 runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe(
200 Effect.map((result): ReadonlyArray<OpenCodeSlashCommand> =>
201 (result.data ?? []).map(({ name, description, source, hints }) => ({
202 name,
203 ...(description === undefined ? {} : { description }),
204 ...(source === undefined ? {} : { source }),
205 hints,
206 })),
207 ),
208 );
209
210export interface ParsedOpenCodeModelSlug {
211 readonly providerID: string;
212 readonly modelID: string;
213}
214
215export interface OpenCodeSkill {
216 readonly name?: string | null;
217 readonly description?: string | null;
218 readonly location?: string | null;
219}
220
221const OpenCodeSkillSchema = Schema.Struct({
222 name: Schema.optionalKey(Schema.NullOr(Schema.String)),
223 description: Schema.optionalKey(Schema.NullOr(Schema.String)),
224 location: Schema.optionalKey(Schema.NullOr(Schema.String)),
225});
226const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit(
227 Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)),
228);
229
230export interface OpenCodeRuntimeShape {
231 /**
232 * Spawns a local OpenCode server process. Its lifetime is bound to the caller's
233 * `Scope.Scope` — the child is killed automatically when that scope closes.
234 * Consumers that want a long-lived server must create and hold a scope explicitly
235 * (see {@link Scope.make}) and close it when done.
236 */
237 readonly startOpenCodeServerProcess: (input: {
238 readonly binaryPath: string;
239 readonly directory: string;
240 readonly serverPassword?: string;
241 readonly environment?: NodeJS.ProcessEnv;
242 readonly port?: number;
243 readonly hostname?: string;
244 readonly timeoutMs?: number;
245 /** Checks the listening server and returns its version. Defaults to the 1.x health check. */
246 readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntimeError>;
247 }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>;
248 /**
249 * Returns a handle to either an externally-managed OpenCode server (when
250 * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a
251 * freshly spawned local server whose lifetime is bound to the caller's scope.
252 */
253 readonly connectToOpenCodeServer: (input: {
254 readonly binaryPath: string;
255 readonly directory: string;
256 readonly serverUrl?: string | null;
257 readonly serverPassword?: string;
258 readonly environment?: NodeJS.ProcessEnv;
259 readonly port?: number;
260 readonly hostname?: string;
261 readonly timeoutMs?: number;
262 }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>;
263 readonly runOpenCodeCommand: (input: {
264 readonly binaryPath: string;
265 readonly args: ReadonlyArray<string>;
266 readonly environment?: NodeJS.ProcessEnv;
267 readonly cwd?: string;
268 readonly maxOutputBytes?: number;
269 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
270 readonly createOpenCodeSdkClient: (input: {
271 readonly baseUrl: string;
272 readonly directory: string;
273 readonly serverPassword?: string;
274 }) => OpencodeClient;
275 readonly loadOpenCodeInventory: (
276 client: OpencodeClient,
277 ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
278 readonly loadOpenCodeSkills: (
279 client: OpencodeClient,
280 ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;
281 readonly loadInventoryFromCli: (input: {
282 readonly binaryPath: string;
283 readonly cwd: string;
284 readonly environment?: NodeJS.ProcessEnv;
285 }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
286 readonly loadSkillsFromCli: (input: {
287 readonly binaryPath: string;
288 readonly cwd: string;
289 readonly environment?: NodeJS.ProcessEnv;
290 }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;
291}
292
293function parseServerUrlFromOutput(output: string): string | null {
294 for (const line of output.split("\n")) {
295 const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i);
296 if (match?.[1]) return match[1];
297 }
298 return null;
299}
300
301const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/;
302const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/;
303
304// Agents that are always hidden in OpenCode but the CLI "agent list" command
305// does not expose the hidden flag. Keep in sync with OpenCode agent
306// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts).
307const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]);
308
309/** @internal */
310export function parseModelsCliOutput(stdout: string): {
311 readonly providers: ReadonlyMap<
312 string,
313 { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } }
314 >;
315 readonly connected: ReadonlyArray<string>;
316} {
317 const providers = new Map<
318 string,
319 { id: string; name: string; models: { [key: string]: Model } }
320 >();
321 const lines = stdout.split("\n");
322 let currentSlug: string | null = null;
323 const jsonLines: Array<string> = [];
324
325 const flushModel = () => {
326 if (currentSlug !== null && jsonLines.length > 0) {
327 const jsonStr = jsonLines.join("\n").trim();
328 if (jsonStr.length > 0) {
329 try {
330 const model = JSON.parse(jsonStr) as Model;
331 const separator = currentSlug.indexOf("/");
332 if (separator > 0) {
333 const providerID = currentSlug.slice(0, separator);
334 const modelID = currentSlug.slice(separator + 1);
335 let provider = providers.get(providerID);
336 if (!provider) {
337 provider = { id: providerID, name: providerID, models: {} };
338 providers.set(providerID, provider);
339 }
340 provider.models[modelID] = model;
341 }
342 } catch {
343 // Skip unparseable model JSON
344 }
345 }
346 }
347 currentSlug = null;
348 jsonLines.length = 0;
349 };
350
351 for (const line of lines) {
352 // A model's JSON body is a single `JSON.stringify` line starting with `{`,
353 // while a provider/model slug is a bare `provider/model` header. Only the
354 // latter can be a slug: without this guard a body line with no interior
355 // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose
356 // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against
357 // an empty body and the model is silently dropped.
358 const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line);
359 if (slugMatch) {
360 flushModel();
361 currentSlug = slugMatch[1]!;
362 } else if (currentSlug !== null) {
363 jsonLines.push(line);
364 }
365 }
366 flushModel();
367
368 return { providers, connected: [...providers.keys()] };
369}
370
371/** @internal */
372export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {
373 const agents: Array<Agent> = [];
374 const lines = stdout.split("\n");
375 let currentHeader: { name: string; mode: string } | null = null;
376 const blockLines: Array<string> = [];
377
378 const flushAgent = () => {
379 if (currentHeader !== null) {
380 const jsonStr = blockLines.join("\n").trim();
381 if (jsonStr.length > 0) {
382 try {
383 const permission = JSON.parse(jsonStr);
384 agents.push({
385 name: currentHeader.name,
386 mode: currentHeader.mode as Agent["mode"],
387 hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name),
388 permission,
389 options: {},
390 });
391 } catch {
392 // Skip unparseable agent
393 }
394 }
395 }
396 currentHeader = null;
397 blockLines.length = 0;
398 };
399
400 for (const line of lines) {
401 const match = AGENT_HEADER_RE.exec(line);
402 if (match) {
403 flushAgent();
404 currentHeader = { name: match[1]!, mode: match[2]! };
405 } else if (currentHeader !== null) {
406 blockLines.push(line);
407 }
408 }
409 flushAgent();
410
411 return agents;
412}
413
414/** @internal */
415export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> {
416 const result = decodeOpenCodeSkillsCliOutputExit(stdout);
417 return Exit.isSuccess(result) ? result.value : [];
418}
419
420export function parseOpenCodeModelSlug(
421 slug: string | null | undefined,
422): ParsedOpenCodeModelSlug | null {
423 if (typeof slug !== "string") {
424 return null;
425 }
426
427 const trimmed = slug.trim();
428 const separator = trimmed.indexOf("/");
429 if (separator <= 0 || separator === trimmed.length - 1) {
430 return null;
431 }
432
433 return {
434 providerID: trimmed.slice(0, separator),
435 modelID: trimmed.slice(separator + 1),
436 };
437}
438
439export function openCodeQuestionId(
440 index: number,
441 question: QuestionRequest["questions"][number],
442): string {
443 const header = question.header
444 .trim()
445 .toLowerCase()
446 .replace(/[^a-z0-9_-]+/g, "-");
447 return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;
448}
449
450/**
451 * Attachments OpenCode can hand to a model as a native file part. Anything
452 * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs
453 * reject, or files over the direct-attachment size limit) would make the turn
454 * fail before it starts, so those ride only as the file path ProviderService
455 * puts in the prompt.
456 */
457const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
458const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024;
459
460function isOpenCodeNativeFilePart(input: {
461 readonly mimeType: string;
462 readonly sizeBytes: number;
463}): boolean {
464 if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) {
465 return false;
466 }
467 const normalized = input.mimeType.trim().toLowerCase();
468 return (
469 OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) ||
470 normalized.startsWith("text/") ||
471 normalized === "application/pdf"
472 );
473}
474
475export function toOpenCodeFileParts(input: {
476 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
477 readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
478}): Array<FilePartInput> {
479 const parts: Array<FilePartInput> = [];
480
481 for (const attachment of input.attachments ?? []) {
482 if (
483 attachment.type === "file" &&
484 "source" in attachment &&
485 attachment.source?._tag === "pasted-text"
486 ) {
487 continue;
488 }
489 if (!isOpenCodeNativeFilePart(attachment)) {
490 continue;
491 }
492 const attachmentPath = input.resolveAttachmentPath(attachment);
493 if (!attachmentPath) {
494 continue;
495 }
496
497 parts.push({
498 type: "file",
499 mime: attachment.mimeType,
500 filename: attachment.name,
501 url: NodeURL.pathToFileURL(attachmentPath).href,
502 });
503 }
504
505 return parts;
506}
507
508export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset {
509 if (runtimeMode === "full-access") {
510 return [
511 { permission: "*", pattern: "*", action: "allow" },
512 { permission: "external_directory", pattern: "*", action: "allow" },
513 ];
514 }
515
516 // "Auto-accept edits" is documented as "auto-approve edits, ask before other
517 // actions", so prompting for every edit ignores the mode the user picked.
518 // "auto" is left asking on purpose: the docs say providers without an AI
519 // reviewer, OpenCode among them, fall back to Supervised for that mode.
520 const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask";
521
522 // Session rules override OpenCode's agent defaults. Allow reads and task
523 // updates, but keep its default approval rules for environment files.
524 return [
525 { permission: "*", pattern: "*", action: "ask" },
526 { permission: "read", pattern: "*", action: "allow" },
527 { permission: "read", pattern: "*.env", action: "ask" },
528 { permission: "read", pattern: "*.env.*", action: "ask" },
529 { permission: "read", pattern: "*.env.example", action: "allow" },
530 { permission: "glob", pattern: "*", action: "allow" },
531 { permission: "grep", pattern: "*", action: "allow" },
532 { permission: "lsp", pattern: "*", action: "allow" },
533 { permission: "skill", pattern: "*", action: "allow" },
534 { permission: "todowrite", pattern: "*", action: "allow" },
535 { permission: "bash", pattern: "*", action: "ask" },
536 { permission: "edit", pattern: "*", action: editAction },
537 { permission: "webfetch", pattern: "*", action: "ask" },
538 { permission: "websearch", pattern: "*", action: "ask" },
539 { permission: "codesearch", pattern: "*", action: "ask" },
540 { permission: "external_directory", pattern: "*", action: "ask" },
541 { permission: "doom_loop", pattern: "*", action: "ask" },
542 { permission: "question", pattern: "*", action: "allow" },
543 ];
544}
545
546export function toOpenCodePermissionReply(
547 decision: ProviderApprovalDecision,
548): "once" | "always" | "reject" {
549 switch (decision) {
550 case "accept":
551 return "once";
552 case "acceptForSession":
553 case "acceptAlways":
554 return "always";
555 case "decline":
556 case "cancel":
557 default:
558 return "reject";
559 }
560}
561
562export function toOpenCodeQuestionAnswers(
563 request: QuestionRequest,
564 answers: Record<string, unknown>,
565): Array<QuestionAnswer> {
566 return request.questions.map((question, index) => {
567 const raw =
568 answers[openCodeQuestionId(index, question)] ??
569 answers[question.header] ??
570 answers[question.question];
571 if (Array.isArray(raw)) {
572 return raw.filter((value): value is string => typeof value === "string");
573 }
574 if (typeof raw === "string") {
575 return raw.trim().length > 0 ? [raw] : [];
576 }
577 return [];
578 });
579}
580
581function ensureRuntimeError(
582 operation: OpenCodeRuntimeError["operation"],
583 detail: string,
584 cause: unknown,
585): OpenCodeRuntimeError {
586 return OpenCodeRuntimeError.is(cause)
587 ? cause
588 : new OpenCodeRuntimeError({ operation, detail, cause });
589}
590
591const makeOpenCodeRuntime = Effect.gen(function* () {
592 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
593 const netService = yield* NetService.NetService;
594 const hostPlatform = yield* HostProcessPlatform;
595 const serverLedger = yield* OpenCodeServerLedger.OpenCodeServerLedger;
596 const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>
597 resolveSpawnCommand(command, args, env ? { env } : {});
598
599 const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) =>
600 Effect.gen(function* () {
601 const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment);
602 const child = yield* spawner.spawn(
603 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
604 detached: hostPlatform !== "win32",
605 shell: spawnCommand.shell,
606 ...(input.cwd ? { cwd: input.cwd } : {}),
607 ...(input.environment ? { env: input.environment } : { extendEnv: true }),
608 }),
609 );
610 const terminateCommandGroup =
611 hostPlatform === "win32"
612 ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid)
613 : Effect.sync(() => {
614 try {
615 signalProcessGroup(Number(child.pid), "SIGKILL");
616 } catch {
617 // The command and its process group may already have exited.
618 }
619 });
620 yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore));
621 const collectOptions =
622 input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes };
623 const [stdout, stderr, code] = yield* Effect.all(
624 [
625 collectStreamAsString(child.stdout, collectOptions),
626 collectStreamAsString(child.stderr, collectOptions),
627 child.exitCode,
628 ],
629 { concurrency: "unbounded" },
630 );
631 const exitCode = Number(code);
632 if (yield* isWindowsCommandNotFound(exitCode, stderr)) {
633 return yield* new OpenCodeRuntimeError({
634 operation: "runOpenCodeCommand",
635 detail: `spawn ${input.binaryPath} ENOENT`,
636 });
637 }
638 return {
639 stdout,
640 stderr,
641 code: exitCode,
642 } satisfies OpenCodeCommandResult;
643 }).pipe(
644 Effect.scoped,
645 Effect.mapError((cause) =>
646 ensureRuntimeError(
647 "runOpenCodeCommand",
648 `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`,
649 cause,
650 ),
651 ),
652 );
653
654 const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) =>
655 createOpencodeClient({
656 baseUrl: input.baseUrl,
657 directory: input.directory,
658 ...(input.serverPassword
659 ? {
660 headers: {
661 Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`,
662 },
663 }
664 : {}),
665 throwOnError: true,
666 });
667
668 const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) =>
669 Effect.gen(function* () {
670 // Bind this server's lifetime to the caller's scope. When the caller's
671 // scope closes, the spawned child is killed and all associated fibers
672 // are interrupted automatically — no `close()` method needed.
673 const runtimeScope = yield* Scope.Scope;
674
675 const hostname = input.hostname ?? DEFAULT_HOSTNAME;
676 const port =
677 input.port ??
678 (yield* netService.findAvailablePort(0).pipe(
679 Effect.mapError(
680 (cause) =>
681 new OpenCodeRuntimeError({
682 operation: "startOpenCodeServerProcess",
683 detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`,
684 cause,
685 }),
686 ),
687 ));
688 const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;
689 const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];
690 const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment);
691 const serverPassword = resolveOpenCodeServerPassword({
692 external: false,
693 ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
694 ...(input.environment !== undefined ? { environment: input.environment } : {}),
695 });
696
697 // Scopes close in reverse order. Forking this before the group kill is
698 // registered forgets the ledger entry only once the group is stopped.
699 const ledgerScope = yield* Scope.fork(runtimeScope);
700 const child = yield* spawner
701 .spawn(
702 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
703 detached: hostPlatform !== "win32",
704 shell: spawnCommand.shell,
705 env: {
706 ...input.environment,
707 ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}),
708 // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or
709 // the inherited process environment, only falling back to the
710 // empty config when neither is set. Setting it unconditionally
711 // previously clobbered the user's opencode config, hiding their
712 // providers/models. The value is set explicitly (rather than
713 // relying on inheritance) because `extendEnv` is false whenever
714 // `input.environment` is provided.
715 OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment),
716 },
717 extendEnv: input.environment === undefined,
718 }),
719 )
720 .pipe(
721 Effect.provideService(Scope.Scope, runtimeScope),
722 Effect.mapError(
723 (cause) =>
724 new OpenCodeRuntimeError({
725 operation: "startOpenCodeServerProcess",
726 detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`,
727 cause,
728 }),
729 ),
730 );
731
732 const killOpenCodeProcessGroup = (signal: NodeJS.Signals) =>
733 hostPlatform === "win32"
734 ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid)
735 : Effect.sync(() => {
736 try {
737 signalProcessGroup(Number(child.pid), signal);
738 } catch {
739 // The direct child may already have exited after starting the
740 // server; the process group kill is best-effort cleanup for
741 // any serve process left in that group.
742 }
743 });
744 const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe(
745 Effect.andThen(Effect.sleep("1 second")),
746 Effect.andThen(killOpenCodeProcessGroup("SIGKILL")),
747 Effect.ignore,
748 );
749 // Registered before recording, so an interrupt while the ledger writes
750 // still stops the group.
751 yield* Scope.addFinalizer(runtimeScope, terminateChild);
752 const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args });
753 yield* Scope.addFinalizer(ledgerScope, forgetServer);
754
755 const stdoutRef = yield* Ref.make<string | null>("");
756 const stderrRef = yield* Ref.make<string | null>("");
757 const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();
758
759 const setReadyFromStdoutChunk = (chunk: string) =>
760 Ref.modify(stdoutRef, (stdout) => {
761 if (stdout === null) {
762 return [null, null] as const;
763 }
764 const nextStdout = `${stdout}${chunk}`;
765 return [
766 parseServerUrlFromOutput(nextStdout),
767 nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS),
768 ] as const;
769 }).pipe(
770 Effect.flatMap((parsed) =>
771 parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void,
772 ),
773 );
774
775 const stdoutFiber = yield* child.stdout.pipe(
776 Stream.decodeText(),
777 Stream.runForEach(setReadyFromStdoutChunk),
778 Effect.ignore,
779 Effect.forkIn(runtimeScope),
780 );
781 const stderrFiber = yield* child.stderr.pipe(
782 Stream.decodeText(),
783 Stream.runForEach((chunk) =>
784 Ref.update(stderrRef, (stderr) =>
785 stderr === null
786 ? null
787 : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS),
788 ),
789 ),
790 Effect.ignore,
791 Effect.forkIn(runtimeScope),
792 );
793
794 const exitFiber = yield* child.exitCode.pipe(
795 Effect.flatMap((code) =>
796 Effect.gen(function* () {
797 const stdout = (yield* Ref.get(stdoutRef)) ?? "";
798 const stderr = (yield* Ref.get(stderrRef)) ?? "";
799 const exitCode = Number(code);
800 yield* Deferred.fail(
801 readyDeferred,
802 new OpenCodeRuntimeError({
803 operation: "startOpenCodeServerProcess",
804 detail: [
805 `OpenCode server exited before startup completed (code: ${String(exitCode)}).`,
806 stdout.trim() ? `stdout:\n${stdout.trim()}` : null,
807 stderr.trim() ? `stderr:\n${stderr.trim()}` : null,
808 ]
809 .filter(Boolean)
810 .join("\n\n"),
811 cause: { exitCode, stdout, stderr },
812 }),
813 ).pipe(Effect.ignore);
814 }),
815 ),
816 Effect.ignore,
817 Effect.forkIn(runtimeScope),
818 );
819
820 const readyExit = yield* Effect.exit(
821 Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),
822 );
823
824 if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) {
825 yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore);
826 }
827
828 if (Exit.isFailure(readyExit)) {
829 const squashed = Cause.squash(readyExit.cause);
830 return yield* ensureRuntimeError(
831 "startOpenCodeServerProcess",
832 `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`,
833 squashed,
834 );
835 }
836
837 const readyOption = readyExit.value;
838 if (Option.isNone(readyOption)) {
839 return yield* new OpenCodeRuntimeError({
840 operation: "startOpenCodeServerProcess",
841 detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`,
842 });
843 }
844
845 // Keep draining both pipes until the process scope closes. Stopping the
846 // readers can block OpenCode when its output buffers fill. Startup output
847 // is no longer needed, so discard later output instead of retaining it.
848 yield* Ref.set(stdoutRef, null);
849 yield* Ref.set(stderrRef, null);
850
851 const url = readyOption.value;
852 const version = yield* (
853 input.verify?.(url) ??
854 verifyOpenCodeServerVersion(
855 createOpenCodeSdkClient({
856 baseUrl: url,
857 directory: input.directory,
858 ...(serverPassword !== undefined ? { serverPassword } : {}),
859 }),
860 )
861 );
862
863 return {
864 url,
865 ...(serverPassword !== undefined ? { serverPassword } : {}),
866 version,
867 isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)),
868 exitCode: child.exitCode.pipe(
869 Effect.map(Number),
870 Effect.orElseSucceed(() => 0),
871 ),
872 } satisfies OpenCodeServerProcess;
873 });
874
875 const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => {
876 const serverUrl = input.serverUrl?.trim();
877 if (serverUrl) {
878 const serverPassword = resolveOpenCodeServerPassword({
879 external: true,
880 ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
881 });
882 return verifyOpenCodeServerVersion(
883 createOpenCodeSdkClient({
884 baseUrl: serverUrl,
885 directory: input.directory,
886 ...(serverPassword !== undefined ? { serverPassword } : {}),
887 }),
888 ).pipe(
889 Effect.map((version) => ({
890 url: serverUrl,
891 ...(serverPassword !== undefined ? { serverPassword } : {}),
892 version,
893 exitCode: null,
894 external: true,
895 })),
896 );
897 }
898
899 return startOpenCodeServerProcess({
900 binaryPath: input.binaryPath,
901 directory: input.directory,
902 ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
903 ...(input.environment !== undefined ? { environment: input.environment } : {}),
904 ...(input.port !== undefined ? { port: input.port } : {}),
905 ...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
906 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
907 }).pipe(
908 Effect.map((server) => ({
909 url: server.url,
910 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
911 version: server.version,
912 exitCode: server.exitCode,
913 external: false,
914 })),
915 );
916 };
917
918 const loadProviders = (client: OpencodeClient) =>
919 runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe(
920 Effect.filterMapOrFail(
921 (list) =>
922 list.data
923 ? Result.succeed(list.data)
924 : Result.fail(
925 new OpenCodeRuntimeError({
926 operation: "provider.list",
927 detail: "OpenCode provider list was empty.",
928 }),
929 ),
930 (result) => result,
931 ),
932 );
933
934 const loadAgents = (client: OpencodeClient) =>
935 runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe(
936 Effect.map((result) => result.data ?? []),
937 Effect.orElseSucceed((): ReadonlyArray<Agent> => []),
938 );
939
940 const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) =>
941 runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe(
942 Effect.map((result) =>
943 (result.data ?? []).map((skill) => ({
944 name: skill.name,
945 ...(skill.description === undefined ? {} : { description: skill.description }),
946 location: skill.location,
947 })),
948 ),
949 );
950 const loadSkills = (client: OpencodeClient) =>
951 loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []));
952
953 const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) =>
954 Effect.all(
955 [
956 loadProviders(client),
957 loadAgents(client),
958 loadSkills(client),
959 loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])),
960 ],
961 {
962 concurrency: "unbounded",
963 },
964 ).pipe(
965 Effect.map(([providerList, agents, skills, commands]) => ({
966 providerList,
967 agents,
968 skills,
969 commands,
970 })),
971 );
972
973 const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) =>
974 Effect.gen(function* () {
975 const env = input.environment !== undefined ? { environment: input.environment } : ({} as {});
976 const commandContext = { cwd: input.cwd, ...env };
977
978 const runModelsCli = () =>
979 runOpenCodeCommand({
980 binaryPath: input.binaryPath,
981 args: ["models", "--verbose"],
982 ...commandContext,
983 }).pipe(Effect.exit);
984 const runAgentsCli = () =>
985 runOpenCodeCommand({
986 binaryPath: input.binaryPath,
987 args: ["agent", "list"],
988 ...commandContext,
989 }).pipe(Effect.exit);
990 const runSkillsCli = () =>
991 runOpenCodeCommand({
992 binaryPath: input.binaryPath,
993 args: ["debug", "skill"],
994 maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES,
995 ...commandContext,
996 }).pipe(Effect.exit);
997
998 // Every OpenCode CLI command opens the same shared SQLite database. Running them
999 // concurrently causes "database is locked" failures, so run them one at a time.
1000 const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all(
1001 [runModelsCli(), runAgentsCli(), runSkillsCli()],
1002 { concurrency: 1 },
1003 );
1004 let modelsResult = initialModelsResult;
1005 let agentsResult = initialAgentsResult;
1006 let skillsResult = initialSkillsResult;
1007
1008 // Retry once after 1s on transient failures (e.g. SQLite "database is locked")
1009 const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0;
1010 const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0;
1011 const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0;
1012 if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) {
1013 yield* Effect.sleep("1 second");
1014 const [m2, a2, s2] = yield* Effect.all(
1015 [
1016 needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult),
1017 needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult),
1018 needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult),
1019 ],
1020 { concurrency: 1 },
1021 );
1022 modelsResult = m2;
1023 agentsResult = a2;
1024 skillsResult = s2;
1025 }
1026
1027 if (modelsResult._tag === "Failure") {
1028 const cause = Cause.squash(modelsResult.cause);
1029 return yield* ensureRuntimeError(
1030 "loadInventoryFromCli",
1031 `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`,
1032 cause,
1033 );
1034 }
1035 if (modelsResult.value.code !== 0) {
1036 return yield* new OpenCodeRuntimeError({
1037 operation: "loadInventoryFromCli",
1038 detail: `OpenCode models command exited with code ${modelsResult.value.code}.`,
1039 });
1040 }
1041
1042 const parsed = parseModelsCliOutput(modelsResult.value.stdout);
1043 const connected = [...parsed.connected];
1044 const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map(
1045 (provider) => ({
1046 id: provider.id,
1047 name: provider.name,
1048 source: "config" as const,
1049 env: [],
1050 options: {},
1051 models: provider.models,
1052 }),
1053 );
1054
1055 // Agent and skill metadata enrich the provider snapshot but are not required
1056 // for an authoritative model inventory, so either may degrade to an empty list.
1057 let agents: ReadonlyArray<Agent> = [];
1058 if (agentsResult._tag === "Success" && agentsResult.value.code === 0) {
1059 agents = parseAgentListCliOutput(agentsResult.value.stdout);
1060 }
1061 let skills: ReadonlyArray<OpenCodeSkill> = [];
1062 if (skillsResult._tag === "Success" && skillsResult.value.code === 0) {
1063 skills = parseSkillsCliOutput(skillsResult.value.stdout);
1064 }
1065
1066 return {
1067 providerList: { all: allProviders, default: {}, connected },
1068 agents,
1069 skills,
1070 };
1071 });
1072
1073 const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) =>
1074 runOpenCodeCommand({
1075 binaryPath: input.binaryPath,
1076 args: ["debug", "skill"],
1077 cwd: input.cwd,
1078 maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES,
1079 ...(input.environment !== undefined ? { environment: input.environment } : {}),
1080 }).pipe(
1081 Effect.flatMap((result) =>
1082 result.code === 0
1083 ? Effect.succeed(parseSkillsCliOutput(result.stdout))
1084 : Effect.fail(
1085 new OpenCodeRuntimeError({
1086 operation: "loadSkillsFromCli",
1087 detail: `OpenCode skills command exited with code ${result.code}.`,
1088 }),
1089 ),
1090 ),
1091 );
1092
1093 return {
1094 startOpenCodeServerProcess,
1095 connectToOpenCodeServer,
1096 runOpenCodeCommand,
1097 createOpenCodeSdkClient,
1098 loadOpenCodeInventory,
1099 loadOpenCodeSkills,
1100 loadInventoryFromCli,
1101 loadSkillsFromCli,
1102 } satisfies OpenCodeRuntimeShape;
1103});
1104
1105export class OpenCodeRuntime extends Context.Service<OpenCodeRuntime, OpenCodeRuntimeShape>()(
1106 "t3/provider/opencodeRuntime",
1107) {}
1108
1109export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe(
1110 Layer.provide(NetService.layer),
1111);
apps/server/src/provider/opencodeVersionProbe.test.ts deleted-197
...@@ -1,197 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
4
5import * as OpenCodeRuntime from "./opencodeRuntime.ts";
6import {
7 classifyOpenCodeCliVersion,
8 makeOpenCodeRuntimeProbe,
9 probeOpenCodeRuntime,
10} from "./opencodeVersionProbe.ts";
11import {
12 OPENCODE_1_RESPONSES,
13 OPENCODE_2_RESPONSES,
14 replayOpenCodeServer,
15} from "./testFixtures/opencodeProbeResponses.ts";
16
17const noBinary = {
18 runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"),
19} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape;
20
21const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) =>
22 probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe(
23 Effect.provideService(HttpClient.HttpClient, http),
24 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, noBinary),
25 );
26const SERVER_URL = "http://127.0.0.1:4096/";
27
28describe("OpenCode version probe", () => {
29 it("classifies the recorded `opencode --version` output of both versions", () => {
30 assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), {
31 generation: "v1",
32 version: "1.18.32",
33 });
34 assert.deepStrictEqual(classifyOpenCodeCliVersion("opencode v2.0.18\n"), {
35 generation: "v2",
36 version: "2.0.18",
37 });
38 assert.isUndefined(classifyOpenCodeCliVersion("opencode dev build\n"));
39 });
40
41 it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () =>
42 Effect.gen(function* () {
43 const paths: Array<string> = [];
44 const probed = yield* probeServer(
45 SERVER_URL,
46 "pw",
47 replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths),
48 );
49 assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" });
50 assert.deepStrictEqual(paths, ["/api/info"]);
51 }),
52 );
53
54 it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () =>
55 Effect.gen(function* () {
56 const paths: Array<string> = [];
57 const probed = yield* probeServer(
58 SERVER_URL,
59 "pw",
60 replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths),
61 );
62 assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" });
63 assert.deepStrictEqual(paths, ["/api/info", "/global/health"]);
64 }),
65 );
66
67 it.effect("keeps a server URL's path prefix and query when probing", () =>
68 Effect.gen(function* () {
69 const urls: Array<string> = [];
70 const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw");
71 // A reverse proxy mounting OpenCode under /opencode, reached with a routing query.
72 const proxied = HttpClient.make((request, url) => {
73 urls.push(url.href);
74 return replay.execute(
75 HttpClientRequest.setUrl(
76 request,
77 `http://127.0.0.1:4096${url.pathname.replace(/^\/opencode/, "")}`,
78 ),
79 );
80 });
81 const probed = yield* probeServer("http://proxy:8080/opencode/?route=oc", "pw", proxied);
82 assert.strictEqual(probed.generation, "v1");
83 assert.deepStrictEqual(urls, [
84 "http://proxy:8080/opencode/api/info?route=oc",
85 "http://proxy:8080/opencode/global/health?route=oc",
86 ]);
87 }),
88 );
89
90 it.effect("reports a rejected password instead of guessing a version", () =>
91 Effect.gen(function* () {
92 for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) {
93 const paths: Array<string> = [];
94 const error = yield* Effect.flip(
95 probeServer(SERVER_URL, "wrong", replayOpenCodeServer(responses, "pw", paths)),
96 );
97 assert.match(error.detail, /401 Unauthorized/);
98 assert.deepStrictEqual(paths, ["/api/info"]);
99 }
100 }),
101 );
102
103 it.effect("sends a non-ASCII password as UTF-8, as both versions expect", () =>
104 Effect.gen(function* () {
105 for (const [responses, password, generation] of [
106 [OPENCODE_1_RESPONSES, "pässwörd", "v1"],
107 [OPENCODE_2_RESPONSES, "pass€word", "v2"],
108 ] as const) {
109 const probed = yield* probeServer(
110 SERVER_URL,
111 password,
112 replayOpenCodeServer(responses, password),
113 );
114 assert.strictEqual(probed.generation, generation);
115 }
116 }),
117 );
118
119 it.effect("does not take other JSON at /api/info for OpenCode 2", () =>
120 Effect.gen(function* () {
121 const impostor = {
122 ...OPENCODE_1_RESPONSES,
123 "/api/info": { status: 200, contentType: "application/json", body: '{"version":"3.4.1"}' },
124 "/global/health": OPENCODE_1_RESPONSES["/api/info"],
125 };
126 const error = yield* Effect.flip(
127 probeServer(SERVER_URL, "pw", replayOpenCodeServer(impostor, "pw")),
128 );
129 assert.match(error.detail, /did not identify itself as OpenCode/);
130 }),
131 );
132
133 it.effect("never puts the configured URL or its credentials in an error detail", () =>
134 Effect.gen(function* () {
135 const secret = "userinfo-secret";
136 const unreachable = HttpClient.make((request) =>
137 Effect.die(`should not be reached for ${request.url}`),
138 );
139 // A fetch failure that echoes the full request URL, as Node's does.
140 const refused = HttpClient.make((request) =>
141 Effect.fail(
142 new HttpClientError.HttpClientError({
143 reason: new HttpClientError.TransportError({
144 request,
145 description: `connect ECONNREFUSED ${request.url}`,
146 }),
147 }),
148 ),
149 );
150 for (const [serverUrl, http] of [
151 [`127.0.0.1:4096?token=${secret}`, unreachable],
152 [`localhost:4096/${secret}`, unreachable],
153 [`http://user:${secret}@10.0.0.1:4096/?token=${secret}`, refused],
154 ] as const) {
155 const error = yield* Effect.flip(probeServer(serverUrl, secret, http));
156 assert.notInclude(error.detail, secret, serverUrl);
157 assert.notInclude(error.detail, "10.0.0.1", serverUrl);
158 }
159 }),
160 );
161
162 it.effect("remembers a probed runtime but never a failure", () =>
163 Effect.gen(function* () {
164 const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"];
165 let calls = 0;
166 const runtime = {
167 runOpenCodeCommand: () => {
168 const stdout = outputs[calls++];
169 return stdout
170 ? Effect.succeed({ stdout, stderr: "", code: 0 })
171 : Effect.fail(
172 new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }),
173 );
174 },
175 } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape;
176 const probe = yield* makeOpenCodeRuntimeProbe(
177 probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe(
178 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime),
179 Effect.provideService(
180 HttpClient.HttpClient,
181 HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")),
182 ),
183 ),
184 );
185
186 yield* Effect.flip(probe.get);
187 assert.isTrue((yield* probe.lastSuccess)._tag === "None");
188 assert.strictEqual((yield* probe.get).generation, "v2");
189 assert.strictEqual((yield* probe.get).generation, "v2");
190 assert.strictEqual(calls, 2);
191 // A status refresh re-probes, so an in-place downgrade or upgrade re-routes.
192 assert.strictEqual((yield* probe.refresh).generation, "v1");
193 assert.strictEqual((yield* probe.get).generation, "v1");
194 assert.strictEqual(calls, 3);
195 }),
196 );
197});
apps/server/src/provider/opencodeVersionProbe.ts deleted-198
...@@ -1,198 +0,0 @@
1/** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */
2import { parseSemver } from "@t3tools/shared/semver";
3import * as Cache from "effect/Cache";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest } from "effect/http";
10
11import * as OpenCodeRuntime from "./opencodeRuntime.ts";
12import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe";
13
14export interface ProbedOpenCode {
15 readonly generation: "v1" | "v2";
16 readonly version: string;
17}
18
19const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds";
20const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds";
21// 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out.
22const decodeApiInfo = Schema.decodeUnknownOption(
23 Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })),
24);
25const decodeGlobalHealth = Schema.decodeUnknownOption(
26 Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })),
27);
28
29function probed(version: string | null | undefined): ProbedOpenCode | undefined {
30 const major = parseSemver(version ?? "")?.major;
31 if (!version || major === undefined) return undefined;
32 return { generation: major >= 2 ? "v2" : "v1", version };
33}
34
35/** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */
36export const classifyOpenCodeCliVersion = (output: string) =>
37 probed(parseGenericCliVersion(output));
38
39/**
40 * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a
41 * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with
42 * a 401 on either path, so a 401 says nothing about the version.
43 */
44function classifyOpenCodeProbeResponse(
45 path: "/api/info" | "/global/health",
46 response: {
47 readonly status: number;
48 readonly contentType: string | undefined;
49 readonly body: string;
50 },
51): ProbedOpenCode | "unauthorized" | undefined {
52 if (response.status === 401) return "unauthorized";
53 const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase();
54 if (response.status !== 200 || mediaType !== "application/json") return undefined;
55 const version: Option.Option<string> =
56 path === "/api/info"
57 ? Option.map(decodeApiInfo(response.body), (info) => info.version)
58 : Option.map(decodeGlobalHealth(response.body), (health) => health.version);
59 return probed(Option.getOrUndefined(version));
60}
61
62const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* (
63 binaryPath: string,
64 environment: NodeJS.ProcessEnv | undefined,
65) {
66 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
67 const { stdout } = yield* runtime
68 .runOpenCodeCommand({
69 binaryPath,
70 args: ["--version"],
71 ...(environment === undefined ? {} : { environment }),
72 })
73 .pipe(
74 Effect.timeoutOrElse({
75 duration: OPENCODE_VERSION_PROBE_TIMEOUT,
76 orElse: () =>
77 Effect.fail(
78 new OpenCodeRuntime.OpenCodeRuntimeError({
79 operation: "probeOpenCodeBinary",
80 detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`,
81 }),
82 ),
83 }),
84 );
85 const result = classifyOpenCodeCliVersion(stdout);
86 if (result) return result;
87 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
88 operation: "probeOpenCodeBinary",
89 detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
90 });
91});
92
93// Server failures reach clients through the provider status, so their details are fixed text:
94// the underlying error can carry the configured URL, its credentials, or the password header.
95const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* (
96 serverUrl: string,
97 serverPassword: string,
98) {
99 const client = yield* HttpClient.HttpClient;
100 const baseUrl = URL.parse(serverUrl.trim());
101 if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") {
102 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
103 operation: "probeOpenCodeServer",
104 detail: "The OpenCode server URL is not a valid http:// or https:// URL.",
105 });
106 }
107 // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`.
108 const authorization = serverPassword
109 ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}`
110 : undefined;
111 for (const path of ["/api/info", "/global/health"] as const) {
112 // A path prefix and query on the configured URL are kept: `/base/?x=1` → `/base/api/info?x=1`.
113 const url = new URL(baseUrl);
114 url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
115 const request = HttpClientRequest.get(url.href);
116 const result = yield* client
117 .execute(
118 authorization
119 ? HttpClientRequest.setHeader(request, "authorization", authorization)
120 : request,
121 )
122 .pipe(
123 Effect.flatMap((response) =>
124 Effect.map(response.text, (body) =>
125 classifyOpenCodeProbeResponse(path, {
126 status: response.status,
127 contentType: response.headers["content-type"],
128 body,
129 }),
130 ),
131 ),
132 Effect.mapError(
133 (cause) =>
134 new OpenCodeRuntime.OpenCodeRuntimeError({
135 operation: "probeOpenCodeServer",
136 detail: "Couldn't reach the OpenCode server.",
137 cause,
138 }),
139 ),
140 Effect.timeoutOrElse({
141 duration: OPENCODE_SERVER_PROBE_TIMEOUT,
142 orElse: () =>
143 Effect.fail(
144 new OpenCodeRuntime.OpenCodeRuntimeError({
145 operation: "probeOpenCodeServer",
146 detail: "Timed out while checking the OpenCode server version.",
147 }),
148 ),
149 }),
150 );
151 if (result === "unauthorized") {
152 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
153 operation: "probeOpenCodeServer",
154 detail: "401 Unauthorized: the OpenCode server rejected the password.",
155 });
156 }
157 if (result !== undefined) return result;
158 }
159 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
160 operation: "probeOpenCodeServer",
161 detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
162 });
163});
164
165/** Probes a configured server when `serverUrl` is set, otherwise the local binary. */
166export const probeOpenCodeRuntime = (
167 settings: {
168 readonly binaryPath: string;
169 readonly serverUrl: string;
170 readonly serverPassword: string;
171 },
172 environment?: NodeJS.ProcessEnv,
173): Effect.Effect<
174 ProbedOpenCode,
175 OpenCodeRuntime.OpenCodeRuntimeError,
176 HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime
177> =>
178 settings.serverUrl.trim().length > 0
179 ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword)
180 : probeOpenCodeBinary(settings.binaryPath, environment);
181
182/**
183 * One instance's runtime, remembered after the first successful probe. Settings changes rebuild
184 * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A
185 * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one.
186 */
187export const makeOpenCodeRuntimeProbe = <E>(probe: Effect.Effect<ProbedOpenCode, E>) =>
188 Effect.map(
189 Cache.makeWith(() => probe, {
190 capacity: 1,
191 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
192 }),
193 (cache) => ({
194 get: Cache.get(cache, undefined),
195 refresh: Cache.refresh(cache, undefined),
196 lastSuccess: Cache.getSuccess(cache, undefined),
197 }),
198 );
apps/server/src/provider/providerStatusCache.ts+1-1
...@@ -9,7 +9,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -9,7 +9,7 @@ import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
1111
12import { writeFileStringAtomically } from "../atomicWrite.ts";12import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
1313
14const decodeProviderStatusCache = Schema.decodeUnknownEffect(14const decodeProviderStatusCache = Schema.decodeUnknownEffect(
15 Schema.fromJsonString(ServerProviderSchema),15 Schema.fromJsonString(ServerProviderSchema),
apps/server/src/provider/testFixtures/opencodeProbeResponses.ts deleted-93
...@@ -1,93 +0,0 @@
1/**
2 * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an
3 * `HttpClient` so the version probe runs against real bytes.
4 */
5import * as Effect from "effect/Effect";
6import { HttpClient, HttpClientResponse } from "effect/http";
7
8interface RecordedResponse {
9 readonly status: number;
10 readonly contentType?: string;
11 readonly body: string;
12}
13
14// Both versions serve their web UI's index page for unknown paths. Bodies are cut after the
15// first lines; the classifier rejects them on content type alone.
16const SPA_HTML =
17 '<!doctype html>\n<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">\n';
18
19export const OPENCODE_2_RESPONSES = {
20 "/api/info": {
21 status: 200,
22 contentType: "application/json",
23 body: '{"version":"2.0.18","pid":2393556,"urls":["http://127.0.0.1:48771"],"paths":{"tmp":"/tmp/opencode"}}',
24 },
25 "/global/health": { status: 200, contentType: "text/html", body: SPA_HTML },
26 unauthorized: {
27 status: 401,
28 contentType: "application/json",
29 body: '{"_tag":"UnauthorizedError","message":"Authentication required"}',
30 },
31} satisfies Record<string, RecordedResponse>;
32
33/**
34 * A 2.0.18 server's command and skill lists for a directory with one command
35 * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on
36 * 2026-09-29. Skill contents are cut; machine-wide skills are dropped.
37 */
38export const OPENCODE_2_WORKSPACE_RESPONSES = {
39 "/api/command": {
40 status: 200,
41 contentType: "application/json",
42 body: '{"location":{"directory":"/work"},"data":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"},{"name":"bee","description":"B command"}]}',
43 },
44 "/api/skill": {
45 status: 200,
46 contentType: "application/json",
47 body: '{"location":{"directory":"/work"},"data":[{"id":"opencode","name":"OpenCode","description":"Use this skill for any question about OpenCode itself.","path":"/builtin/opencode.md","content":"# OpenCode"},{"id":"report","name":"Report","description":"Use when the user wants to report an opencode issue or bug.","path":"/builtin/report.md","content":"<!-- Built-in skill. -->"},{"id":"plum","name":"plum","description":"plum skill","path":"/work/.opencode/skills/plum/SKILL.md","content":"Say PLUM."}]}',
48 },
49} satisfies Record<string, RecordedResponse>;
50
51export const OPENCODE_1_RESPONSES = {
52 "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML },
53 "/global/health": {
54 status: 200,
55 contentType: "application/json",
56 body: '{"healthy":true,"version":"1.18.32"}',
57 },
58 // 1.x also answers a wrong password with an empty 401 on every path.
59 unauthorized: { status: 401, body: "" },
60} satisfies Record<string, RecordedResponse>;
61
62/** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */
63export function replayOpenCodeServer(
64 responses:
65 | typeof OPENCODE_1_RESPONSES
66 | typeof OPENCODE_2_RESPONSES
67 | (typeof OPENCODE_2_RESPONSES & typeof OPENCODE_2_WORKSPACE_RESPONSES),
68 password: string,
69 requestedPaths: Array<string> = [],
70) {
71 const answers: Record<string, RecordedResponse | undefined> = responses;
72 // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`).
73 const expected = password
74 ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`
75 : undefined;
76 return HttpClient.make((request) => {
77 const path = new URL(request.url).pathname;
78 requestedPaths.push(path);
79 const recorded: RecordedResponse =
80 request.headers.authorization === expected
81 ? (answers[path] ?? responses.unauthorized)
82 : responses.unauthorized;
83 return Effect.succeed(
84 HttpClientResponse.fromWeb(
85 request,
86 new Response(recorded.body, {
87 status: recorded.status,
88 headers: recorded.contentType ? { "content-type": recorded.contentType } : {},
89 }),
90 ),
91 );
92 });
93}
apps/server/src/server.ts+14-3
...@@ -43,8 +43,8 @@ import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor...@@ -43,8 +43,8 @@ import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor
43import * as ModelManifest from "./provider/ModelManifest.ts";43import * as ModelManifest from "./provider/ModelManifest.ts";
44import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts";44import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts";
45import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts";45import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts";
46import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";46import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
47import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts";47import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
48import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts";48import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts";
49import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";49import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
50import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";50import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
...@@ -614,7 +614,18 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(...@@ -614,7 +614,18 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
614 // the rewritten registry reads snapshots off the instance registry and614 // the rewritten registry reads snapshots off the instance registry and
615 // no longer transitively provides it. Exposing it at the runtime level615 // no longer transitively provides it. Exposing it at the runtime level
616 // keeps a single Live for all opencode consumers.616 // keeps a single Live for all opencode consumers.
617 Layer.provideMerge(OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layer))),617 Layer.provideMerge(
618 OpenCodeRuntime.layer.pipe(
619 Layer.provide(
620 Layer.unwrap(
621 Effect.gen(function* () {
622 const config = yield* ServerConfig.ServerConfig;
623 return OpenCodeServerLedger.layer({ stateDir: config.stateDir });
624 }),
625 ),
626 ),
627 ),
628 ),
618 Layer.provideMerge(layerWorkspace),629 Layer.provideMerge(layerWorkspace),
619 Layer.provideMerge(ProjectEnrichmentService.layer),630 Layer.provideMerge(ProjectEnrichmentService.layer),
620 Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)),631 Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)),
apps/server/src/serverRuntimeState.ts+1-1
...@@ -4,7 +4,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -4,7 +4,7 @@ import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
66
7import { writeFileStringAtomically } from "./atomicWrite.ts";7import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
8import type * as ServerConfig from "./config.ts";8import type * as ServerConfig from "./config.ts";
9import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";9import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";
1010
apps/server/src/serverSettings.test.ts+1-1
...@@ -28,7 +28,7 @@ import * as SqlClient from "effect/sql/SqlClient";...@@ -28,7 +28,7 @@ import * as SqlClient from "effect/sql/SqlClient";
28import * as ServerSecretStore from "./auth/ServerSecretStore.ts";28import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
29import * as ServerConfig from "./config.ts";29import * as ServerConfig from "./config.ts";
30import * as SqlitePersistence from "./persistence/Sqlite.ts";30import * as SqlitePersistence from "./persistence/Sqlite.ts";
31import { writeFileStringAtomically } from "./atomicWrite.ts";31import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
32import * as ServerSettingsModule from "./serverSettings.ts";32import * as ServerSettingsModule from "./serverSettings.ts";
33import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";33import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";
3434
apps/server/src/serverSettings.ts+1-1
...@@ -53,7 +53,7 @@ import * as Semaphore from "effect/Semaphore";...@@ -53,7 +53,7 @@ import * as Semaphore from "effect/Semaphore";
53import * as Scope from "effect/Scope";53import * as Scope from "effect/Scope";
54import * as Stream from "effect/Stream";54import * as Stream from "effect/Stream";
55import * as SqlClient from "effect/sql/SqlClient";55import * as SqlClient from "effect/sql/SqlClient";
56import { writeFileStringAtomically } from "./atomicWrite.ts";56import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
57import { resolveSymlinkTarget } from "@t3tools/shared/symlink";57import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
58import * as ServerConfig from "./config.ts";58import * as ServerConfig from "./config.ts";
59import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";59import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
apps/server/src/telemetry/Identify.ts+1-1
...@@ -8,7 +8,7 @@ import * as Path from "effect/Path";...@@ -8,7 +8,7 @@ import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";8import * as PlatformError from "effect/PlatformError";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
1010
11import { writeFileStringAtomically } from "../atomicWrite.ts";11import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
12import * as ServerConfig from "../config.ts";12import * as ServerConfig from "../config.ts";
1313
14/**14/**
apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts+4-6
...@@ -4,15 +4,13 @@ import { ProviderInstanceId } from "@t3tools/contracts";...@@ -4,15 +4,13 @@ import { ProviderInstanceId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
66
7import * as ServerConfig from "../config.ts";7import { layerTestProviderHost } from "@t3tools/provider-testing/host";
8import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts";8import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts";
9import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts";9import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server";
10import * as OpenCode2TextGeneration from "./OpenCode2TextGeneration.ts";10import * as OpenCode2TextGeneration from "@t3tools/provider-opencode/server/v2/textGeneration";
11import { OPENCODE2_TITLE_GENERATION } from "./OpenCode2TextGeneration.fixture.ts";11import { OPENCODE2_TITLE_GENERATION } from "./OpenCode2TextGeneration.fixture.ts";
1212
13const layer = ServerConfig.ServerConfig.layerTest(process.cwd(), {13const layer = layerTestProviderHost().pipe(Layer.provideMerge(NodeServices.layer));
14 prefix: "t3code-opencode2-text-generation-test-",
15}).pipe(Layer.provideMerge(NodeServices.layer));
1614
17it.layer(layer)("OpenCode2TextGeneration", (it) => {15it.layer(layer)("OpenCode2TextGeneration", (it) => {
18 it.effect("generates a title in a temporary session on the free tier and removes it", () =>16 it.effect("generates a title in a temporary session on the free tier and removes it", () =>
apps/server/src/textGeneration/OpenCode2TextGeneration.ts deleted-206
...@@ -1,206 +0,0 @@
1/**
2 * Text generation on an OpenCode 2 server. Both of its `generate` endpoints
3 * answer 403 on OpenCode's free tier ("free tier can only be used from within
4 * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x
5 * does, and the session is removed afterwards.
6 *
7 * @module textGeneration/OpenCode2TextGeneration
8 */
9import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect";
10import { TextGenerationError } from "@t3tools/contracts";
11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16
17import * as ServerConfig from "../config.ts";
18import { resolveAttachmentPath } from "../attachmentStore.ts";
19import type { OpenCode2Connection } from "../provider/opencode2/OpenCode2Server.ts";
20import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts";
21import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts";
22import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
23
24const isTextGenerationError = Schema.is(TextGenerationError);
25
26/** A generation that takes this long is stuck; its session is stopped and removed. */
27const GENERATION_TIMEOUT = "3 minutes";
28
29/**
30 * Nothing in a text generation needs a tool, so every tool asks and T3 is not
31 * there to answer. Denying `shell` or `read` outright gets the whole session
32 * refused on OpenCode's free models, and an ask that is never answered would
33 * hang, so asks are rejected as they arrive.
34 */
35const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const;
36
37/** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */
38type Outcome =
39 | { readonly _tag: "text"; readonly text: string }
40 | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown };
41
42const runOnServer = (
43 connection: OpenCode2Connection,
44 input: TextGenerationOperations.Request<Schema.Top>,
45 attachmentsDir: string,
46) =>
47 Effect.gen(function* () {
48 const { client } = connection;
49 const parsed = parseOpenCodeModelSlug(input.modelSelection.model);
50 if (parsed === null) {
51 return yield* new TextGenerationError({
52 operation: input.operation,
53 detail: "OpenCode model selection must use the 'provider/model' format.",
54 });
55 }
56 const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant");
57 const outcome = yield* Deferred.make<Outcome>();
58 let sessionId: string | undefined;
59 const texts = new Map<string, string>();
60 // Subscribed first: the stream is volatile, and the reply arrives on it.
61 const events = yield* connection.events;
62 yield* events.pipe(
63 Stream.runForEach((event) => {
64 // An execution end a newer server sent in a shape this build cannot
65 // read still ends the generation, as it ends a turn in the adapter.
66 if (event.type === "unreadable.execution.ended") {
67 if (event.sessionID !== sessionId) return Effect.void;
68 return Deferred.succeed(
69 outcome,
70 event.executionType === "session.execution.succeeded"
71 ? { _tag: "text", text: [...texts.values()].join("\n").trim() }
72 : {
73 _tag: "failed",
74 detail: "OpenCode ended the generation in a way this version cannot read.",
75 cause: event,
76 },
77 );
78 }
79 if (
80 sessionId === undefined ||
81 !("data" in event) ||
82 !("sessionID" in event.data) ||
83 event.data.sessionID !== sessionId
84 ) {
85 return Effect.void;
86 }
87 switch (event.type) {
88 case "session.text.ended":
89 texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text);
90 return Effect.void;
91 case "permission.asked":
92 return client.permission
93 .reply({
94 sessionID: event.data.sessionID,
95 requestID: event.data.id,
96 decision: "reject",
97 message: "Tools are not available while generating text.",
98 })
99 .pipe(Effect.ignore({ log: true }));
100 case "session.execution.succeeded":
101 return Deferred.succeed(outcome, {
102 _tag: "text",
103 text: [...texts.values()].join("\n").trim(),
104 });
105 case "session.execution.failed":
106 return Deferred.succeed(outcome, {
107 _tag: "failed",
108 detail: "OpenCode could not generate the text.",
109 cause: event.data.error,
110 });
111 case "session.execution.interrupted":
112 return Deferred.succeed(outcome, {
113 _tag: "failed",
114 detail: "OpenCode stopped the generation.",
115 });
116 default:
117 return Effect.void;
118 }
119 }),
120 // The reply only arrives on this stream, so a lost stream ends the wait.
121 Effect.exit,
122 Effect.flatMap((exit) =>
123 Deferred.succeed(outcome, {
124 _tag: "failed",
125 detail: "The OpenCode event stream was lost.",
126 cause: exit,
127 }),
128 ),
129 Effect.forkScoped,
130 );
131 const session = yield* client.session.create({
132 title: `T3 Code ${input.operation}`,
133 location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }),
134 model: Model.Ref.make({
135 providerID: Provider.ID.make(parsed.providerID),
136 id: Model.ID.make(parsed.modelID),
137 ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
138 }),
139 permissions: GENERATION_PERMISSIONS,
140 });
141 sessionId = session.id;
142 yield* Effect.addFinalizer(() =>
143 client.session
144 .remove({ sessionID: session.id })
145 .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })),
146 );
147 const images = (input.attachments ?? []).flatMap((attachment) => {
148 if (attachment.type !== "image") return [];
149 const path = resolveAttachmentPath({ attachmentsDir, attachment });
150 return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }];
151 });
152 yield* client.session.prompt({
153 sessionID: session.id,
154 text: input.prompt,
155 ...(images.length === 0 ? {} : { files: images }),
156 });
157 const result = yield* Deferred.await(outcome).pipe(
158 Effect.timeoutOrElse({
159 duration: GENERATION_TIMEOUT,
160 orElse: () =>
161 client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe(
162 Effect.ignore,
163 Effect.as<Outcome>({
164 _tag: "failed",
165 detail: "OpenCode did not finish generating in time.",
166 }),
167 ),
168 }),
169 );
170 if (result._tag === "failed") {
171 return yield* new TextGenerationError({
172 operation: input.operation,
173 detail: result.detail,
174 ...(result.cause === undefined ? {} : { cause: result.cause }),
175 });
176 }
177 if (result.text.length === 0) {
178 return yield* new TextGenerationError({
179 operation: input.operation,
180 detail: "OpenCode returned empty output.",
181 });
182 }
183 return result.text;
184 }).pipe(Effect.scoped);
185
186/** Text generation for an instance whose server is OpenCode 2. */
187export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () {
188 const server = yield* OpenCode2Server.OpenCode2Server;
189 const { attachmentsDir } = yield* ServerConfig.ServerConfig;
190 const run: TextGenerationOperations.Runner = (input) =>
191 server
192 .withConnection((connection) => runOnServer(connection, input, attachmentsDir))
193 .pipe(
194 Effect.mapError((cause) =>
195 isTextGenerationError(cause)
196 ? cause
197 : new TextGenerationError({
198 operation: input.operation,
199 detail: "OpenCode text generation failed.",
200 cause,
201 }),
202 ),
203 Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)),
204 );
205 return TextGenerationOperations.fromRunner("OpenCode2TextGeneration", run);
206});
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts deleted-616
...@@ -1,616 +0,0 @@
1import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it } from "@effect/vitest";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import * as TestClock from "effect/testing/TestClock";
9import * as NetService from "@t3tools/shared/Net";
10import { beforeEach, expect } from "vite-plus/test";
11
12import * as ServerConfig from "../config.ts";
13import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
14import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts";
15import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts";
16import * as TextGeneration from "./TextGeneration.ts";
17
18const runtimeMock = {
19 state: {
20 startCalls: [] as string[],
21 promptUrls: [] as string[],
22 promptParts: [] as ReadonlyArray<unknown>[],
23 authHeaders: [] as Array<string | null>,
24 closeCalls: [] as string[],
25 sessionCreateCalls: 0,
26 connectionError: undefined as Error | undefined,
27 sessionCreateError: undefined as unknown,
28 sessionResult: undefined as { data?: { id: string } } | undefined,
29 promptRequestError: undefined as unknown,
30 promptResult: undefined as
31 | { data?: { info?: { error?: unknown }; parts?: Array<unknown> } }
32 | undefined,
33 },
34 reset() {
35 this.state.startCalls.length = 0;
36 this.state.promptUrls.length = 0;
37 this.state.promptParts.length = 0;
38 this.state.authHeaders.length = 0;
39 this.state.closeCalls.length = 0;
40 this.state.sessionCreateCalls = 0;
41 this.state.connectionError = undefined;
42 this.state.sessionCreateError = undefined;
43 this.state.sessionResult = undefined;
44 this.state.promptRequestError = undefined;
45 this.state.promptResult = undefined;
46 },
47};
48
49const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
50 startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) =>
51 Effect.gen(function* () {
52 const index = runtimeMock.state.startCalls.length + 1;
53 const url = `http://127.0.0.1:${4_300 + index}`;
54 runtimeMock.state.startCalls.push(binaryPath);
55 // The production runtime binds server lifetime to the caller's scope.
56 // Mirror that here so the closeCalls probe observes scope close.
57 yield* Effect.addFinalizer(() =>
58 Effect.sync(() => {
59 runtimeMock.state.closeCalls.push(url);
60 }),
61 );
62 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
63 external: false,
64 ...(serverPassword !== undefined ? { serverPassword } : {}),
65 ...(environment !== undefined ? { environment } : {}),
66 });
67 return {
68 url,
69 ...(effectiveServerPassword !== undefined
70 ? { serverPassword: effectiveServerPassword }
71 : {}),
72 version: "1.14.19",
73 isRunning: Effect.succeed(true),
74 exitCode: Effect.never,
75 };
76 }),
77 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
78 runtimeMock.state.connectionError
79 ? Effect.fail(
80 new OpenCodeRuntime.OpenCodeRuntimeError({
81 operation: "global.health",
82 detail: runtimeMock.state.connectionError.message,
83 cause: runtimeMock.state.connectionError,
84 }),
85 )
86 : Effect.succeed({
87 url: serverUrl ?? "http://127.0.0.1:4301",
88 ...(serverPassword ? { serverPassword } : {}),
89 version: "1.14.19",
90 exitCode: null,
91 external: Boolean(serverUrl),
92 }),
93 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
94 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
95 ({
96 session: {
97 create: async () => {
98 runtimeMock.state.sessionCreateCalls += 1;
99 if (runtimeMock.state.sessionCreateError !== undefined) {
100 throw runtimeMock.state.sessionCreateError;
101 }
102 return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };
103 },
104 prompt: async (input: { readonly parts: ReadonlyArray<unknown> }) => {
105 runtimeMock.state.promptUrls.push(baseUrl);
106 runtimeMock.state.promptParts.push(input.parts);
107 runtimeMock.state.authHeaders.push(
108 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
109 );
110 if (runtimeMock.state.promptRequestError !== undefined) {
111 throw runtimeMock.state.promptRequestError;
112 }
113 return (
114 runtimeMock.state.promptResult ?? {
115 data: {
116 parts: [
117 {
118 type: "text",
119 text: JSON.stringify({
120 subject: "Improve OpenCode reuse",
121 body: "Reuse one server for the full action.",
122 }),
123 },
124 ],
125 },
126 }
127 );
128 },
129 },
130 }) as unknown as ReturnType<OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
131 loadOpenCodeInventory: () =>
132 Effect.fail(
133 new OpenCodeRuntime.OpenCodeRuntimeError({
134 operation: "loadOpenCodeInventory",
135 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
136 cause: null,
137 }),
138 ),
139 loadOpenCodeSkills: () => Effect.succeed([]),
140 loadInventoryFromCli: () =>
141 Effect.fail(
142 new OpenCodeRuntime.OpenCodeRuntimeError({
143 operation: "loadInventoryFromCli",
144 detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test",
145 cause: null,
146 }),
147 ),
148 loadSkillsFromCli: () => Effect.succeed([]),
149};
150
151const DEFAULT_TEST_MODEL_SELECTION = {
152 instanceId: ProviderInstanceId.make("opencode"),
153 model: "openai/gpt-5",
154};
155const DEFAULT_COMMIT_MESSAGE_INPUT = {
156 cwd: process.cwd(),
157 branch: "feature/opencode-reuse",
158 stagedSummary: "M README.md",
159 stagedPatch: "diff --git a/README.md b/README.md",
160 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
161};
162
163const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
164
165const layerOpenCodeTextGenerationTest = Layer.succeed(
166 OpenCodeRuntime.OpenCodeRuntime,
167 OpenCodeRuntimeTestDouble,
168).pipe(
169 Layer.provideMerge(
170 ServerConfig.ServerConfig.layerTest(process.cwd(), {
171 prefix: "t3code-opencode-text-generation-test-",
172 }),
173 ),
174 Layer.provideMerge(NetService.layer),
175 Layer.provideMerge(NodeServices.layer),
176);
177
178const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed(
179 OpenCodeRuntime.OpenCodeRuntime,
180 OpenCodeRuntimeTestDouble,
181).pipe(
182 Layer.provideMerge(
183 ServerConfig.ServerConfig.layerTest(process.cwd(), {
184 prefix: "t3code-opencode-text-generation-existing-server-test-",
185 }),
186 ),
187 Layer.provideMerge(NetService.layer),
188 Layer.provideMerge(NodeServices.layer),
189);
190
191const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
192 binaryPath: "fake-opencode",
193});
194const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
195 binaryPath: "fake-opencode",
196 serverPassword: "secret-password",
197});
198const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
199 binaryPath: "fake-opencode",
200 serverUrl: "http://127.0.0.1:9999",
201 serverPassword: "secret-password",
202});
203const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
204 binaryPath: "fake-opencode",
205 serverUrl: "http://127.0.0.1:9999",
206});
207
208function withOpenCodeTextGeneration<A, E, R>(
209 settings: OpenCodeSettings,
210 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
211 environment?: NodeJS.ProcessEnv,
212) {
213 return Effect.gen(function* () {
214 const serverOwner = yield* OpenCodeServerOwner.make({
215 binaryPath: settings.binaryPath,
216 directory: process.cwd(),
217 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
218 ...(environment ? { environment } : {}),
219 });
220 const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe(
221 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
222 );
223 return yield* effectFn(textGeneration);
224 }).pipe(Effect.scoped);
225}
226
227beforeEach(() => {
228 runtimeMock.reset();
229});
230
231const advanceIdleClock = Effect.gen(function* () {
232 yield* Effect.yieldNow;
233 yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1));
234 yield* Effect.yieldNow;
235});
236
237it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => {
238 it.effect("excludes generic files from thread title generation", () =>
239 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
240 Effect.gen(function* () {
241 runtimeMock.state.promptResult = {
242 data: {
243 parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }],
244 },
245 };
246
247 yield* textGeneration.generateThreadTitle({
248 cwd: process.cwd(),
249 message: "Review these attachments.",
250 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
251 attachments: [
252 {
253 type: "image",
254 id: "thread-image-attachment",
255 name: "screenshot.png",
256 mimeType: "image/png",
257 sizeBytes: 3,
258 },
259 {
260 type: "file",
261 id: "thread-report-attachment-pdf",
262 name: "report.pdf",
263 mimeType: "application/pdf",
264 sizeBytes: 42,
265 },
266 ],
267 });
268
269 expect(runtimeMock.state.promptParts[0]).toEqual([
270 expect.objectContaining({ type: "text" }),
271 expect.objectContaining({ type: "file", filename: "screenshot.png" }),
272 ]);
273 }),
274 ),
275 );
276
277 it.effect("passes configured authentication to a locally spawned server", () =>
278 withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) =>
279 Effect.gen(function* () {
280 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
281
282 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
283 expect(runtimeMock.state.authHeaders).toEqual([
284 `Basic ${btoa("opencode:secret-password")}`,
285 ]);
286 }),
287 ),
288 );
289
290 it.effect("uses an environment-only password for a locally spawned server", () =>
291 withOpenCodeTextGeneration(
292 DEFAULT_OPENCODE_SETTINGS,
293 (textGeneration) =>
294 Effect.gen(function* () {
295 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
296
297 expect(runtimeMock.state.authHeaders).toEqual([
298 `Basic ${btoa("opencode:environment-password")}`,
299 ]);
300 }),
301 { OPENCODE_SERVER_PASSWORD: "environment-password" },
302 ),
303 );
304
305 it.effect("uses settings auth when the local environment password differs", () =>
306 withOpenCodeTextGeneration(
307 LOCAL_AUTH_OPENCODE_SETTINGS,
308 (textGeneration) =>
309 Effect.gen(function* () {
310 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
311
312 expect(runtimeMock.state.authHeaders).toEqual([
313 `Basic ${btoa("opencode:secret-password")}`,
314 ]);
315 }),
316 { OPENCODE_SERVER_PASSWORD: "environment-password" },
317 ),
318 );
319
320 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
321 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
322 Effect.gen(function* () {
323 yield* textGeneration.generateCommitMessage({
324 cwd: process.cwd(),
325 branch: "feature/opencode-reuse",
326 stagedSummary: "M README.md",
327 stagedPatch: "diff --git a/README.md b/README.md",
328 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
329 });
330 yield* textGeneration.generateCommitMessage({
331 cwd: process.cwd(),
332 branch: "feature/opencode-reuse",
333 stagedSummary: "M README.md",
334 stagedPatch: "diff --git a/README.md b/README.md",
335 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
336 });
337
338 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
339 expect(runtimeMock.state.promptUrls).toEqual([
340 "http://127.0.0.1:4301",
341 "http://127.0.0.1:4301",
342 ]);
343 expect(runtimeMock.state.closeCalls).toEqual([]);
344
345 yield* advanceIdleClock;
346
347 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
348 }),
349 ).pipe(Effect.provide(TestClock.layer())),
350 );
351
352 it.effect("starts a new server after the warm server idles out", () =>
353 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
354 Effect.gen(function* () {
355 yield* textGeneration.generateCommitMessage({
356 cwd: process.cwd(),
357 branch: "feature/opencode-reuse",
358 stagedSummary: "M README.md",
359 stagedPatch: "diff --git a/README.md b/README.md",
360 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
361 });
362
363 yield* advanceIdleClock;
364
365 yield* textGeneration.generateCommitMessage({
366 cwd: process.cwd(),
367 branch: "feature/opencode-reuse",
368 stagedSummary: "M README.md",
369 stagedPatch: "diff --git a/README.md b/README.md",
370 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
371 });
372
373 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
374 expect(runtimeMock.state.promptUrls).toEqual([
375 "http://127.0.0.1:4301",
376 "http://127.0.0.1:4302",
377 ]);
378 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
379 }),
380 ).pipe(Effect.provide(TestClock.layer())),
381 );
382
383 it.effect("preserves the SDK cause when session creation fails", () =>
384 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
385 Effect.gen(function* () {
386 const sdkCause = new Error("session endpoint unavailable");
387 runtimeMock.state.sessionCreateError = sdkCause;
388
389 const error = yield* textGeneration
390 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
391 .pipe(Effect.flip);
392
393 expect(error).toBeInstanceOf(TextGenerationError);
394 expect(error.message).toContain("OpenCode session.create request failed.");
395 expect(error.cause).toMatchObject({
396 _tag: "OpenCodeTextGenerationSessionRequestError",
397 operation: "generateCommitMessage",
398 cwd: process.cwd(),
399 cause: sdkCause,
400 });
401 expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
402 }),
403 ),
404 );
405
406 it.effect("reports a missing session payload without manufacturing a cause", () =>
407 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
408 Effect.gen(function* () {
409 runtimeMock.state.sessionResult = {};
410
411 const error = yield* textGeneration
412 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
413 .pipe(Effect.flip);
414
415 expect(error.message).toContain("OpenCode session.create returned no session payload.");
416 expect(error.cause).toMatchObject({
417 _tag: "OpenCodeTextGenerationSessionPayloadError",
418 operation: "generateCommitMessage",
419 cwd: process.cwd(),
420 });
421 expect(error.cause).not.toHaveProperty("cause");
422 }),
423 ),
424 );
425
426 it.effect("preserves the SDK cause and request context when prompting fails", () =>
427 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
428 Effect.gen(function* () {
429 const sdkCause = new Error("prompt endpoint unavailable");
430 runtimeMock.state.promptRequestError = sdkCause;
431
432 const error = yield* textGeneration
433 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
434 .pipe(Effect.flip);
435
436 expect(error.message).toContain("OpenCode session.prompt request failed.");
437 expect(error.cause).toMatchObject({
438 _tag: "OpenCodeTextGenerationPromptRequestError",
439 operation: "generateCommitMessage",
440 cwd: process.cwd(),
441 sessionId: "http://127.0.0.1:4301/session",
442 providerId: "openai",
443 modelId: "gpt-5",
444 cause: sdkCause,
445 });
446 expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
447 }),
448 ),
449 );
450
451 it.effect("returns a typed empty-output error for malformed and blank response parts", () =>
452 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
453 Effect.gen(function* () {
454 runtimeMock.state.promptResult = {
455 data: {
456 parts: [null, { type: "tool" }, { type: "text", text: " " }],
457 },
458 };
459
460 const error = yield* textGeneration
461 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
462 .pipe(Effect.flip);
463
464 expect(error.message).toContain("OpenCode returned empty output.");
465 expect(error.cause).toMatchObject({
466 _tag: "OpenCodeTextGenerationEmptyOutputError",
467 operation: "generateCommitMessage",
468 cwd: process.cwd(),
469 sessionId: "http://127.0.0.1:4301/session",
470 providerId: "openai",
471 modelId: "gpt-5",
472 responsePartCount: 3,
473 textPartCount: 1,
474 });
475 expect(error.cause).not.toHaveProperty("cause");
476 }),
477 ),
478 );
479
480 it.effect("parses JSON returned as plain text output", () =>
481 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
482 Effect.gen(function* () {
483 runtimeMock.state.promptResult = {
484 data: {
485 parts: [
486 {
487 type: "text",
488 text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}',
489 },
490 ],
491 },
492 };
493
494 const result = yield* textGeneration.generateCommitMessage({
495 cwd: process.cwd(),
496 branch: "feature/opencode-reuse",
497 stagedSummary: "M README.md",
498 stagedPatch: "diff --git a/README.md b/README.md",
499 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
500 });
501
502 expect(result).toEqual({
503 subject: "Tighten OpenCode parsing",
504 body: "Handle JSON text output locally.",
505 });
506 }),
507 ),
508 );
509
510 it.effect("surfaces the upstream OpenCode structured-output error message", () =>
511 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
512 Effect.gen(function* () {
513 runtimeMock.state.promptResult = {
514 data: {
515 info: {
516 error: {
517 name: "StructuredOutputError",
518 data: {
519 message: "Model did not produce structured output",
520 retries: 2,
521 },
522 },
523 },
524 },
525 };
526
527 const error = yield* textGeneration
528 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
529 .pipe(Effect.flip);
530
531 expect(error.message).toContain("Model did not produce structured output");
532 expect(error.cause).toMatchObject({
533 _tag: "OpenCodeTextGenerationPromptResponseError",
534 operation: "generateCommitMessage",
535 cwd: process.cwd(),
536 sessionId: "http://127.0.0.1:4301/session",
537 providerId: "openai",
538 modelId: "gpt-5",
539 providerErrorName: "StructuredOutputError",
540 providerMessage: "Model did not produce structured output",
541 });
542 expect(error.cause).not.toHaveProperty("cause");
543 }),
544 ),
545 );
546});
547
548it.layer(layerOpenCodeTextGenerationExistingServerTest)(
549 "OpenCodeTextGeneration with configured server URL",
550 (it) => {
551 it.effect("does not send a local environment password to a configured server", () =>
552 withOpenCodeTextGeneration(
553 EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS,
554 (textGeneration) =>
555 Effect.gen(function* () {
556 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
557 expect(runtimeMock.state.authHeaders).toEqual([null]);
558 }),
559 { OPENCODE_SERVER_PASSWORD: "local-secret" },
560 ),
561 );
562
563 it.effect("does not create a session when the server version is unsupported", () =>
564 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
565 Effect.gen(function* () {
566 runtimeMock.state.connectionError = new Error(
567 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
568 );
569
570 const error = yield* textGeneration
571 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
572 .pipe(Effect.flip);
573
574 expect(error).toBeInstanceOf(TextGenerationError);
575 expect(error.message).toContain("v1.14.18 is too old");
576 expect(runtimeMock.state.sessionCreateCalls).toBe(0);
577 }),
578 ),
579 );
580
581 it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
582 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
583 Effect.gen(function* () {
584 yield* textGeneration.generateCommitMessage({
585 cwd: process.cwd(),
586 branch: "feature/opencode-reuse",
587 stagedSummary: "M README.md",
588 stagedPatch: "diff --git a/README.md b/README.md",
589 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
590 });
591 yield* textGeneration.generateCommitMessage({
592 cwd: process.cwd(),
593 branch: "feature/opencode-reuse",
594 stagedSummary: "M README.md",
595 stagedPatch: "diff --git a/README.md b/README.md",
596 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
597 });
598
599 expect(runtimeMock.state.startCalls).toEqual([]);
600 expect(runtimeMock.state.promptUrls).toEqual([
601 "http://127.0.0.1:9999",
602 "http://127.0.0.1:9999",
603 ]);
604 expect(runtimeMock.state.authHeaders).toEqual([
605 `Basic ${btoa("opencode:secret-password")}`,
606 `Basic ${btoa("opencode:secret-password")}`,
607 ]);
608
609 yield* advanceIdleClock;
610
611 expect(runtimeMock.state.closeCalls).toEqual([]);
612 }),
613 ).pipe(Effect.provide(TestClock.layer())),
614 );
615 },
616);
apps/server/src/textGeneration/OpenCodeTextGeneration.ts deleted-325
...@@ -1,325 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3
4import { NonNegativeInt, TextGenerationError, type OpenCodeSettings } from "@t3tools/contracts";
5import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
6
7import * as ServerConfig from "../config.ts";
8import { resolveAttachmentPath } from "../attachmentStore.ts";
9import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
10import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
11import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts";
12
13const OpenCodeTextGenerationOperation = Schema.Literals([
14 "generateCommitMessage",
15 "generatePrContent",
16 "generateBranchName",
17 "generateThreadTitle",
18]);
19
20const openCodeTextGenerationErrorContext = {
21 operation: OpenCodeTextGenerationOperation,
22 cwd: Schema.String,
23};
24
25export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedError<OpenCodeTextGenerationSessionRequestError>()(
26 "OpenCodeTextGenerationSessionRequestError",
27 {
28 ...openCodeTextGenerationErrorContext,
29 cause: Schema.Defect(),
30 },
31) {
32 override get message(): string {
33 return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`;
34 }
35}
36
37export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedError<OpenCodeTextGenerationSessionPayloadError>()(
38 "OpenCodeTextGenerationSessionPayloadError",
39 openCodeTextGenerationErrorContext,
40) {
41 override get message(): string {
42 return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`;
43 }
44}
45
46const openCodePromptErrorContext = {
47 ...openCodeTextGenerationErrorContext,
48 sessionId: Schema.String,
49 providerId: Schema.String,
50 modelId: Schema.String,
51};
52
53export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedError<OpenCodeTextGenerationPromptRequestError>()(
54 "OpenCodeTextGenerationPromptRequestError",
55 {
56 ...openCodePromptErrorContext,
57 cause: Schema.Defect(),
58 },
59) {
60 override get message(): string {
61 return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`;
62 }
63}
64
65export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedError<OpenCodeTextGenerationPromptResponseError>()(
66 "OpenCodeTextGenerationPromptResponseError",
67 {
68 ...openCodePromptErrorContext,
69 providerErrorName: Schema.optional(Schema.String),
70 providerMessage: Schema.String,
71 },
72) {
73 override get message(): string {
74 const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : "";
75 return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`;
76 }
77}
78
79export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError<OpenCodeTextGenerationEmptyOutputError>()(
80 "OpenCodeTextGenerationEmptyOutputError",
81 {
82 ...openCodePromptErrorContext,
83 responsePartCount: NonNegativeInt,
84 textPartCount: NonNegativeInt,
85 },
86) {
87 override get message(): string {
88 return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`;
89 }
90}
91
92interface OpenCodePromptFailure {
93 readonly name?: string;
94 readonly message: string;
95}
96
97interface OpenCodeTextPart {
98 readonly type: "text";
99 readonly text: string;
100}
101
102function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null {
103 if (!error || typeof error !== "object") {
104 return null;
105 }
106
107 const name =
108 "name" in error && typeof error.name === "string" && error.name.trim().length > 0
109 ? error.name.trim()
110 : undefined;
111 const message =
112 "data" in error &&
113 error.data &&
114 typeof error.data === "object" &&
115 "message" in error.data &&
116 typeof error.data.message === "string"
117 ? error.data.message.trim()
118 : "";
119 if (message.length > 0) {
120 return {
121 ...(name ? { name } : {}),
122 message,
123 };
124 }
125
126 if (name) {
127 return { name, message: name };
128 }
129
130 return null;
131}
132
133function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart {
134 return (
135 part !== null &&
136 typeof part === "object" &&
137 "type" in part &&
138 part.type === "text" &&
139 "text" in part &&
140 typeof part.text === "string"
141 );
142}
143
144function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string {
145 return (parts ?? [])
146 .filter(isOpenCodeTextPart)
147 .map((part) => part.text)
148 .join("")
149 .trim();
150}
151
152export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
153 openCodeSettings: OpenCodeSettings,
154) {
155 const serverConfig = yield* ServerConfig.ServerConfig;
156 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
157 const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
158
159 const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(
160 input: TextGenerationOperations.Request<S>,
161 ) {
162 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model);
163 if (!parsedModel) {
164 return yield* new TextGenerationError({
165 operation: input.operation,
166 detail: "OpenCode model selection must use the 'provider/model' format.",
167 });
168 }
169
170 const fileParts = OpenCodeRuntime.toOpenCodeFileParts({
171 attachments: input.attachments?.filter((attachment) => attachment.type === "image"),
172 resolveAttachmentPath: (attachment) =>
173 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
174 });
175
176 const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")(
177 function* (
178 server: Pick<
179 OpenCodeRuntime.OpenCodeServerConnection,
180 "url" | "serverPassword" | "version"
181 >,
182 ) {
183 const client = openCodeRuntime.createOpenCodeSdkClient({
184 baseUrl: server.url,
185 directory: input.cwd,
186 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
187 });
188 const session = yield* Effect.tryPromise({
189 try: () =>
190 client.session.create({
191 title: `T3 Code ${input.operation}`,
192 permission: [{ permission: "*", pattern: "*", action: "deny" }],
193 }),
194 catch: (cause) =>
195 new OpenCodeTextGenerationSessionRequestError({
196 operation: input.operation,
197 cwd: input.cwd,
198 cause,
199 }),
200 });
201 if (!session.data) {
202 return yield* new OpenCodeTextGenerationSessionPayloadError({
203 operation: input.operation,
204 cwd: input.cwd,
205 });
206 }
207 const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent");
208 const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant");
209 const promptContext = {
210 operation: input.operation,
211 cwd: input.cwd,
212 sessionId: session.data.id,
213 providerId: parsedModel.providerID,
214 modelId: parsedModel.modelID,
215 };
216
217 const result = yield* Effect.tryPromise({
218 try: () =>
219 client.session.prompt({
220 sessionID: session.data.id,
221 model: parsedModel,
222 ...(selectedAgent ? { agent: selectedAgent } : {}),
223 ...(selectedVariant ? { variant: selectedVariant } : {}),
224 parts: [{ type: "text", text: input.prompt }, ...fileParts],
225 }),
226 catch: (cause) =>
227 new OpenCodeTextGenerationPromptRequestError({
228 ...promptContext,
229 cause,
230 }),
231 });
232 const promptFailure = getOpenCodePromptFailure(result.data?.info?.error);
233 if (promptFailure) {
234 return yield* new OpenCodeTextGenerationPromptResponseError({
235 ...promptContext,
236 ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}),
237 providerMessage: promptFailure.message,
238 });
239 }
240 const responseParts = result.data?.parts ?? [];
241 const rawText = getOpenCodeTextResponse(responseParts);
242 if (rawText.length === 0) {
243 return yield* new OpenCodeTextGenerationEmptyOutputError({
244 ...promptContext,
245 responsePartCount: responseParts.length,
246 textPartCount: responseParts.filter(isOpenCodeTextPart).length,
247 });
248 }
249 return rawText;
250 },
251 Effect.catchTags({
252 OpenCodeTextGenerationSessionRequestError: (cause) =>
253 Effect.fail(
254 new TextGenerationError({
255 operation: cause.operation,
256 detail: "OpenCode session.create request failed.",
257 cause,
258 }),
259 ),
260 OpenCodeTextGenerationSessionPayloadError: (cause) =>
261 Effect.fail(
262 new TextGenerationError({
263 operation: cause.operation,
264 detail: "OpenCode session.create returned no session payload.",
265 cause,
266 }),
267 ),
268 OpenCodeTextGenerationPromptRequestError: (cause) =>
269 Effect.fail(
270 new TextGenerationError({
271 operation: cause.operation,
272 detail: "OpenCode session.prompt request failed.",
273 cause,
274 }),
275 ),
276 OpenCodeTextGenerationPromptResponseError: (cause) =>
277 Effect.fail(
278 new TextGenerationError({
279 operation: cause.operation,
280 detail: cause.providerMessage,
281 cause,
282 }),
283 ),
284 OpenCodeTextGenerationEmptyOutputError: (cause) =>
285 Effect.fail(
286 new TextGenerationError({
287 operation: cause.operation,
288 detail: "OpenCode returned empty output.",
289 cause,
290 }),
291 ),
292 }),
293 );
294
295 const serverOutput =
296 openCodeSettings.serverUrl.length > 0
297 ? openCodeRuntime
298 .connectToOpenCodeServer({
299 binaryPath: openCodeSettings.binaryPath,
300 directory: input.cwd,
301 serverUrl: openCodeSettings.serverUrl,
302 ...(openCodeSettings.serverPassword
303 ? { serverPassword: openCodeSettings.serverPassword }
304 : {}),
305 })
306 .pipe(Effect.flatMap(runAgainstServer), Effect.scoped)
307 : serverOwner.withServer(runAgainstServer);
308 const rawOutput = yield* serverOutput.pipe(
309 Effect.catchTags({
310 OpenCodeRuntimeError: (cause) =>
311 Effect.fail(
312 new TextGenerationError({
313 operation: input.operation,
314 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
315 cause,
316 }),
317 ),
318 }),
319 );
320
321 return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput);
322 });
323
324 return TextGenerationOperations.fromRunner("OpenCodeTextGeneration", runOpenCodeJson);
325});
apps/server/src/usage/UsageService.ts+1-1
...@@ -52,7 +52,7 @@ import * as Schema from "effect/Schema";...@@ -52,7 +52,7 @@ import * as Schema from "effect/Schema";
52import * as Semaphore from "effect/Semaphore";52import * as Semaphore from "effect/Semaphore";
53import { HttpClient, HttpClientResponse } from "effect/http";53import { HttpClient, HttpClientResponse } from "effect/http";
5454
55import { writeFileStringAtomically } from "../atomicWrite.ts";55import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
56import * as ServerConfig from "../config.ts";56import * as ServerConfig from "../config.ts";
57import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";57import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
58import * as ServerSettings from "../serverSettings.ts";58import * as ServerSettings from "../serverSettings.ts";
apps/web/package.json+1
...@@ -30,6 +30,7 @@...@@ -30,6 +30,7 @@
30 "@t3tools/contracts": "workspace:*",30 "@t3tools/contracts": "workspace:*",
31 "@t3tools/provider-core": "workspace:*",31 "@t3tools/provider-core": "workspace:*",
32 "@t3tools/provider-muse": "workspace:*",32 "@t3tools/provider-muse": "workspace:*",
33 "@t3tools/provider-opencode": "workspace:*",
33 "@t3tools/provider-pi": "workspace:*",34 "@t3tools/provider-pi": "workspace:*",
34 "@t3tools/shared": "workspace:*",35 "@t3tools/shared": "workspace:*",
35 "@tanstack/react-pacer": "^0.19.4",36 "@tanstack/react-pacer": "^0.19.4",
apps/web/src/components/Icons.tsx-26
...@@ -681,32 +681,6 @@ export const AntigravityIcon: Icon = (props) => (...@@ -681,32 +681,6 @@ export const AntigravityIcon: Icon = (props) => (
681 </svg>681 </svg>
682);682);
683683
684export const OpenCodeIcon: Icon = (props) => (
685 <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
686 <g clipPath="url(#opencode__clip0_1311_94969)">
687 <path
688 data-opencode-hole="true"
689 className="dark:hidden"
690 d="M24 32H8V16H24V32Z"
691 fill="#CFCECD"
692 />
693 <path className="dark:hidden" d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" />
694 <path
695 data-opencode-hole="true"
696 className="hidden dark:block"
697 d="M24 32H8V16H24V32Z"
698 fill="#4B4646"
699 />
700 <path className="hidden dark:block" d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC" />
701 </g>
702 <defs>
703 <clipPath id="opencode__clip0_1311_94969">
704 <rect width="32" height="40" fill="white" />
705 </clipPath>
706 </defs>
707 </svg>
708);
709
710export const ACPRegistryIcon: Icon = ({ className, ...props }) => (684export const ACPRegistryIcon: Icon = ({ className, ...props }) => (
711 <svg685 <svg
712 {...props}686 {...props}
apps/web/src/components/chat/ChatComposer.tsx+1-1
...@@ -5572,7 +5572,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5572,7 +5572,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5572 activeProviderIconClassName: cn(5572 activeProviderIconClassName: cn(
5573 composerProviderState.modelPickerIconClassName,5573 composerProviderState.modelPickerIconClassName,
5574 composerControlsCollapsed &&5574 composerControlsCollapsed &&
5575 "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70! [&_[data-opencode-hole]]:fill-transparent!",5575 "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70! [&_[data-icon-detail]]:fill-transparent!",
5576 ),5576 ),
5577 }5577 }
5578 : {})}5578 : {})}
apps/web/src/components/chat/ProviderInstanceIcon.tsx+1-11
...@@ -3,15 +3,7 @@ import { type CSSProperties, memo } from "react";...@@ -3,15 +3,7 @@ import { type CSSProperties, memo } from "react";
3import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";3import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
44
5import { ProviderDriverKind } from "@t3tools/contracts";5import { ProviderDriverKind } from "@t3tools/contracts";
6import {6import { AntigravityIcon, ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI } from "../Icons";
7 AntigravityIcon,
8 ClaudeAI,
9 CursorIcon,
10 GrokIcon,
11 Icon,
12 OpenAI,
13 OpenCodeIcon,
14} from "../Icons";
157
16import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
17import { providerClients } from "../settings/providerDriverMeta";9import { providerClients } from "../settings/providerDriverMeta";
...@@ -25,7 +17,6 @@ import {...@@ -25,7 +17,6 @@ import {
25const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {17const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
26 [ProviderDriverKind.make("codex")]: OpenAI,18 [ProviderDriverKind.make("codex")]: OpenAI,
27 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,19 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,
28 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
29 [ProviderDriverKind.make("cursor")]: CursorIcon,20 [ProviderDriverKind.make("cursor")]: CursorIcon,
30 [ProviderDriverKind.make("grok")]: GrokIcon,21 [ProviderDriverKind.make("grok")]: GrokIcon,
31 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,22 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,
...@@ -36,7 +27,6 @@ const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string...@@ -36,7 +27,6 @@ const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string
36 [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]",27 [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]",
37 [ProviderDriverKind.make("cursor")]: "text-[#26251E] dark:text-[#EDECEC]",28 [ProviderDriverKind.make("cursor")]: "text-[#26251E] dark:text-[#EDECEC]",
38 [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]",29 [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]",
39 [ProviderDriverKind.make("opencode")]: "text-[#211E1E] dark:text-[#F1ECEC]",
40 [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]",30 [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]",
41};31};
4232
apps/web/src/components/chat/ProviderPackageIcon.tsx+2
...@@ -31,6 +31,8 @@ export function ProviderPackageIcon({...@@ -31,6 +31,8 @@ export function ProviderPackageIcon({
31 key={path.d}31 key={path.d}
32 d={path.d}32 d={path.d}
33 fillRule={path.fillRule}33 fillRule={path.fillRule}
34 // Monochrome renderings (the collapsed composer) clear these detail paths.
35 data-icon-detail={path.fill ? true : undefined}
34 className={path.fill ? "fill-(--path-light) dark:fill-(--path-dark)" : undefined}36 className={path.fill ? "fill-(--path-light) dark:fill-(--path-dark)" : undefined}
35 style={37 style={
36 path.fill38 path.fill
apps/web/src/components/settings/providerDriverMeta.ts+2-6
...@@ -5,11 +5,11 @@ import {...@@ -5,11 +5,11 @@ import {
5 CodexSettings,5 CodexSettings,
6 CursorSettings,6 CursorSettings,
7 GrokSettings,7 GrokSettings,
8 OpenCodeSettings,
9 ProviderDriverKind,8 ProviderDriverKind,
10} from "@t3tools/contracts";9} from "@t3tools/contracts";
11import { makeProviderClientRegistry } from "@t3tools/provider-core/client";10import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
12import { museClient } from "@t3tools/provider-muse/client";11import { museClient } from "@t3tools/provider-muse/client";
12import { openCodeClient } from "@t3tools/provider-opencode/client";
13import { piClient } from "@t3tools/provider-pi/client";13import { piClient } from "@t3tools/provider-pi/client";
1414
15/** The provider client definitions this web build ships, in presentation order. */15/** The provider client definitions this web build ships, in presentation order. */
...@@ -43,11 +43,7 @@ export const providerClients = makeProviderClientRegistry([...@@ -43,11 +43,7 @@ export const providerClients = makeProviderClientRegistry([
43 label: "Grok",43 label: "Grok",
44 settingsSchema: GrokSettings,44 settingsSchema: GrokSettings,
45 },45 },
46 {46 openCodeClient,
47 driverKind: ProviderDriverKind.make("opencode"),
48 label: "OpenCode",
49 settingsSchema: OpenCodeSettings,
50 },
51 {47 {
52 driverKind: ProviderDriverKind.make("antigravity"),48 driverKind: ProviderDriverKind.make("antigravity"),
53 label: "Antigravity",49 label: "Antigravity",
docs/internals/adding-a-provider.md+3-2
...@@ -110,8 +110,9 @@ A provider lives in its own `packages/provider-<name>` package, with...@@ -110,8 +110,9 @@ A provider lives in its own `packages/provider-<name>` package, with
110- **Package exports.** `./settings` holds the instance settings schema, built with110- **Package exports.** `./settings` holds the instance settings schema, built with
111 `makeProviderSettingsSchema` from contracts. `./client` exports a `ProviderClientDefinition`111 `makeProviderSettingsSchema` from contracts. `./client` exports a `ProviderClientDefinition`
112 (label, settings schema, plain-data icon, badge) and must stay browser- and React Native-safe.112 (label, settings schema, plain-data icon, badge) and must stay browser- and React Native-safe.
113 `./server` exports the `ProviderDriver` and its adapter driver. Settings live in113 `./server` exports the `ProviderDriver` and its adapter driver. `./testing` exposes internals
114 `providerInstances`; new providers are off by default.114 the server's replay testkits drive; testkits that need the orchestrator stay in the server.
115 Settings live in `providerInstances`; new providers are off by default.
115- **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts,116- **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts,
116 and shared. It reaches the server through `ProviderHost` (paths, settings, background demand,117 and shared. It reaches the server through `ProviderHost` (paths, settings, background demand,
117 attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`.118 attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`.
docs/internals/providers.md+4-4
...@@ -17,12 +17,12 @@ directory must not share one T3 MCP entry....@@ -17,12 +17,12 @@ directory must not share one T3 MCP entry.
1717
18- **1.x** uses one T3-managed chat server per thread, so threads cannot replace each other's18- **1.x** uses one T3-managed chat server per thread, so threads cannot replace each other's
19 connection. Catalog and text-generation work can share the19 connection. Catalog and text-generation work can share the
20 [instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes20 [instance-owned helper](../../packages/provider-opencode/src/server/OpenCodeServerOwner.ts), which closes
21 after an idle period. See the [1.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts).21 after an idle period. See the [1.x adapter](../../packages/provider-opencode/src/server/adapter.ts).
22- **2.x** serves every directory from one22- **2.x** serves every directory from one
23 [server per instance](../../apps/server/src/provider/opencode2/OpenCode2Server.ts). Each thread23 [server per instance](../../packages/provider-opencode/src/server/v2/OpenCode2Server.ts). Each thread
24 registers its own `t3-code-<thread>` MCP entry, and session permission rules deny every other24 registers its own `t3-code-<thread>` MCP entry, and session permission rules deny every other
25 thread's entry. See the [2.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts).25 thread's entry. See the [2.x adapter](../../packages/provider-opencode/src/server/v2/adapter.ts).
2626
27External OpenCode servers remain externally owned and can require an external restart to pick up27External OpenCode servers remain externally owned and can require an external restart to pick up
28configuration changes. OpenCode stores "always" approval grants for the whole project. Automatic28configuration changes. OpenCode stores "always" approval grants for the whole project. Automatic
package.json+1-1
...@@ -28,7 +28,7 @@...@@ -28,7 +28,7 @@
28 "tc": "vp run -r --concurrency-limit 2 typecheck",28 "tc": "vp run -r --concurrency-limit 2 typecheck",
29 "lint": "vp lint --report-unused-disable-directives",29 "lint": "vp lint --report-unused-disable-directives",
30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "test": "vp run -r test",34 "test": "vp run -r test",
packages/contracts/src/settings.ts-52
...@@ -907,58 +907,6 @@ export const AcpRegistrySettings = makeProviderSettingsSchema(...@@ -907,58 +907,6 @@ export const AcpRegistrySettings = makeProviderSettingsSchema(
907);907);
908export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;908export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;
909909
910export const OpenCodeSettings = makeProviderSettingsSchema(
911 {
912 // Off by default (like Cursor and Grok): the binding is not yet stable
913 // enough to probe on every install. Users opt in from Settings.
914 enabled: Schema.Boolean.pipe(
915 Schema.withDecodingDefault(Effect.succeed(false)),
916 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
917 ),
918 binaryPath: makeBinaryPathSetting("opencode").pipe(
919 Schema.annotateKey({
920 title: "Binary path",
921 description: "Path to the OpenCode binary.",
922 providerSettingsForm: {
923 placeholder: "opencode",
924 clearWhenEmpty: "omit",
925 },
926 }),
927 ),
928 serverUrl: TrimmedString.pipe(
929 Schema.withDecodingDefault(Effect.succeed("")),
930 Schema.annotateKey({
931 title: "Server URL",
932 description: "Leave blank to let T3 Code spawn the server when needed.",
933 providerSettingsForm: {
934 placeholder: "http://127.0.0.1:4096",
935 clearWhenEmpty: "omit",
936 },
937 }),
938 ),
939 serverPassword: TrimmedString.pipe(
940 Schema.withDecodingDefault(Effect.succeed("")),
941 Schema.annotateKey({
942 title: "Server password",
943 description: "Stored in plain text on disk.",
944 providerSettingsForm: {
945 control: "password",
946 placeholder: "Optional",
947 clearWhenEmpty: "omit",
948 },
949 }),
950 ),
951 customModels: Schema.Array(CustomModelSetting).pipe(
952 Schema.withDecodingDefault(Effect.succeed([])),
953 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
954 ),
955 },
956 {
957 order: ["binaryPath", "serverUrl", "serverPassword"],
958 },
959);
960export type OpenCodeSettings = typeof OpenCodeSettings.Type;
961
962/**910/**
963 * A read-only quota source outside this environment's provider CLIs. The911 * A read-only quota source outside this environment's provider CLIs. The
964 * only kind today is a CLIProxyAPI hub, whose management API reports the912 * only kind today is a CLIProxyAPI hub, whose management API reports the
packages/provider-core/package.json+8
...@@ -75,6 +75,10 @@...@@ -75,6 +75,10 @@
75 "types": "./src/server/mcpToolPresentation.ts",75 "types": "./src/server/mcpToolPresentation.ts",
76 "import": "./src/server/mcpToolPresentation.ts"76 "import": "./src/server/mcpToolPresentation.ts"
77 },77 },
78 "./server/nativeProtocolLogging": {
79 "types": "./src/server/nativeProtocolLogging.ts",
80 "import": "./src/server/nativeProtocolLogging.ts"
81 },
78 "./server/notification": {82 "./server/notification": {
79 "types": "./src/server/notification.ts",83 "types": "./src/server/notification.ts",
80 "import": "./src/server/notification.ts"84 "import": "./src/server/notification.ts"
...@@ -127,6 +131,10 @@...@@ -127,6 +131,10 @@
127 "types": "./src/server/snapshotSettings.ts",131 "types": "./src/server/snapshotSettings.ts",
128 "import": "./src/server/snapshotSettings.ts"132 "import": "./src/server/snapshotSettings.ts"
129 },133 },
134 "./server/subagentProjection": {
135 "types": "./src/server/subagentProjection.ts",
136 "import": "./src/server/subagentProjection.ts"
137 },
130 "./server/textGeneration": {138 "./server/textGeneration": {
131 "types": "./src/server/textGeneration.ts",139 "types": "./src/server/textGeneration.ts",
132 "import": "./src/server/textGeneration.ts"140 "import": "./src/server/textGeneration.ts"
packages/provider-core/src/server/nativeProtocolLogging.ts created+40
...@@ -0,0 +1,40 @@
1import { errorTag } from "@t3tools/shared/observability";
2
3export function structuralProtocolMethod(value: string): string {
4 return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown";
5}
6
7/**
8 * Retain only bounded structural metadata for native protocol diagnostics.
9 * Provider payload values may contain prompts, credentials, or arbitrarily
10 * large tool output and must never be copied into the observability stream.
11 */
12export function summarizeNativeProtocolPayload(
13 payload: unknown,
14): Readonly<Record<string, unknown>> {
15 if (payload === null) return { valueType: "null" };
16 if (typeof payload === "string") {
17 return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength };
18 }
19 if (payload instanceof Uint8Array) {
20 return { valueType: "bytes", byteLength: payload.byteLength };
21 }
22 if (Array.isArray(payload)) {
23 return { valueType: "array", itemCount: payload.length };
24 }
25 if (typeof payload !== "object") {
26 return { valueType: typeof payload };
27 }
28
29 try {
30 const record = payload as Record<string, unknown>;
31 return {
32 valueType: "object",
33 fieldCount: Object.keys(record).length,
34 ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}),
35 ...(typeof record.tag === "string" ? { method: structuralProtocolMethod(record.tag) } : {}),
36 };
37 } catch {
38 return { valueType: "object" };
39 }
40}
packages/provider-core/src/server/subagentProjection.ts created+259
...@@ -0,0 +1,259 @@
1import type {
2 MessageId,
3 ModelSelection,
4 NodeId,
5 OrchestrationV2AppThread,
6 OrchestrationV2Actor,
7 OrchestrationV2ConversationMessage,
8 OrchestrationV2CreationSource,
9 OrchestrationV2ProviderRef,
10 OrchestrationV2Run,
11 OrchestrationV2ThreadProjection,
12 OrchestrationV2TurnItem,
13 ProviderInstanceId,
14 ProviderThreadId,
15 ProviderTurnId,
16 ThreadId,
17 TurnItemId,
18} from "@t3tools/contracts";
19import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
20import * as DateTime from "effect/DateTime";
21import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
22
23function trimmed(value: string | null | undefined): string | undefined {
24 const result = value?.trim();
25 return result && result.length > 0 ? result : undefined;
26}
27
28export function subagentThreadTitle(input: {
29 readonly parentTitle: string;
30 readonly title?: string | null;
31 readonly prompt: string;
32 readonly ordinal: number;
33}): string {
34 const detail = trimmed(input.title) ?? trimmed(input.prompt);
35 if (detail === undefined) {
36 return `${input.parentTitle} subagent ${input.ordinal}`;
37 }
38 const clipped = detail.length > 72 ? `${detail.slice(0, 69)}...` : detail;
39 return clipped;
40}
41
42export function makeSubagentChildThread(input: {
43 readonly parentThread: OrchestrationV2AppThread;
44 readonly childThreadId: ThreadId;
45 readonly parentNodeId: NodeId;
46 readonly activeProviderThreadId: ProviderThreadId | null;
47 readonly providerInstanceId: ProviderInstanceId;
48 readonly modelSelection: ModelSelection;
49 readonly title: string;
50 readonly now: DateTime.Utc;
51 readonly createdBy: OrchestrationV2Actor;
52 readonly creationSource: OrchestrationV2CreationSource;
53}): OrchestrationV2AppThread {
54 return {
55 ...input.parentThread,
56 createdBy: input.createdBy,
57 creationSource: input.creationSource,
58 id: input.childThreadId,
59 title: input.title,
60 linkedPullRequest: null,
61 pullRequests: [],
62 historyOrigin: undefined,
63 providerInstanceId: input.providerInstanceId,
64 modelSelection: input.modelSelection,
65 activeProviderThreadId: input.activeProviderThreadId,
66 lineage: {
67 parentThreadId: input.parentThread.id,
68 relationshipToParent: "subagent",
69 rootThreadId: input.parentThread.lineage.rootThreadId,
70 },
71 forkedFrom: {
72 type: "node",
73 nodeId: input.parentNodeId,
74 },
75 createdAt: input.now,
76 updatedAt: input.now,
77 archivedAt: null,
78 settledOverride: null,
79 settledAt: null,
80 snoozedUntil: null,
81 snoozedAt: null,
82 lastVisitedAt: null,
83 deletedAt: null,
84 };
85}
86
87export function makeSubagentConversationArtifacts(input: {
88 readonly messageId: MessageId;
89 readonly senderThreadId?: ThreadId;
90 readonly turnItemId: TurnItemId;
91 readonly threadId: ThreadId;
92 readonly rootNodeId: NodeId;
93 readonly providerThreadId: ProviderThreadId | null;
94 readonly providerTurnId: ProviderTurnId | null;
95 readonly nativeItemRef: OrchestrationV2ProviderRef | null;
96 readonly role: "user" | "assistant";
97 readonly text: string;
98 readonly ordinal: number;
99 readonly now: DateTime.Utc;
100}): {
101 readonly message: OrchestrationV2ConversationMessage;
102 readonly turnItem: OrchestrationV2TurnItem;
103} {
104 const message: OrchestrationV2ConversationMessage = {
105 createdBy: "agent",
106 creationSource: "provider",
107 id: input.messageId,
108 threadId: input.threadId,
109 runId: null,
110 nodeId: input.rootNodeId,
111 role: input.role,
112 ...(input.role === "user" && input.senderThreadId !== undefined
113 ? { senderThreadId: input.senderThreadId }
114 : {}),
115 text: input.text,
116 attachments: [],
117 streaming: false,
118 createdAt: input.now,
119 updatedAt: input.now,
120 };
121 const base = {
122 id: input.turnItemId,
123 threadId: input.threadId,
124 runId: null,
125 nodeId: input.rootNodeId,
126 providerThreadId: input.providerThreadId,
127 providerTurnId: input.providerTurnId,
128 nativeItemRef: input.nativeItemRef,
129 parentItemId: null,
130 ordinal: input.ordinal,
131 status: "completed" as const,
132 title: null,
133 startedAt: input.now,
134 completedAt: input.now,
135 updatedAt: input.now,
136 messageId: input.messageId,
137 text: input.text,
138 };
139 const turnItem: OrchestrationV2TurnItem =
140 input.role === "user"
141 ? {
142 ...base,
143 createdBy: "agent",
144 creationSource: "provider",
145 type: "user_message",
146 ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }),
147 inputIntent: "turn_start",
148 attachments: [],
149 }
150 : {
151 ...base,
152 type: "assistant_message",
153 streaming: false,
154 };
155 return { message, turnItem };
156}
157
158export function subagentResultForRun(
159 projection: Pick<OrchestrationV2ThreadProjection, "messages" | "turnItems">,
160 run: OrchestrationV2Run,
161): {
162 readonly text: string;
163 readonly messageId: OrchestrationV2ConversationMessage["id"] | null;
164 readonly turnItemId: OrchestrationV2TurnItem["id"] | null;
165} {
166 const message =
167 projection.messages
168 .filter(
169 (candidate) =>
170 candidate.runId === run.id &&
171 candidate.role === "assistant" &&
172 candidate.text.trim().length > 0,
173 )
174 .toSorted(
175 (left, right) =>
176 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
177 )[0] ?? null;
178 const turnItem =
179 projection.turnItems
180 .filter(
181 (
182 candidate,
183 ): candidate is Extract<OrchestrationV2TurnItem, { readonly type: "assistant_message" }> =>
184 candidate.runId === run.id &&
185 candidate.type === "assistant_message" &&
186 candidate.text.trim().length > 0,
187 )
188 .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null;
189 const failure =
190 run.status === "failed"
191 ? projection.turnItems
192 .filter((item) => item.runId === run.id && item.type === "error")
193 .toSorted((left, right) => right.ordinal - left.ordinal)[0]
194 : undefined;
195 const text =
196 (failure?.type === "error" ? failure.failure.message : undefined) ??
197 message?.text ??
198 turnItem?.text ??
199 (run.status === "completed"
200 ? "Child task completed without an assistant result."
201 : `Child task ended with status ${run.status}.`);
202 return {
203 text,
204 messageId: failure === undefined ? (message?.id ?? turnItem?.messageId ?? null) : null,
205 turnItemId: failure?.id ?? turnItem?.id ?? null,
206 };
207}
208
209/** A finished turn can still own live children or queued completion follow-ups. */
210export function delegatedTaskProgress(projection: {
211 readonly runs: OrchestrationV2ThreadProjection["runs"];
212 readonly messages: ReadonlyArray<
213 Pick<OrchestrationV2ConversationMessage, "runId" | "notification">
214 >;
215 readonly subagents: ReadonlyArray<
216 Pick<OrchestrationV2ThreadProjection["subagents"][number], "status" | "completionDelivery">
217 >;
218 readonly providerThreads: ReadonlyArray<
219 Pick<OrchestrationV2ThreadProjection["providerThreads"][number], "pendingBackgroundTasks">
220 >;
221}) {
222 const terminal = (status: string) =>
223 ["completed", "failed", "cancelled", "interrupted", "rolled_back"].includes(status);
224 const monitorRuns = new Set(
225 projection.messages
226 .filter((message) => message.notification?.source.kind === "monitor")
227 .map((message) => message.runId),
228 );
229 const workRuns = projection.runs.filter(
230 (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back",
231 );
232 // A held queue waits for the user to resume it (after Stop, a restart, or a
233 // provider failure), so its runs are not work the task still owes.
234 const active = workRuns.some(
235 (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true),
236 );
237 const children =
238 projection.subagents.some(
239 (task) =>
240 isOrchestrationV2WorkActive(task.status) ||
241 // Publishing a child's result precedes scheduling its parent's wake.
242 // The parent still owes that follow-up even between those transactions.
243 task.completionDelivery?.state === "pending" ||
244 task.completionDelivery?.state === "claimed",
245 ) ||
246 projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0);
247 const resultRun = workRuns
248 .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1))
249 .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0];
250 return {
251 state:
252 active || resultRun === undefined
253 ? ("working" as const)
254 : children
255 ? ("waiting_for_children" as const)
256 : ("result_available" as const),
257 resultRun,
258 };
259}
packages/provider-opencode/package.json created+68
...@@ -0,0 +1,68 @@
1{
2 "name": "@t3tools/provider-opencode",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./client": {
7 "types": "./src/client.ts",
8 "import": "./src/client.ts"
9 },
10 "./settings": {
11 "types": "./src/settings.ts",
12 "import": "./src/settings.ts"
13 },
14 "./server": {
15 "types": "./src/server.ts",
16 "import": "./src/server.ts"
17 },
18 "./testing": {
19 "types": "./src/testing.ts",
20 "import": "./src/testing.ts"
21 },
22 "./server/OpenCodeRuntime": {
23 "types": "./src/server/OpenCodeRuntime.ts",
24 "import": "./src/server/OpenCodeRuntime.ts"
25 },
26 "./server/OpenCodeServerLedger": {
27 "types": "./src/server/OpenCodeServerLedger.ts",
28 "import": "./src/server/OpenCodeServerLedger.ts"
29 },
30 "./server/v2/adapter": {
31 "types": "./src/server/v2/adapter.ts",
32 "import": "./src/server/v2/adapter.ts"
33 },
34 "./server/v2/OpenCode2Client": {
35 "types": "./src/server/v2/OpenCode2Client.ts",
36 "import": "./src/server/v2/OpenCode2Client.ts"
37 },
38 "./server/v2/OpenCode2Server": {
39 "types": "./src/server/v2/OpenCode2Server.ts",
40 "import": "./src/server/v2/OpenCode2Server.ts"
41 },
42 "./server/v2/textGeneration": {
43 "types": "./src/server/v2/textGeneration.ts",
44 "import": "./src/server/v2/textGeneration.ts"
45 }
46 },
47 "scripts": {
48 "typecheck": "tsc --noEmit",
49 "test": "vp test run --config ../../vite.config.ts --dir ."
50 },
51 "dependencies": {
52 "@opencode-ai/sdk": "^1.3.15",
53 "@opencode/client": "2.0.23",
54 "@opencode/protocol": "2.0.23",
55 "@opencode/schema": "2.0.23",
56 "@t3tools/contracts": "workspace:*",
57 "@t3tools/provider-core": "workspace:*",
58 "@t3tools/shared": "workspace:*",
59 "effect": "catalog:"
60 },
61 "devDependencies": {
62 "@effect/platform-node": "catalog:",
63 "@effect/vitest": "catalog:",
64 "@t3tools/provider-testing": "workspace:*",
65 "@types/node": "catalog:",
66 "vite-plus": "catalog:"
67 }
68}
packages/provider-opencode/src/client.ts created+24
...@@ -0,0 +1,24 @@
1/**
2 * OpenCode's client definition: settings form, label, and glyph.
3 * Browser- and React Native-safe.
4 *
5 * @module provider-opencode/client
6 */
7import { ProviderDriverKind } from "@t3tools/contracts";
8import { defineProviderClient } from "@t3tools/provider-core/client";
9
10import { OpenCodeSettings } from "./settings.ts";
11
12export const openCodeClient = defineProviderClient({
13 driverKind: ProviderDriverKind.make("opencode"),
14 label: "OpenCode",
15 settingsSchema: OpenCodeSettings,
16 icon: {
17 viewBox: "0 0 32 40",
18 fill: { light: "#211E1E", dark: "#F1ECEC" },
19 paths: [
20 { d: "M24 32H8V16H24V32Z", fill: { light: "#CFCECD", dark: "#4B4646" } },
21 { d: "M24 8H8V32H24V8ZM32 40H0V0H32V40Z" },
22 ],
23 },
24});
packages/provider-opencode/src/server.ts created+8
...@@ -0,0 +1,8 @@
1/**
2 * OpenCode's server entry: the driver the server registers, its adapter
3 * driver, and the runtime layer the driver needs from the server.
4 *
5 * @module provider-opencode/server
6 */
7export { OpenCodeDriver, type OpenCodeDriverEnv } from "./server/driver.ts";
8export { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv } from "./server/adapter.ts";
packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts created+330
...@@ -0,0 +1,330 @@
1import * as NodeAssert from "node:assert/strict";
2
3import { describe, it } from "vite-plus/test";
4
5import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
6
7describe("parseModelsCliOutput", () => {
8 it("parses a single model from a single provider", () => {
9 const stdout = [
10 "anthropic/claude-sonnet-4-5",
11 JSON.stringify({
12 id: "claude-sonnet-4-5",
13 providerID: "anthropic",
14 name: "Claude Sonnet 4.5",
15 capabilities: { temperature: true, reasoning: true, toolcall: true },
16 cost: { input: 3, output: 15 },
17 limit: { context: 200000, output: 8192 },
18 status: "active",
19 options: {},
20 headers: {},
21 release_date: "2025-01-01",
22 }),
23 ].join("\n");
24
25 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
26 NodeAssert.equal(result.providers.size, 1);
27 NodeAssert.equal(result.connected.length, 1);
28 NodeAssert.equal(result.connected[0], "anthropic");
29
30 const provider = result.providers.get("anthropic")!;
31 NodeAssert.ok(provider);
32 NodeAssert.equal(provider.id, "anthropic");
33 NodeAssert.equal(provider.name, "anthropic");
34 NodeAssert.equal(Object.keys(provider.models).length, 1);
35
36 const model = provider.models["claude-sonnet-4-5"]!;
37 NodeAssert.ok(model);
38 NodeAssert.equal(model.id, "claude-sonnet-4-5");
39 NodeAssert.equal(model.providerID, "anthropic");
40 NodeAssert.equal(model.name, "Claude Sonnet 4.5");
41 });
42
43 it("parses multiple models from multiple providers", () => {
44 const stdout = [
45 "anthropic/claude-sonnet-4-5",
46 JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }),
47 "anthropic/claude-haiku-4-5",
48 JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }),
49 "openai/gpt-4o",
50 JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }),
51 ].join("\n");
52
53 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
54 NodeAssert.equal(result.providers.size, 2);
55 NodeAssert.equal(result.connected.length, 2);
56 NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai");
57 NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2);
58 NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1);
59 });
60
61 it("handles empty input", () => {
62 const result = OpenCodeRuntime.parseModelsCliOutput("");
63 NodeAssert.equal(result.providers.size, 0);
64 NodeAssert.equal(result.connected.length, 0);
65 });
66
67 it("skips unparseable JSON blocks", () => {
68 const stdout = [
69 "anthropic/claude-sonnet-4-5",
70 "this is not valid json {{{",
71 "anthropic/claude-haiku-4-5",
72 JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }),
73 ].join("\n");
74
75 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
76 NodeAssert.equal(result.providers.size, 1);
77 const provider = result.providers.get("anthropic")!;
78 NodeAssert.equal(Object.keys(provider.models).length, 1);
79 NodeAssert.ok(provider.models["claude-haiku-4-5"]);
80 });
81
82 it("handles Windows-style CRLF line endings", () => {
83 const stdout =
84 "anthropic/claude-sonnet-4-5\r\n" +
85 JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) +
86 "\r\n";
87
88 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
89 NodeAssert.equal(result.providers.size, 1);
90 NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]);
91 });
92
93 it("handles model JSON with variants and nested fields", () => {
94 const stdout = [
95 "opencode/gpt-5.4",
96 JSON.stringify({
97 id: "gpt-5.4",
98 providerID: "opencode",
99 name: "GPT-5.4",
100 family: "gpt",
101 capabilities: {
102 temperature: true,
103 reasoning: true,
104 attachment: false,
105 toolcall: true,
106 input: { text: true, audio: false, image: false, video: false, pdf: false },
107 output: { text: true, audio: false, image: false, video: false, pdf: false },
108 interleaved: false,
109 },
110 cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
111 limit: { context: 200000, input: 160000, output: 32000 },
112 status: "active",
113 options: {},
114 headers: {},
115 release_date: "2025-01-01",
116 variants: { none: {}, low: {}, medium: {}, high: {} },
117 }),
118 ].join("\n");
119
120 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
121 const model = result.providers.get("opencode")!.models["gpt-5.4"]!;
122 NodeAssert.ok(model);
123 NodeAssert.ok(model.capabilities);
124 NodeAssert.equal(model.capabilities!.reasoning, true);
125 NodeAssert.ok(model.variants);
126 NodeAssert.equal(model.variants!["medium"] !== undefined, true);
127 });
128
129 it("keeps a model whose JSON body has a slash and no interior whitespace", () => {
130 // OpenRouter-style: the model id contains a `/` and no string value has a
131 // space, so the JSON body line itself matches the slug regex. It must still
132 // be treated as the body of the preceding slug, not a new slug.
133 const stdout = [
134 "openrouter/qwen/qwen3-coder",
135 JSON.stringify({
136 id: "qwen/qwen3-coder",
137 providerID: "openrouter",
138 name: "qwen3-coder",
139 status: "active",
140 }),
141 ].join("\n");
142
143 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
144 NodeAssert.equal(result.providers.size, 1);
145 NodeAssert.deepEqual([...result.connected], ["openrouter"]);
146 const provider = result.providers.get("openrouter")!;
147 NodeAssert.ok(provider);
148 const model = provider.models["qwen/qwen3-coder"]!;
149 NodeAssert.ok(model);
150 NodeAssert.equal(model.id, "qwen/qwen3-coder");
151 NodeAssert.equal(model.providerID, "openrouter");
152 });
153});
154
155describe("parseAgentListCliOutput", () => {
156 it("parses a single agent", () => {
157 const stdout = [
158 "build (primary)",
159 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
160 ].join("\n");
161
162 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
163 NodeAssert.equal(result.length, 1);
164 NodeAssert.equal(result[0]!.name, "build");
165 NodeAssert.equal(result[0]!.mode, "primary");
166 NodeAssert.equal(result[0]!.permission.length, 1);
167 });
168
169 it("parses multiple agents", () => {
170 const stdout = [
171 "build (primary)",
172 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
173 "explore (subagent)",
174 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
175 "plan (primary)",
176 " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]),
177 ].join("\n");
178
179 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
180 NodeAssert.equal(result.length, 3);
181 NodeAssert.equal(result[0]!.name, "build");
182 NodeAssert.equal(result[0]!.mode, "primary");
183 NodeAssert.equal(result[1]!.name, "explore");
184 NodeAssert.equal(result[1]!.mode, "subagent");
185 NodeAssert.equal(result[2]!.name, "plan");
186 NodeAssert.equal(result[2]!.mode, "primary");
187 });
188
189 it("handles empty input", () => {
190 const result = OpenCodeRuntime.parseAgentListCliOutput("");
191 NodeAssert.equal(result.length, 0);
192 });
193
194 it("skips agents with unparseable permission JSON", () => {
195 const stdout = [
196 "build (primary)",
197 " not valid json {",
198 "explore (subagent)",
199 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
200 ].join("\n");
201
202 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
203 NodeAssert.equal(result.length, 1);
204 NodeAssert.equal(result[0]!.name, "explore");
205 });
206
207 it("handles real-world permission blocks with nested paths", () => {
208 const permissions = [
209 { permission: "*", action: "allow", pattern: "*" },
210 {
211 permission: "external_directory",
212 pattern: "C:\\Users\\test\\.local\\*",
213 action: "allow",
214 },
215 { permission: "read", pattern: "*.env", action: "ask" },
216 ];
217 const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n");
218
219 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
220 NodeAssert.equal(result.length, 1);
221 NodeAssert.equal(result[0]!.permission.length, 3);
222 NodeAssert.equal(result[0]!.permission[0]!.action, "allow");
223 NodeAssert.equal(result[0]!.permission[2]!.action, "ask");
224 });
225
226 it("handles agent names with spaces", () => {
227 const stdout = [
228 "code reviewer (subagent)",
229 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
230 "my custom agent (primary)",
231 " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]),
232 ].join("\n");
233
234 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
235 NodeAssert.equal(result.length, 2);
236 NodeAssert.equal(result[0]!.name, "code reviewer");
237 NodeAssert.equal(result[0]!.mode, "subagent");
238 NodeAssert.equal(result[1]!.name, "my custom agent");
239 NodeAssert.equal(result[1]!.mode, "primary");
240 });
241
242 it("marks known hidden agents", () => {
243 const stdout = [
244 "compaction (primary)",
245 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
246 "build (primary)",
247 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
248 ].join("\n");
249
250 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
251 NodeAssert.equal(result[0]!.hidden, true);
252 NodeAssert.equal(result[1]!.hidden, false);
253 });
254});
255
256describe("parseSkillsCliOutput", () => {
257 it("parses only skill metadata from the CLI JSON output", () => {
258 const result = OpenCodeRuntime.parseSkillsCliOutput(
259 JSON.stringify([
260 {
261 name: "review-pr",
262 description: "Review a pull request.",
263 location: "/tmp/review-pr/SKILL.md",
264 content: "---\nname: review-pr\n---\n",
265 },
266 ]),
267 );
268
269 NodeAssert.deepEqual(result, [
270 {
271 name: "review-pr",
272 description: "Review a pull request.",
273 location: "/tmp/review-pr/SKILL.md",
274 },
275 ]);
276 });
277
278 it("degrades malformed output to an empty skill list", () => {
279 NodeAssert.deepEqual(OpenCodeRuntime.parseSkillsCliOutput("not json"), []);
280 });
281});
282
283describe("toOpenCodeFileParts", () => {
284 const attachment = (mimeType: string, sizeBytes = 12) => ({
285 type: "file" as const,
286 id: "thread-1-00000000-0000-4000-8000-000000000001-bin",
287 name: "attachment",
288 mimeType,
289 sizeBytes,
290 });
291
292 it("sends supported images, text, and PDFs natively and skips what models reject", () => {
293 const parts = OpenCodeRuntime.toOpenCodeFileParts({
294 attachments: [
295 attachment("application/pdf"),
296 attachment("text/markdown"),
297 attachment("image/png"),
298 // A ZIP file part makes OpenCode's Anthropic path throw before the
299 // turn starts; it must ride only as the prompt's file path line.
300 attachment("application/zip"),
301 attachment("application/octet-stream"),
302 // Image formats the model APIs reject stay on the fallback path too.
303 attachment("image/bmp"),
304 attachment("image/svg+xml"),
305 // Over the direct-attachment limit: path fallback even for a PDF.
306 attachment("application/pdf", 21 * 1024 * 1024),
307 ],
308 resolveAttachmentPath: () => "/tmp/attachment",
309 });
310
311 NodeAssert.deepEqual(
312 parts.map((part) => part.mime),
313 ["application/pdf", "text/markdown", "image/png"],
314 );
315 });
316
317 it("keeps folded clipboard text on the lazy path fallback", () => {
318 const parts = OpenCodeRuntime.toOpenCodeFileParts({
319 attachments: [
320 {
321 ...attachment("text/plain"),
322 source: { _tag: "pasted-text" as const },
323 },
324 ],
325 resolveAttachmentPath: () => "/tmp/pasted-text.txt",
326 });
327
328 NodeAssert.deepEqual(parts, []);
329 });
330});
packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts created+239
...@@ -0,0 +1,239 @@
1import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it as effectIt } from "@effect/vitest";
4import {
5 HostProcessEnvironment,
6 HostProcessExecutablePath,
7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as TestClock from "effect/testing/TestClock";
15import { FetchHttpClient, HttpClient } from "effect/http";
16import { describe, expect, it } from "vite-plus/test";
17
18import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
19import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
20
21describe("resolveOpenCodeConfigContent", () => {
22 it("prefers the caller environment over the inherited environment", () => {
23 expect(
24 OpenCodeRuntime.resolveOpenCodeConfigContent(
25 { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' },
26 { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' },
27 ),
28 ).toBe('{"source":"caller"}');
29 });
30
31 it("falls back to the inherited environment and then an empty config", () => {
32 expect(
33 OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {
34 OPENCODE_CONFIG_CONTENT: '{"source":"process"}',
35 }),
36 ).toBe('{"source":"process"}');
37 expect(OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {})).toBe("{}");
38 });
39});
40
41describe("resolveOpenCodeServerPassword", () => {
42 it("uses the local environment password when settings do not provide one", () => {
43 expect(
44 OpenCodeRuntime.resolveOpenCodeServerPassword(
45 { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } },
46 {},
47 ),
48 ).toBe(" env password ");
49 });
50
51 it("uses the settings password for a local server", () => {
52 expect(
53 OpenCodeRuntime.resolveOpenCodeServerPassword(
54 { external: false, serverPassword: " settings password " },
55 {},
56 ),
57 ).toBe(" settings password ");
58 });
59
60 it("uses the settings password when local settings and environment differ", () => {
61 expect(
62 OpenCodeRuntime.resolveOpenCodeServerPassword(
63 {
64 external: false,
65 serverPassword: "settings-password",
66 environment: { OPENCODE_SERVER_PASSWORD: "environment-password" },
67 },
68 {},
69 ),
70 ).toBe("settings-password");
71 });
72
73 it("does not send an inherited local password to an external server", () => {
74 expect(
75 OpenCodeRuntime.resolveOpenCodeServerPassword(
76 { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } },
77 { OPENCODE_SERVER_PASSWORD: "inherited-secret" },
78 ),
79 ).toBeUndefined();
80 });
81});
82
83function makeHealthClient(
84 result: (options?: { readonly signal?: AbortSignal }) => Promise<unknown>,
85): OpencodeClient {
86 return {
87 global: {
88 health: result,
89 },
90 } as unknown as OpencodeClient;
91}
92
93describe("verifyOpenCodeServerVersion", () => {
94 effectIt.effect("accepts a supported server version", () =>
95 Effect.gen(function* () {
96 const version = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
97 makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })),
98 );
99 expect(version).toBe("1.14.19");
100 }),
101 );
102
103 effectIt.effect("rejects a server below the supported version", () =>
104 Effect.gen(function* () {
105 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
106 makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })),
107 ).pipe(Effect.flip);
108 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
109 expect(error.detail).toContain("v1.14.18 is too old");
110 }),
111 );
112
113 for (const data of [
114 { healthy: true },
115 { healthy: true, version: "not-a-version" },
116 { healthy: false, version: "1.14.19" },
117 ]) {
118 effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () =>
119 Effect.gen(function* () {
120 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
121 makeHealthClient(() => Promise.resolve({ data })),
122 ).pipe(Effect.flip);
123 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
124 expect(error.detail).toContain("requires OpenCode v1.14.19 or newer");
125 }),
126 );
127 }
128
129 effectIt.effect("preserves an unauthorized health error", () =>
130 Effect.gen(function* () {
131 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
132 makeHealthClient(() =>
133 Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }),
134 ),
135 ).pipe(Effect.flip);
136 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
137 expect(error.detail).toContain("status=401");
138 expect(error.detail).toContain("Unauthorized");
139 }),
140 );
141
142 effectIt.effect("aborts a health request when the version check times out", () =>
143 Effect.gen(function* () {
144 let requestSignal: AbortSignal | undefined;
145 const checkFiber = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
146 makeHealthClient((options) => {
147 requestSignal = options?.signal;
148 return new Promise(() => undefined);
149 }),
150 ).pipe(Effect.flip, Effect.forkChild);
151
152 yield* Effect.yieldNow;
153 expect(requestSignal).toBeDefined();
154 yield* TestClock.adjust("6 seconds");
155
156 const error = yield* Fiber.join(checkFiber);
157 expect(error.detail).toBe("Timed out while checking the OpenCode server version.");
158 expect(requestSignal?.aborted).toBe(true);
159 }).pipe(Effect.provide(TestClock.layer())),
160 );
161});
162
163describe("OpenCode server output", () => {
164 effectIt.live(
165 "drains stdout and stderr after startup so server requests can finish",
166 () =>
167 Effect.gen(function* () {
168 const fs = yield* FileSystem.FileSystem;
169 const path = yield* Path.Path;
170 const environment = yield* HostProcessEnvironment;
171 const executablePath = yield* HostProcessExecutablePath;
172 const platform = yield* HostProcessPlatform;
173 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" });
174 const isWindows = platform === "win32";
175 const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode");
176 const scriptPath = path.join(tempDir, "opencode.mjs");
177
178 yield* fs.writeFileString(
179 scriptPath,
180 `import { createServer } from "node:http";
181const writeOutput = (stream) => new Promise((resolve, reject) => {
182 stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve());
183});
184const server = createServer(async (request, response) => {
185 if (request.url.startsWith("/global/health")) {
186 response.setHeader("Content-Type", "application/json");
187 response.end(JSON.stringify({ healthy: true, version: "1.14.19" }));
188 return;
189 }
190 await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]);
191 response.end("drained");
192});
193server.listen(0, "127.0.0.1", () => {
194 process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n");
195});
196`,
197 );
198 yield* fs.writeFileString(
199 binaryPath,
200 [
201 ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]),
202 isWindows
203 ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*'
204 : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"',
205 "",
206 ].join("\n"),
207 );
208 if (!isWindows) {
209 yield* fs.chmod(binaryPath, 0o755);
210 }
211
212 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
213 const server = yield* runtime.startOpenCodeServerProcess({
214 binaryPath,
215 directory: tempDir,
216 port: 0,
217 environment: {
218 ...environment,
219 T3_TEST_NODE_BINARY: executablePath,
220 T3_TEST_OPENCODE_SCRIPT: scriptPath,
221 },
222 });
223 const response = yield* HttpClient.get(`${server.url}/output`);
224
225 expect(yield* response.text).toBe("drained");
226 expect(yield* server.isRunning).toBe(true);
227 }).pipe(
228 Effect.scoped,
229 Effect.provide([
230 OpenCodeRuntime.layer.pipe(
231 Layer.provide(OpenCodeServerLedger.layerTest),
232 Layer.provideMerge(NodeServices.layer),
233 ),
234 FetchHttpClient.layer,
235 ]),
236 ),
237 10_000,
238 );
239});
packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts created+290
...@@ -0,0 +1,290 @@
1import * as NodeAssert from "node:assert/strict";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
5import { it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Path from "effect/Path";
11import * as Queue from "effect/Queue";
12import {
13 HostProcessEnvironment,
14 HostProcessExecutablePath,
15 HostProcessPlatform,
16} from "@t3tools/shared/hostProcess";
17
18import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
19import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
20
21const layerTest = OpenCodeRuntime.layer.pipe(
22 Layer.provide(OpenCodeServerLedger.layerTest),
23 Layer.provideMerge(NodeServices.layer),
24);
25
26it.layer(layerTest)("OpenCodeRuntime inventory", (it) => {
27 it.effect("aborts pending SDK requests when inventory loading is interrupted", () =>
28 Effect.gen(function* () {
29 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
30 const started = yield* Queue.make<void>();
31 const aborted = yield* Queue.make<string>();
32 const client = createOpencodeClient({
33 baseUrl: "http://opencode.test",
34 fetch: Object.assign(
35 (input: string | Request | URL) => {
36 const request = input instanceof Request ? input : new Request(input.toString());
37 return new Promise<Response>((_resolve, reject) => {
38 request.signal.addEventListener(
39 "abort",
40 () => {
41 Queue.offerUnsafe(aborted, new URL(request.url).pathname);
42 reject(request.signal.reason);
43 },
44 { once: true },
45 );
46 Queue.offerUnsafe(started, undefined);
47 });
48 },
49 { preconnect: () => undefined },
50 ),
51 });
52
53 const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild);
54 yield* Queue.takeN(started, 4);
55 yield* Fiber.interrupt(inventoryFiber);
56
57 NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [
58 "/agent",
59 "/command",
60 "/provider",
61 "/skill",
62 ]);
63 }),
64 );
65
66 it.effect("discovers directory-scoped commands without retaining prompt templates", () =>
67 Effect.gen(function* () {
68 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
69 const requests: Request[] = [];
70 const client = createOpencodeClient({
71 baseUrl: "http://opencode.test",
72 directory: "/workspace/project",
73 fetch: Object.assign(
74 async (input: string | Request | URL) => {
75 const request = input instanceof Request ? input : new Request(input.toString());
76 requests.push(request);
77 const route = new URL(request.url).pathname;
78 return Response.json(
79 route === "/provider"
80 ? { connected: ["openai"], all: [], default: {} }
81 : route === "/command"
82 ? [
83 {
84 name: "review",
85 description: "Review changes",
86 source: "command",
87 hints: ["$ARGUMENTS"],
88 template: "private native template",
89 },
90 ]
91 : [],
92 );
93 },
94 { preconnect: () => undefined },
95 ),
96 });
97 const inventory = yield* runtime.loadOpenCodeInventory(client);
98 NodeAssert.deepEqual(inventory.commands, [
99 {
100 name: "review",
101 description: "Review changes",
102 source: "command",
103 hints: ["$ARGUMENTS"],
104 },
105 ]);
106 const commandRequest = requests.find(
107 (request) => new URL(request.url).pathname === "/command",
108 );
109 NodeAssert.ok(commandRequest);
110 NodeAssert.equal(
111 new URL(commandRequest.url).searchParams.get("directory") ??
112 decodeURIComponent(commandRequest.headers.get("x-opencode-directory") ?? ""),
113 "/workspace/project",
114 );
115 }),
116 );
117
118 it.effect("keeps provider inventory when agent discovery fails", () =>
119 Effect.gen(function* () {
120 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
121 const client = {
122 provider: {
123 list: () =>
124 Promise.resolve({
125 data: {
126 connected: ["openai"],
127 all: [],
128 default: {},
129 },
130 }),
131 },
132 app: {
133 agents: () => Promise.reject(new Error("agents endpoint unavailable")),
134 skills: () => Promise.resolve({ data: [] }),
135 },
136 } as unknown as OpencodeClient;
137
138 const inventory = yield* runtime.loadOpenCodeInventory(client);
139
140 NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
141 NodeAssert.deepEqual(inventory.agents, []);
142 NodeAssert.deepEqual(inventory.skills, []);
143 }),
144 );
145
146 it.effect("keeps provider inventory when skill discovery fails", () =>
147 Effect.gen(function* () {
148 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
149 const client = {
150 provider: {
151 list: () =>
152 Promise.resolve({
153 data: {
154 connected: ["openai"],
155 all: [],
156 default: {},
157 },
158 }),
159 },
160 app: {
161 agents: () => Promise.resolve({ data: [] }),
162 skills: () => Promise.reject(new Error("skills endpoint unavailable")),
163 },
164 } as unknown as OpencodeClient;
165
166 const inventory = yield* runtime.loadOpenCodeInventory(client);
167
168 NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
169 NodeAssert.deepEqual(inventory.agents, []);
170 NodeAssert.deepEqual(inventory.skills, []);
171 }),
172 );
173
174 it.effect("keeps only SDK skill metadata in inventory", () =>
175 Effect.gen(function* () {
176 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
177 const client = {
178 provider: {
179 list: () =>
180 Promise.resolve({
181 data: {
182 connected: ["openai"],
183 all: [],
184 default: {},
185 },
186 }),
187 },
188 app: {
189 agents: () => Promise.resolve({ data: [] }),
190 skills: () =>
191 Promise.resolve({
192 data: [
193 {
194 name: "review",
195 description: "Review code changes",
196 location: "/skills/review/SKILL.md",
197 content: "unused skill content",
198 },
199 ],
200 }),
201 },
202 } as unknown as OpencodeClient;
203
204 const inventory = yield* runtime.loadOpenCodeInventory(client);
205
206 NodeAssert.deepEqual(inventory.skills, [
207 {
208 name: "review",
209 description: "Review code changes",
210 location: "/skills/review/SKILL.md",
211 },
212 ]);
213 }),
214 );
215
216 it.effect("drops oversized CLI skill output without losing the model inventory", () =>
217 Effect.gen(function* () {
218 const fs = yield* FileSystem.FileSystem;
219 const path = yield* Path.Path;
220 const hostEnvironment = yield* HostProcessEnvironment;
221 const executablePath = yield* HostProcessExecutablePath;
222 const hostPlatform = yield* HostProcessPlatform;
223 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" });
224 const isWindows = hostPlatform === "win32";
225 const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode");
226 const scriptPath = path.join(tempDir, "opencode.mjs");
227 const oversizedContentBytes = 8 * 1024 * 1024 + 1;
228
229 yield* fs.writeFileString(
230 scriptPath,
231 [
232 'if (process.argv[2] === "models") {',
233 ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);',
234 '} else if (process.argv[2] === "debug") {',
235 ` const content = "x".repeat(${oversizedContentBytes});`,
236 ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);',
237 "}",
238 "",
239 ].join("\n"),
240 );
241 yield* fs.writeFileString(
242 binaryPath,
243 [
244 ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]),
245 isWindows
246 ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*'
247 : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"',
248 "",
249 ].join("\n"),
250 );
251 if (!isWindows) {
252 yield* fs.chmod(binaryPath, 0o755);
253 }
254
255 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
256 const inventory = yield* runtime.loadInventoryFromCli({
257 binaryPath,
258 cwd: tempDir,
259 environment: {
260 ...hostEnvironment,
261 T3_TEST_NODE_BINARY: executablePath,
262 T3_TEST_OPENCODE_SCRIPT: scriptPath,
263 },
264 });
265
266 NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
267 NodeAssert.equal(inventory.skills.length, 0);
268 }),
269 );
270
271 it.effect("caps and drains command stdout and stderr when requested", () =>
272 Effect.gen(function* () {
273 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
274 const executablePath = yield* HostProcessExecutablePath;
275 const outputBytes = 2 * 1024 * 1024;
276 const result = yield* runtime.runOpenCodeCommand({
277 binaryPath: executablePath,
278 args: [
279 "-e",
280 `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`,
281 ],
282 maxOutputBytes: 64,
283 });
284
285 NodeAssert.equal(result.stdout, "o".repeat(64));
286 NodeAssert.equal(result.stderr, "e".repeat(64));
287 NodeAssert.equal(result.code, 0);
288 }),
289 );
290});
packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts created+89
...@@ -0,0 +1,89 @@
1import * as NodeAssert from "node:assert/strict";
2
3import * as RegExpUtils from "effect/RegExp";
4import { describe, it } from "vite-plus/test";
5
6import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
7
8function actionFor(
9 runtimeMode: Parameters<typeof OpenCodeRuntime.buildOpenCodePermissionRules>[0],
10 permission: string,
11 target = "*",
12) {
13 // OpenCode uses the last matching rule. Its wildcards match directory separators.
14 return OpenCodeRuntime.buildOpenCodePermissionRules(runtimeMode).findLast(
15 (rule) =>
16 (rule.permission === "*" || rule.permission === permission) &&
17 new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target),
18 )?.action;
19}
20
21describe("buildOpenCodePermissionRules", () => {
22 it("pre-approves edits once the user has chosen to auto-accept them", () => {
23 NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow");
24 });
25
26 it("still asks before editing when approval is required", () => {
27 NodeAssert.equal(actionFor("approval-required", "edit"), "ask");
28 });
29
30 // Documented in docs/user/permission-modes.md: providers without an AI
31 // reviewer, OpenCode among them, fall back to Supervised for "auto".
32 it("leaves auto asking, as the docs say it does without a reviewer", () => {
33 NodeAssert.equal(actionFor("auto", "edit"), "ask");
34 });
35
36 it("allows workspace reads and task updates without asking in supervised modes", () => {
37 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
38 for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) {
39 NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow");
40 }
41 }
42 });
43
44 it("preserves OpenCode's environment-file approval rules", () => {
45 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
46 for (const target of [
47 ".env",
48 ".env.local",
49 "config/service.env",
50 "config/service.env.local",
51 ]) {
52 NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask");
53 }
54 for (const target of [".env.example", "config/service.env.example"]) {
55 NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow");
56 }
57 }
58 });
59
60 it("still asks before commands, network access, external directories and unknown tools", () => {
61 for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) {
62 NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask");
63 NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask");
64 NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask");
65 NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask");
66 NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask");
67 NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask");
68 }
69 });
70
71 it("allows everything only under full access", () => {
72 NodeAssert.deepEqual(OpenCodeRuntime.buildOpenCodePermissionRules("full-access"), [
73 { permission: "*", pattern: "*", action: "allow" },
74 { permission: "external_directory", pattern: "*", action: "allow" },
75 ]);
76 });
77});
78
79describe("toOpenCodePermissionReply", () => {
80 it.each([
81 ["accept", "once"],
82 ["acceptForSession", "always"],
83 ["acceptAlways", "always"],
84 ["decline", "reject"],
85 ["cancel", "reject"],
86 ] as const)("maps %s to %s", (decision, reply) => {
87 NodeAssert.equal(OpenCodeRuntime.toOpenCodePermissionReply(decision), reply);
88 });
89});
packages/provider-opencode/src/server/OpenCodeRuntime.ts created+1111
...@@ -0,0 +1,1111 @@
1import * as NodeURL from "node:url";
2
3import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";
4import {
5 createOpencodeClient,
6 type Agent,
7 type Command,
8 type FilePartInput,
9 type Model,
10 type OpencodeClient,
11 type PermissionRuleset,
12 type ProviderListResponse,
13 type QuestionAnswer,
14 type QuestionRequest,
15} from "@opencode-ai/sdk/v2";
16import * as Cause from "effect/Cause";
17import * as Context from "effect/Context";
18import * as Data from "effect/Data";
19import * as Deferred from "effect/Deferred";
20import * as Effect from "effect/Effect";
21import * as Exit from "effect/Exit";
22import * as Fiber from "effect/Fiber";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import * as P from "effect/Predicate";
26import * as Ref from "effect/Ref";
27import * as Result from "effect/Result";
28import * as Scope from "effect/Scope";
29import * as Schema from "effect/Schema";
30import * as Stream from "effect/Stream";
31import { ChildProcess, ChildProcessSpawner } from "effect/process";
32
33import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
34import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe";
35import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
36import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe";
37import * as NetService from "@t3tools/shared/Net";
38import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
39import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
40import { resolveSpawnCommand } from "@t3tools/shared/shell";
41const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
42const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";
43
44export const MINIMUM_OPENCODE_VERSION = "1.14.19";
45const OPENCODE_HEALTH_TIMEOUT = "5 seconds";
46
47const OpenCodeHealthSchema = Schema.Struct({
48 healthy: Schema.Literal(true),
49 version: Schema.String,
50});
51const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema);
52
53export function resolveOpenCodeConfigContent(
54 inputEnvironment: Readonly<Record<string, string | undefined>> | undefined,
55 inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,
56): string {
57 return (
58 inputEnvironment?.OPENCODE_CONFIG_CONTENT ??
59 inheritedEnvironment.OPENCODE_CONFIG_CONTENT ??
60 OPENCODE_EMPTY_CONFIG_CONTENT
61 );
62}
63
64export function resolveOpenCodeServerPassword(
65 input: {
66 readonly external: boolean;
67 readonly serverPassword?: string;
68 readonly environment?: Readonly<Record<string, string | undefined>>;
69 },
70 inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,
71): string | undefined {
72 if (input.serverPassword !== undefined) {
73 return input.serverPassword;
74 }
75 if (input.external) {
76 return undefined;
77 }
78 return input.environment === undefined
79 ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD
80 : input.environment.OPENCODE_SERVER_PASSWORD;
81}
82
83const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
84const DEFAULT_HOSTNAME = "127.0.0.1";
85const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024;
86const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
87export interface OpenCodeServerProcess {
88 readonly url: string;
89 readonly serverPassword?: string;
90 readonly version: string;
91 readonly isRunning: Effect.Effect<boolean>;
92 readonly exitCode: Effect.Effect<number, never>;
93}
94
95export interface OpenCodeServerConnection {
96 readonly url: string;
97 readonly serverPassword?: string;
98 readonly version: string;
99 readonly exitCode: Effect.Effect<number, never> | null;
100 readonly external: boolean;
101}
102
103const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";
104export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{
105 readonly operation: string;
106 readonly cause?: unknown;
107 readonly detail: string;
108}> {
109 static readonly is = (u: unknown): u is OpenCodeRuntimeError =>
110 P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);
111}
112
113function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
114 const result = encodeUnknownJsonStringExit(input);
115 return Exit.isSuccess(result) ? result.value : undefined;
116}
117
118export function openCodeRuntimeErrorDetail(cause: unknown): string {
119 if (OpenCodeRuntimeError.is(cause)) return cause.detail;
120 if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim();
121 if (cause && typeof cause === "object") {
122 // SDK v2 throws { response, request, error? } shapes — extract what's useful
123 const anyCause = cause as Record<string, unknown>;
124 const status = (anyCause.response as { status?: number } | undefined)?.status;
125 const body = anyCause.error ?? anyCause.data ?? anyCause.body;
126 const encodedBody = encodeJsonStringForDiagnostics(body ?? cause);
127 if (encodedBody) {
128 return `status=${status ?? "?"} body=${encodedBody}`;
129 }
130 }
131 return String(cause);
132}
133
134export const runOpenCodeSdk = <A>(
135 operation: string,
136 fn: (signal: AbortSignal) => Promise<A>,
137): Effect.Effect<A, OpenCodeRuntimeError> =>
138 Effect.tryPromise({
139 try: fn,
140 catch: (cause) =>
141 new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }),
142 }).pipe(Effect.withSpan(`opencode.${operation}`));
143
144export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* (
145 client: OpencodeClient,
146) {
147 const healthOption = yield* runOpenCodeSdk("global.health", (signal) =>
148 client.global.health({ signal }),
149 ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT));
150 if (Option.isNone(healthOption)) {
151 return yield* new OpenCodeRuntimeError({
152 operation: "global.health",
153 detail: "Timed out while checking the OpenCode server version.",
154 });
155 }
156
157 const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe(
158 Effect.mapError(
159 (cause) =>
160 new OpenCodeRuntimeError({
161 operation: "global.health",
162 detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,
163 cause,
164 }),
165 ),
166 );
167 if (parseSemver(health.version) === null) {
168 return yield* new OpenCodeRuntimeError({
169 operation: "global.health",
170 detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,
171 });
172 }
173 if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) {
174 return yield* new OpenCodeRuntimeError({
175 operation: "global.health",
176 detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`,
177 });
178 }
179 return health.version;
180});
181
182export interface OpenCodeCommandResult {
183 readonly stdout: string;
184 readonly stderr: string;
185 readonly code: number;
186}
187
188export interface OpenCodeInventory {
189 readonly providerList: ProviderListResponse;
190 readonly agents: ReadonlyArray<Agent>;
191 readonly skills: ReadonlyArray<OpenCodeSkill>;
192 readonly commands?: ReadonlyArray<OpenCodeSlashCommand>;
193}
194
195export type OpenCodeSlashCommand = Pick<Command, "name" | "description" | "source" | "hints">;
196
197/** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */
198export const loadOpenCodeCommands = (client: OpencodeClient) =>
199 runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe(
200 Effect.map((result): ReadonlyArray<OpenCodeSlashCommand> =>
201 (result.data ?? []).map(({ name, description, source, hints }) => ({
202 name,
203 ...(description === undefined ? {} : { description }),
204 ...(source === undefined ? {} : { source }),
205 hints,
206 })),
207 ),
208 );
209
210export interface ParsedOpenCodeModelSlug {
211 readonly providerID: string;
212 readonly modelID: string;
213}
214
215export interface OpenCodeSkill {
216 readonly name?: string | null;
217 readonly description?: string | null;
218 readonly location?: string | null;
219}
220
221const OpenCodeSkillSchema = Schema.Struct({
222 name: Schema.optionalKey(Schema.NullOr(Schema.String)),
223 description: Schema.optionalKey(Schema.NullOr(Schema.String)),
224 location: Schema.optionalKey(Schema.NullOr(Schema.String)),
225});
226const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit(
227 Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)),
228);
229
230export interface OpenCodeRuntimeShape {
231 /**
232 * Spawns a local OpenCode server process. Its lifetime is bound to the caller's
233 * `Scope.Scope` — the child is killed automatically when that scope closes.
234 * Consumers that want a long-lived server must create and hold a scope explicitly
235 * (see {@link Scope.make}) and close it when done.
236 */
237 readonly startOpenCodeServerProcess: (input: {
238 readonly binaryPath: string;
239 readonly directory: string;
240 readonly serverPassword?: string;
241 readonly environment?: NodeJS.ProcessEnv;
242 readonly port?: number;
243 readonly hostname?: string;
244 readonly timeoutMs?: number;
245 /** Checks the listening server and returns its version. Defaults to the 1.x health check. */
246 readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntimeError>;
247 }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>;
248 /**
249 * Returns a handle to either an externally-managed OpenCode server (when
250 * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a
251 * freshly spawned local server whose lifetime is bound to the caller's scope.
252 */
253 readonly connectToOpenCodeServer: (input: {
254 readonly binaryPath: string;
255 readonly directory: string;
256 readonly serverUrl?: string | null;
257 readonly serverPassword?: string;
258 readonly environment?: NodeJS.ProcessEnv;
259 readonly port?: number;
260 readonly hostname?: string;
261 readonly timeoutMs?: number;
262 }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>;
263 readonly runOpenCodeCommand: (input: {
264 readonly binaryPath: string;
265 readonly args: ReadonlyArray<string>;
266 readonly environment?: NodeJS.ProcessEnv;
267 readonly cwd?: string;
268 readonly maxOutputBytes?: number;
269 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
270 readonly createOpenCodeSdkClient: (input: {
271 readonly baseUrl: string;
272 readonly directory: string;
273 readonly serverPassword?: string;
274 }) => OpencodeClient;
275 readonly loadOpenCodeInventory: (
276 client: OpencodeClient,
277 ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
278 readonly loadOpenCodeSkills: (
279 client: OpencodeClient,
280 ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;
281 readonly loadInventoryFromCli: (input: {
282 readonly binaryPath: string;
283 readonly cwd: string;
284 readonly environment?: NodeJS.ProcessEnv;
285 }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
286 readonly loadSkillsFromCli: (input: {
287 readonly binaryPath: string;
288 readonly cwd: string;
289 readonly environment?: NodeJS.ProcessEnv;
290 }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;
291}
292
293function parseServerUrlFromOutput(output: string): string | null {
294 for (const line of output.split("\n")) {
295 const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i);
296 if (match?.[1]) return match[1];
297 }
298 return null;
299}
300
301const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/;
302const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/;
303
304// Agents that are always hidden in OpenCode but the CLI "agent list" command
305// does not expose the hidden flag. Keep in sync with OpenCode agent
306// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts).
307const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]);
308
309/** @internal */
310export function parseModelsCliOutput(stdout: string): {
311 readonly providers: ReadonlyMap<
312 string,
313 { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } }
314 >;
315 readonly connected: ReadonlyArray<string>;
316} {
317 const providers = new Map<
318 string,
319 { id: string; name: string; models: { [key: string]: Model } }
320 >();
321 const lines = stdout.split("\n");
322 let currentSlug: string | null = null;
323 const jsonLines: Array<string> = [];
324
325 const flushModel = () => {
326 if (currentSlug !== null && jsonLines.length > 0) {
327 const jsonStr = jsonLines.join("\n").trim();
328 if (jsonStr.length > 0) {
329 try {
330 const model = JSON.parse(jsonStr) as Model;
331 const separator = currentSlug.indexOf("/");
332 if (separator > 0) {
333 const providerID = currentSlug.slice(0, separator);
334 const modelID = currentSlug.slice(separator + 1);
335 let provider = providers.get(providerID);
336 if (!provider) {
337 provider = { id: providerID, name: providerID, models: {} };
338 providers.set(providerID, provider);
339 }
340 provider.models[modelID] = model;
341 }
342 } catch {
343 // Skip unparseable model JSON
344 }
345 }
346 }
347 currentSlug = null;
348 jsonLines.length = 0;
349 };
350
351 for (const line of lines) {
352 // A model's JSON body is a single `JSON.stringify` line starting with `{`,
353 // while a provider/model slug is a bare `provider/model` header. Only the
354 // latter can be a slug: without this guard a body line with no interior
355 // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose
356 // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against
357 // an empty body and the model is silently dropped.
358 const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line);
359 if (slugMatch) {
360 flushModel();
361 currentSlug = slugMatch[1]!;
362 } else if (currentSlug !== null) {
363 jsonLines.push(line);
364 }
365 }
366 flushModel();
367
368 return { providers, connected: [...providers.keys()] };
369}
370
371/** @internal */
372export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {
373 const agents: Array<Agent> = [];
374 const lines = stdout.split("\n");
375 let currentHeader: { name: string; mode: string } | null = null;
376 const blockLines: Array<string> = [];
377
378 const flushAgent = () => {
379 if (currentHeader !== null) {
380 const jsonStr = blockLines.join("\n").trim();
381 if (jsonStr.length > 0) {
382 try {
383 const permission = JSON.parse(jsonStr);
384 agents.push({
385 name: currentHeader.name,
386 mode: currentHeader.mode as Agent["mode"],
387 hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name),
388 permission,
389 options: {},
390 });
391 } catch {
392 // Skip unparseable agent
393 }
394 }
395 }
396 currentHeader = null;
397 blockLines.length = 0;
398 };
399
400 for (const line of lines) {
401 const match = AGENT_HEADER_RE.exec(line);
402 if (match) {
403 flushAgent();
404 currentHeader = { name: match[1]!, mode: match[2]! };
405 } else if (currentHeader !== null) {
406 blockLines.push(line);
407 }
408 }
409 flushAgent();
410
411 return agents;
412}
413
414/** @internal */
415export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> {
416 const result = decodeOpenCodeSkillsCliOutputExit(stdout);
417 return Exit.isSuccess(result) ? result.value : [];
418}
419
420export function parseOpenCodeModelSlug(
421 slug: string | null | undefined,
422): ParsedOpenCodeModelSlug | null {
423 if (typeof slug !== "string") {
424 return null;
425 }
426
427 const trimmed = slug.trim();
428 const separator = trimmed.indexOf("/");
429 if (separator <= 0 || separator === trimmed.length - 1) {
430 return null;
431 }
432
433 return {
434 providerID: trimmed.slice(0, separator),
435 modelID: trimmed.slice(separator + 1),
436 };
437}
438
439export function openCodeQuestionId(
440 index: number,
441 question: QuestionRequest["questions"][number],
442): string {
443 const header = question.header
444 .trim()
445 .toLowerCase()
446 .replace(/[^a-z0-9_-]+/g, "-");
447 return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;
448}
449
450/**
451 * Attachments OpenCode can hand to a model as a native file part. Anything
452 * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs
453 * reject, or files over the direct-attachment size limit) would make the turn
454 * fail before it starts, so those ride only as the file path ProviderService
455 * puts in the prompt.
456 */
457const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
458const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024;
459
460function isOpenCodeNativeFilePart(input: {
461 readonly mimeType: string;
462 readonly sizeBytes: number;
463}): boolean {
464 if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) {
465 return false;
466 }
467 const normalized = input.mimeType.trim().toLowerCase();
468 return (
469 OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) ||
470 normalized.startsWith("text/") ||
471 normalized === "application/pdf"
472 );
473}
474
475export function toOpenCodeFileParts(input: {
476 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
477 readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
478}): Array<FilePartInput> {
479 const parts: Array<FilePartInput> = [];
480
481 for (const attachment of input.attachments ?? []) {
482 if (
483 attachment.type === "file" &&
484 "source" in attachment &&
485 attachment.source?._tag === "pasted-text"
486 ) {
487 continue;
488 }
489 if (!isOpenCodeNativeFilePart(attachment)) {
490 continue;
491 }
492 const attachmentPath = input.resolveAttachmentPath(attachment);
493 if (!attachmentPath) {
494 continue;
495 }
496
497 parts.push({
498 type: "file",
499 mime: attachment.mimeType,
500 filename: attachment.name,
501 url: NodeURL.pathToFileURL(attachmentPath).href,
502 });
503 }
504
505 return parts;
506}
507
508export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset {
509 if (runtimeMode === "full-access") {
510 return [
511 { permission: "*", pattern: "*", action: "allow" },
512 { permission: "external_directory", pattern: "*", action: "allow" },
513 ];
514 }
515
516 // "Auto-accept edits" is documented as "auto-approve edits, ask before other
517 // actions", so prompting for every edit ignores the mode the user picked.
518 // "auto" is left asking on purpose: the docs say providers without an AI
519 // reviewer, OpenCode among them, fall back to Supervised for that mode.
520 const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask";
521
522 // Session rules override OpenCode's agent defaults. Allow reads and task
523 // updates, but keep its default approval rules for environment files.
524 return [
525 { permission: "*", pattern: "*", action: "ask" },
526 { permission: "read", pattern: "*", action: "allow" },
527 { permission: "read", pattern: "*.env", action: "ask" },
528 { permission: "read", pattern: "*.env.*", action: "ask" },
529 { permission: "read", pattern: "*.env.example", action: "allow" },
530 { permission: "glob", pattern: "*", action: "allow" },
531 { permission: "grep", pattern: "*", action: "allow" },
532 { permission: "lsp", pattern: "*", action: "allow" },
533 { permission: "skill", pattern: "*", action: "allow" },
534 { permission: "todowrite", pattern: "*", action: "allow" },
535 { permission: "bash", pattern: "*", action: "ask" },
536 { permission: "edit", pattern: "*", action: editAction },
537 { permission: "webfetch", pattern: "*", action: "ask" },
538 { permission: "websearch", pattern: "*", action: "ask" },
539 { permission: "codesearch", pattern: "*", action: "ask" },
540 { permission: "external_directory", pattern: "*", action: "ask" },
541 { permission: "doom_loop", pattern: "*", action: "ask" },
542 { permission: "question", pattern: "*", action: "allow" },
543 ];
544}
545
546export function toOpenCodePermissionReply(
547 decision: ProviderApprovalDecision,
548): "once" | "always" | "reject" {
549 switch (decision) {
550 case "accept":
551 return "once";
552 case "acceptForSession":
553 case "acceptAlways":
554 return "always";
555 case "decline":
556 case "cancel":
557 default:
558 return "reject";
559 }
560}
561
562export function toOpenCodeQuestionAnswers(
563 request: QuestionRequest,
564 answers: Record<string, unknown>,
565): Array<QuestionAnswer> {
566 return request.questions.map((question, index) => {
567 const raw =
568 answers[openCodeQuestionId(index, question)] ??
569 answers[question.header] ??
570 answers[question.question];
571 if (Array.isArray(raw)) {
572 return raw.filter((value): value is string => typeof value === "string");
573 }
574 if (typeof raw === "string") {
575 return raw.trim().length > 0 ? [raw] : [];
576 }
577 return [];
578 });
579}
580
581function ensureRuntimeError(
582 operation: OpenCodeRuntimeError["operation"],
583 detail: string,
584 cause: unknown,
585): OpenCodeRuntimeError {
586 return OpenCodeRuntimeError.is(cause)
587 ? cause
588 : new OpenCodeRuntimeError({ operation, detail, cause });
589}
590
591const makeOpenCodeRuntime = Effect.gen(function* () {
592 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
593 const netService = yield* NetService.NetService;
594 const hostPlatform = yield* HostProcessPlatform;
595 const serverLedger = yield* OpenCodeServerLedger.OpenCodeServerLedger;
596 const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>
597 resolveSpawnCommand(command, args, env ? { env } : {});
598
599 const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) =>
600 Effect.gen(function* () {
601 const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment);
602 const child = yield* spawner.spawn(
603 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
604 detached: hostPlatform !== "win32",
605 shell: spawnCommand.shell,
606 ...(input.cwd ? { cwd: input.cwd } : {}),
607 ...(input.environment ? { env: input.environment } : { extendEnv: true }),
608 }),
609 );
610 const terminateCommandGroup =
611 hostPlatform === "win32"
612 ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid)
613 : Effect.sync(() => {
614 try {
615 signalProcessGroup(Number(child.pid), "SIGKILL");
616 } catch {
617 // The command and its process group may already have exited.
618 }
619 });
620 yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore));
621 const collectOptions =
622 input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes };
623 const [stdout, stderr, code] = yield* Effect.all(
624 [
625 collectStreamAsString(child.stdout, collectOptions),
626 collectStreamAsString(child.stderr, collectOptions),
627 child.exitCode,
628 ],
629 { concurrency: "unbounded" },
630 );
631 const exitCode = Number(code);
632 if (yield* isWindowsCommandNotFound(exitCode, stderr)) {
633 return yield* new OpenCodeRuntimeError({
634 operation: "runOpenCodeCommand",
635 detail: `spawn ${input.binaryPath} ENOENT`,
636 });
637 }
638 return {
639 stdout,
640 stderr,
641 code: exitCode,
642 } satisfies OpenCodeCommandResult;
643 }).pipe(
644 Effect.scoped,
645 Effect.mapError((cause) =>
646 ensureRuntimeError(
647 "runOpenCodeCommand",
648 `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`,
649 cause,
650 ),
651 ),
652 );
653
654 const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) =>
655 createOpencodeClient({
656 baseUrl: input.baseUrl,
657 directory: input.directory,
658 ...(input.serverPassword
659 ? {
660 headers: {
661 Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`,
662 },
663 }
664 : {}),
665 throwOnError: true,
666 });
667
668 const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) =>
669 Effect.gen(function* () {
670 // Bind this server's lifetime to the caller's scope. When the caller's
671 // scope closes, the spawned child is killed and all associated fibers
672 // are interrupted automatically — no `close()` method needed.
673 const runtimeScope = yield* Scope.Scope;
674
675 const hostname = input.hostname ?? DEFAULT_HOSTNAME;
676 const port =
677 input.port ??
678 (yield* netService.findAvailablePort(0).pipe(
679 Effect.mapError(
680 (cause) =>
681 new OpenCodeRuntimeError({
682 operation: "startOpenCodeServerProcess",
683 detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`,
684 cause,
685 }),
686 ),
687 ));
688 const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;
689 const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];
690 const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment);
691 const serverPassword = resolveOpenCodeServerPassword({
692 external: false,
693 ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
694 ...(input.environment !== undefined ? { environment: input.environment } : {}),
695 });
696
697 // Scopes close in reverse order. Forking this before the group kill is
698 // registered forgets the ledger entry only once the group is stopped.
699 const ledgerScope = yield* Scope.fork(runtimeScope);
700 const child = yield* spawner
701 .spawn(
702 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
703 detached: hostPlatform !== "win32",
704 shell: spawnCommand.shell,
705 env: {
706 ...input.environment,
707 ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}),
708 // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or
709 // the inherited process environment, only falling back to the
710 // empty config when neither is set. Setting it unconditionally
711 // previously clobbered the user's opencode config, hiding their
712 // providers/models. The value is set explicitly (rather than
713 // relying on inheritance) because `extendEnv` is false whenever
714 // `input.environment` is provided.
715 OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment),
716 },
717 extendEnv: input.environment === undefined,
718 }),
719 )
720 .pipe(
721 Effect.provideService(Scope.Scope, runtimeScope),
722 Effect.mapError(
723 (cause) =>
724 new OpenCodeRuntimeError({
725 operation: "startOpenCodeServerProcess",
726 detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`,
727 cause,
728 }),
729 ),
730 );
731
732 const killOpenCodeProcessGroup = (signal: NodeJS.Signals) =>
733 hostPlatform === "win32"
734 ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid)
735 : Effect.sync(() => {
736 try {
737 signalProcessGroup(Number(child.pid), signal);
738 } catch {
739 // The direct child may already have exited after starting the
740 // server; the process group kill is best-effort cleanup for
741 // any serve process left in that group.
742 }
743 });
744 const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe(
745 Effect.andThen(Effect.sleep("1 second")),
746 Effect.andThen(killOpenCodeProcessGroup("SIGKILL")),
747 Effect.ignore,
748 );
749 // Registered before recording, so an interrupt while the ledger writes
750 // still stops the group.
751 yield* Scope.addFinalizer(runtimeScope, terminateChild);
752 const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args });
753 yield* Scope.addFinalizer(ledgerScope, forgetServer);
754
755 const stdoutRef = yield* Ref.make<string | null>("");
756 const stderrRef = yield* Ref.make<string | null>("");
757 const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();
758
759 const setReadyFromStdoutChunk = (chunk: string) =>
760 Ref.modify(stdoutRef, (stdout) => {
761 if (stdout === null) {
762 return [null, null] as const;
763 }
764 const nextStdout = `${stdout}${chunk}`;
765 return [
766 parseServerUrlFromOutput(nextStdout),
767 nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS),
768 ] as const;
769 }).pipe(
770 Effect.flatMap((parsed) =>
771 parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void,
772 ),
773 );
774
775 const stdoutFiber = yield* child.stdout.pipe(
776 Stream.decodeText(),
777 Stream.runForEach(setReadyFromStdoutChunk),
778 Effect.ignore,
779 Effect.forkIn(runtimeScope),
780 );
781 const stderrFiber = yield* child.stderr.pipe(
782 Stream.decodeText(),
783 Stream.runForEach((chunk) =>
784 Ref.update(stderrRef, (stderr) =>
785 stderr === null
786 ? null
787 : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS),
788 ),
789 ),
790 Effect.ignore,
791 Effect.forkIn(runtimeScope),
792 );
793
794 const exitFiber = yield* child.exitCode.pipe(
795 Effect.flatMap((code) =>
796 Effect.gen(function* () {
797 const stdout = (yield* Ref.get(stdoutRef)) ?? "";
798 const stderr = (yield* Ref.get(stderrRef)) ?? "";
799 const exitCode = Number(code);
800 yield* Deferred.fail(
801 readyDeferred,
802 new OpenCodeRuntimeError({
803 operation: "startOpenCodeServerProcess",
804 detail: [
805 `OpenCode server exited before startup completed (code: ${String(exitCode)}).`,
806 stdout.trim() ? `stdout:\n${stdout.trim()}` : null,
807 stderr.trim() ? `stderr:\n${stderr.trim()}` : null,
808 ]
809 .filter(Boolean)
810 .join("\n\n"),
811 cause: { exitCode, stdout, stderr },
812 }),
813 ).pipe(Effect.ignore);
814 }),
815 ),
816 Effect.ignore,
817 Effect.forkIn(runtimeScope),
818 );
819
820 const readyExit = yield* Effect.exit(
821 Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),
822 );
823
824 if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) {
825 yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore);
826 }
827
828 if (Exit.isFailure(readyExit)) {
829 const squashed = Cause.squash(readyExit.cause);
830 return yield* ensureRuntimeError(
831 "startOpenCodeServerProcess",
832 `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`,
833 squashed,
834 );
835 }
836
837 const readyOption = readyExit.value;
838 if (Option.isNone(readyOption)) {
839 return yield* new OpenCodeRuntimeError({
840 operation: "startOpenCodeServerProcess",
841 detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`,
842 });
843 }
844
845 // Keep draining both pipes until the process scope closes. Stopping the
846 // readers can block OpenCode when its output buffers fill. Startup output
847 // is no longer needed, so discard later output instead of retaining it.
848 yield* Ref.set(stdoutRef, null);
849 yield* Ref.set(stderrRef, null);
850
851 const url = readyOption.value;
852 const version = yield* (
853 input.verify?.(url) ??
854 verifyOpenCodeServerVersion(
855 createOpenCodeSdkClient({
856 baseUrl: url,
857 directory: input.directory,
858 ...(serverPassword !== undefined ? { serverPassword } : {}),
859 }),
860 )
861 );
862
863 return {
864 url,
865 ...(serverPassword !== undefined ? { serverPassword } : {}),
866 version,
867 isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)),
868 exitCode: child.exitCode.pipe(
869 Effect.map(Number),
870 Effect.orElseSucceed(() => 0),
871 ),
872 } satisfies OpenCodeServerProcess;
873 });
874
875 const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => {
876 const serverUrl = input.serverUrl?.trim();
877 if (serverUrl) {
878 const serverPassword = resolveOpenCodeServerPassword({
879 external: true,
880 ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
881 });
882 return verifyOpenCodeServerVersion(
883 createOpenCodeSdkClient({
884 baseUrl: serverUrl,
885 directory: input.directory,
886 ...(serverPassword !== undefined ? { serverPassword } : {}),
887 }),
888 ).pipe(
889 Effect.map((version) => ({
890 url: serverUrl,
891 ...(serverPassword !== undefined ? { serverPassword } : {}),
892 version,
893 exitCode: null,
894 external: true,
895 })),
896 );
897 }
898
899 return startOpenCodeServerProcess({
900 binaryPath: input.binaryPath,
901 directory: input.directory,
902 ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
903 ...(input.environment !== undefined ? { environment: input.environment } : {}),
904 ...(input.port !== undefined ? { port: input.port } : {}),
905 ...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
906 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
907 }).pipe(
908 Effect.map((server) => ({
909 url: server.url,
910 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
911 version: server.version,
912 exitCode: server.exitCode,
913 external: false,
914 })),
915 );
916 };
917
918 const loadProviders = (client: OpencodeClient) =>
919 runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe(
920 Effect.filterMapOrFail(
921 (list) =>
922 list.data
923 ? Result.succeed(list.data)
924 : Result.fail(
925 new OpenCodeRuntimeError({
926 operation: "provider.list",
927 detail: "OpenCode provider list was empty.",
928 }),
929 ),
930 (result) => result,
931 ),
932 );
933
934 const loadAgents = (client: OpencodeClient) =>
935 runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe(
936 Effect.map((result) => result.data ?? []),
937 Effect.orElseSucceed((): ReadonlyArray<Agent> => []),
938 );
939
940 const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) =>
941 runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe(
942 Effect.map((result) =>
943 (result.data ?? []).map((skill) => ({
944 name: skill.name,
945 ...(skill.description === undefined ? {} : { description: skill.description }),
946 location: skill.location,
947 })),
948 ),
949 );
950 const loadSkills = (client: OpencodeClient) =>
951 loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []));
952
953 const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) =>
954 Effect.all(
955 [
956 loadProviders(client),
957 loadAgents(client),
958 loadSkills(client),
959 loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])),
960 ],
961 {
962 concurrency: "unbounded",
963 },
964 ).pipe(
965 Effect.map(([providerList, agents, skills, commands]) => ({
966 providerList,
967 agents,
968 skills,
969 commands,
970 })),
971 );
972
973 const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) =>
974 Effect.gen(function* () {
975 const env = input.environment !== undefined ? { environment: input.environment } : ({} as {});
976 const commandContext = { cwd: input.cwd, ...env };
977
978 const runModelsCli = () =>
979 runOpenCodeCommand({
980 binaryPath: input.binaryPath,
981 args: ["models", "--verbose"],
982 ...commandContext,
983 }).pipe(Effect.exit);
984 const runAgentsCli = () =>
985 runOpenCodeCommand({
986 binaryPath: input.binaryPath,
987 args: ["agent", "list"],
988 ...commandContext,
989 }).pipe(Effect.exit);
990 const runSkillsCli = () =>
991 runOpenCodeCommand({
992 binaryPath: input.binaryPath,
993 args: ["debug", "skill"],
994 maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES,
995 ...commandContext,
996 }).pipe(Effect.exit);
997
998 // Every OpenCode CLI command opens the same shared SQLite database. Running them
999 // concurrently causes "database is locked" failures, so run them one at a time.
1000 const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all(
1001 [runModelsCli(), runAgentsCli(), runSkillsCli()],
1002 { concurrency: 1 },
1003 );
1004 let modelsResult = initialModelsResult;
1005 let agentsResult = initialAgentsResult;
1006 let skillsResult = initialSkillsResult;
1007
1008 // Retry once after 1s on transient failures (e.g. SQLite "database is locked")
1009 const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0;
1010 const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0;
1011 const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0;
1012 if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) {
1013 yield* Effect.sleep("1 second");
1014 const [m2, a2, s2] = yield* Effect.all(
1015 [
1016 needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult),
1017 needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult),
1018 needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult),
1019 ],
1020 { concurrency: 1 },
1021 );
1022 modelsResult = m2;
1023 agentsResult = a2;
1024 skillsResult = s2;
1025 }
1026
1027 if (modelsResult._tag === "Failure") {
1028 const cause = Cause.squash(modelsResult.cause);
1029 return yield* ensureRuntimeError(
1030 "loadInventoryFromCli",
1031 `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`,
1032 cause,
1033 );
1034 }
1035 if (modelsResult.value.code !== 0) {
1036 return yield* new OpenCodeRuntimeError({
1037 operation: "loadInventoryFromCli",
1038 detail: `OpenCode models command exited with code ${modelsResult.value.code}.`,
1039 });
1040 }
1041
1042 const parsed = parseModelsCliOutput(modelsResult.value.stdout);
1043 const connected = [...parsed.connected];
1044 const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map(
1045 (provider) => ({
1046 id: provider.id,
1047 name: provider.name,
1048 source: "config" as const,
1049 env: [],
1050 options: {},
1051 models: provider.models,
1052 }),
1053 );
1054
1055 // Agent and skill metadata enrich the provider snapshot but are not required
1056 // for an authoritative model inventory, so either may degrade to an empty list.
1057 let agents: ReadonlyArray<Agent> = [];
1058 if (agentsResult._tag === "Success" && agentsResult.value.code === 0) {
1059 agents = parseAgentListCliOutput(agentsResult.value.stdout);
1060 }
1061 let skills: ReadonlyArray<OpenCodeSkill> = [];
1062 if (skillsResult._tag === "Success" && skillsResult.value.code === 0) {
1063 skills = parseSkillsCliOutput(skillsResult.value.stdout);
1064 }
1065
1066 return {
1067 providerList: { all: allProviders, default: {}, connected },
1068 agents,
1069 skills,
1070 };
1071 });
1072
1073 const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) =>
1074 runOpenCodeCommand({
1075 binaryPath: input.binaryPath,
1076 args: ["debug", "skill"],
1077 cwd: input.cwd,
1078 maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES,
1079 ...(input.environment !== undefined ? { environment: input.environment } : {}),
1080 }).pipe(
1081 Effect.flatMap((result) =>
1082 result.code === 0
1083 ? Effect.succeed(parseSkillsCliOutput(result.stdout))
1084 : Effect.fail(
1085 new OpenCodeRuntimeError({
1086 operation: "loadSkillsFromCli",
1087 detail: `OpenCode skills command exited with code ${result.code}.`,
1088 }),
1089 ),
1090 ),
1091 );
1092
1093 return {
1094 startOpenCodeServerProcess,
1095 connectToOpenCodeServer,
1096 runOpenCodeCommand,
1097 createOpenCodeSdkClient,
1098 loadOpenCodeInventory,
1099 loadOpenCodeSkills,
1100 loadInventoryFromCli,
1101 loadSkillsFromCli,
1102 } satisfies OpenCodeRuntimeShape;
1103});
1104
1105export class OpenCodeRuntime extends Context.Service<OpenCodeRuntime, OpenCodeRuntimeShape>()(
1106 "@t3tools/provider-opencode/server/OpenCodeRuntime",
1107) {}
1108
1109export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe(
1110 Layer.provide(NetService.layer),
1111);
packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts created+244
...@@ -0,0 +1,244 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, expect, it } from "@effect/vitest";
6import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";
10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as Scope from "effect/Scope";
15import { ChildProcessSpawner } from "effect/process";
16
17import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
18import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
19
20const SERVE_ARGS = ["serve", "--hostname=127.0.0.1", "--port=4096"];
21
22const groupExists = (pgid: number) => {
23 try {
24 process.kill(-pgid, 0);
25 return true;
26 } catch {
27 return false;
28 }
29};
30
31const killGroup = (pgid: number) =>
32 Effect.sync(() => {
33 if (groupExists(pgid)) process.kill(-pgid, "SIGKILL");
34 });
35
36/**
37 * Spawns a throwaway process group shaped like `opencode serve`: by default a
38 * shell leader whose argv ends in the serve arguments, plus a `sleep` member.
39 */
40const spawnGroup = (args: ReadonlyArray<string>, script = "sleep 600 & wait") =>
41 Effect.acquireRelease(
42 Effect.gen(function* () {
43 const child = NodeChildProcess.spawn("/bin/sh", ["-c", script, ...args], {
44 detached: true,
45 stdio: "ignore",
46 });
47 const exited = yield* Deferred.make<NodeJS.Signals | null>();
48 child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal)));
49 const pid = child.pid;
50 if (pid === undefined) return yield* Effect.die("spawn failed");
51 return { pid, exited: Deferred.await(exited) };
52 }),
53 ({ pid }) => killGroup(pid),
54 );
55
56/** A T3 server that recorded its OpenCode server and then died without cleanup. */
57const recordFromDeadServer = (stateDir: string, server: { readonly pid: number }) =>
58 Effect.gen(function* () {
59 const previousServer = yield* spawnGroup([]);
60 const previousLedger = yield* OpenCodeServerLedger.make({
61 stateDir,
62 ownerPid: previousServer.pid,
63 });
64 // The previous server never gets to forget its entry.
65 yield* Effect.asVoid(previousLedger.track({ pid: server.pid, port: 4096, args: SERVE_ARGS }));
66 process.kill(-previousServer.pid, "SIGKILL");
67 yield* previousServer.exited;
68 });
69
70const hostPlatform = HostProcessPlatform.defaultValue();
71// procps accepts the same `ps` flags as macOS, so Linux also covers the macOS path.
72const observedPlatforms: ReadonlyArray<NodeJS.Platform> =
73 hostPlatform === "linux" ? ["linux", "darwin"] : hostPlatform === "darwin" ? ["darwin"] : [];
74
75describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platform) => {
76 const provideHost = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
77 effect.pipe(
78 Effect.provideService(HostProcessPlatform, platform),
79 Effect.provide(NodeServices.layer),
80 );
81
82 it.live("stops an OpenCode server group left by a server that died", () =>
83 Effect.gen(function* () {
84 const fs = yield* FileSystem.FileSystem;
85 const path = yield* Path.Path;
86 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
87 const orphan = yield* spawnGroup(SERVE_ARGS);
88 yield* recordFromDeadServer(stateDir, orphan);
89 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1);
90
91 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
92 yield* restarted.reapOrphans;
93
94 expect(yield* orphan.exited).toBe("SIGTERM");
95 expect(groupExists(orphan.pid)).toBe(false);
96 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
97 }).pipe(provideHost),
98 );
99
100 it.live("records a group whose spawned wrapper already exited", () =>
101 Effect.gen(function* () {
102 const fs = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
105 // The wrapper starts the serve process in its group and exits.
106 const wrapper = yield* spawnGroup(
107 SERVE_ARGS,
108 '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0',
109 );
110 yield* wrapper.exited;
111 expect(groupExists(wrapper.pid)).toBe(true);
112
113 yield* recordFromDeadServer(stateDir, wrapper);
114 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1);
115
116 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
117 yield* restarted.reapOrphans;
118
119 expect(groupExists(wrapper.pid)).toBe(false);
120 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
121 }).pipe(provideHost),
122 );
123
124 it.live("leaves a recycled pid alone when its start time does not match", () =>
125 Effect.gen(function* () {
126 const fs = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
129 const unrelated = yield* spawnGroup(SERVE_ARGS);
130 yield* recordFromDeadServer(stateDir, unrelated);
131 const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`);
132 // Same pid, a different process: what a recycled pid looks like.
133 const entry = yield* fs.readFileString(entryPath);
134 yield* fs.writeFileString(entryPath, entry.replace(/"startTime":"[^"]+"/, '"startTime":"1"'));
135
136 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
137 yield* restarted.reapOrphans;
138
139 expect(groupExists(unrelated.pid)).toBe(true);
140 expect(yield* fs.exists(entryPath)).toBe(false);
141 }).pipe(provideHost),
142 );
143
144 it.live("leaves a serve process on another port alone", () =>
145 Effect.gen(function* () {
146 const fs = yield* FileSystem.FileSystem;
147 const path = yield* Path.Path;
148 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
149 const unrelated = yield* spawnGroup(SERVE_ARGS);
150 yield* recordFromDeadServer(stateDir, unrelated);
151 const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`);
152 // Same pid and start second, but not the server T3 started.
153 const entry = yield* fs.readFileString(entryPath);
154 yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097"));
155
156 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
157 yield* restarted.reapOrphans;
158
159 expect(groupExists(unrelated.pid)).toBe(true);
160 expect(yield* fs.exists(entryPath)).toBe(false);
161 }).pipe(provideHost),
162 );
163
164 it.live("leaves the servers of a running T3 server alone", () =>
165 Effect.gen(function* () {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
169 const running = yield* OpenCodeServerLedger.make({ stateDir });
170 const server = yield* spawnGroup(SERVE_ARGS);
171 const forget = yield* running.track({ pid: server.pid, port: 4096, args: SERVE_ARGS });
172
173 const other = yield* OpenCodeServerLedger.make({ stateDir });
174 yield* other.reapOrphans;
175 expect(groupExists(server.pid)).toBe(true);
176
177 yield* forget;
178 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
179 }).pipe(provideHost),
180 );
181});
182
183describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => {
184 it.live("stops the group when interrupted while the server is being recorded", () =>
185 Effect.gen(function* () {
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-startup-" });
189 // A wrapper that leaves the serve process running in its group and exits 0,
190 // which the spawner's own cleanup does not stop.
191 const binaryPath = path.join(tempDir, "opencode");
192 yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 600 &\nexit 0\n");
193 yield* fs.chmod(binaryPath, 0o755);
194
195 const spawned = yield* Deferred.make<ChildProcessSpawner.ChildProcessHandle>();
196 const recording = yield* Deferred.make<number>();
197 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
198 const layerRuntime = OpenCodeRuntime.layer.pipe(
199 Layer.provide(
200 Layer.succeed(
201 ChildProcessSpawner.ChildProcessSpawner,
202 ChildProcessSpawner.make((command) =>
203 spawner
204 .spawn(command)
205 .pipe(Effect.tap((handle) => Deferred.succeed(spawned, handle))),
206 ),
207 ),
208 ),
209 Layer.provide(
210 Layer.succeed(
211 OpenCodeServerLedger.OpenCodeServerLedger,
212 OpenCodeServerLedger.OpenCodeServerLedger.of({
213 // Recording stalls until the scope is interrupted, after the wrapper exited.
214 track: (server) =>
215 Effect.gen(function* () {
216 yield* (yield* Deferred.await(spawned)).exitCode.pipe(Effect.ignore);
217 yield* Deferred.succeed(recording, server.pid);
218 return yield* Effect.never;
219 }),
220 }),
221 ),
222 ),
223 );
224
225 const serverScope = yield* Scope.make();
226 const starting = yield* OpenCodeRuntime.OpenCodeRuntime.pipe(
227 Effect.flatMap((runtime) =>
228 runtime.startOpenCodeServerProcess({ binaryPath, directory: tempDir, port: 4096 }),
229 ),
230 Effect.provideService(Scope.Scope, serverScope),
231 Effect.provide(layerRuntime),
232 Effect.forkChild,
233 );
234 const pgid = yield* Deferred.await(recording);
235 yield* Effect.addFinalizer(() => killGroup(pgid));
236 expect(groupExists(pgid)).toBe(true);
237
238 yield* Fiber.interrupt(starting);
239 yield* Scope.close(serverScope, Exit.void);
240
241 expect(groupExists(pgid)).toBe(false);
242 }).pipe(Effect.provide(NodeServices.layer)),
243 );
244});
packages/provider-opencode/src/server/OpenCodeServerLedger.ts created+339
...@@ -0,0 +1,339 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import { ChildProcess, ChildProcessSpawner } from "effect/process";
10
11import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
12import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
13
14const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String });
15type ProcessIdentity = typeof ProcessIdentity.Type;
16
17const OpenCodeServerEntry = Schema.Struct({
18 version: Schema.Literal(1),
19 /** The spawned group. Its id is the pid T3 spawned, which also names the entry file. */
20 pgid: Schema.Int,
21 /**
22 * A group member seen at spawn: the spawned process, or a process it left in
23 * the group if it had already exited. Its pid, start time, and command line
24 * must all still match before the group is signalled.
25 */
26 pid: Schema.Int,
27 /** Linux `/proc/<pid>/stat` start ticks, or macOS `ps` lstart (whole seconds). */
28 startTime: Schema.String,
29 command: Schema.String,
30 port: Schema.Int,
31 /** Entries copied along with a state directory are dropped, never acted on. */
32 stateDir: Schema.String,
33 /** The T3 server that spawned it. Entries of a live owner are never touched. */
34 owner: ProcessIdentity,
35});
36type OpenCodeServerEntry = typeof OpenCodeServerEntry.Type;
37const OpenCodeServerEntryJson = Schema.fromJsonString(OpenCodeServerEntry);
38const decodeEntry = Schema.decodeUnknownOption(OpenCodeServerEntryJson);
39const encodeEntry = Schema.encodeEffect(OpenCodeServerEntryJson);
40
41/**
42 * Local `opencode serve` processes run in their own process group so T3 can
43 * stop the whole group, which also means they outlive a T3 server that is
44 * SIGKILLed or crashes. Each spawn is recorded under the state directory and
45 * removed on a graceful stop; the next server start stops whatever a dead
46 * server left behind.
47 */
48export class OpenCodeServerLedger extends Context.Service<
49 OpenCodeServerLedger,
50 {
51 /** Records a spawned server group and returns the effect that forgets it after a graceful stop. */
52 readonly track: (server: {
53 readonly pid: number;
54 readonly port: number;
55 /** The argv after the binary, e.g. `["serve", "--hostname=127.0.0.1", "--port=4096"]`. */
56 readonly args: ReadonlyArray<string>;
57 }) => Effect.Effect<Effect.Effect<void>>;
58 }
59>()("@t3tools/provider-opencode/server/OpenCodeServerLedger") {}
60
61const ENTRY_DIRECTORY = "opencode-servers";
62const ENTRY_FILE = /^\d+\.json$/;
63const STOP_POLL_INTERVAL = "50 millis";
64const STOP_POLL_ATTEMPTS = 40;
65
66interface ObservedProcess {
67 readonly pid: number;
68 readonly pgid: number;
69 readonly startTime: string;
70 readonly command: string;
71 readonly zombie: boolean;
72}
73
74// `ps -o lstart` prints e.g. `Sat Sep 6 20:51:57 2026`; C locale and UTC keep it stable.
75const DARWIN_PS_LINE =
76 /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4})\s+(.*)$/;
77
78const parseDarwinPs = (output: string): ReadonlyArray<ObservedProcess> =>
79 output.split("\n").flatMap((line) => {
80 const match = DARWIN_PS_LINE.exec(line);
81 if (match === null) return [];
82 return [
83 {
84 pid: Number(match[1]),
85 pgid: Number(match[2]),
86 zombie: match[3]!.startsWith("Z"),
87 startTime: match[4]!,
88 command: match[5]!.trimEnd(),
89 },
90 ];
91 });
92
93const signalGroup = (pgid: number, signal: NodeJS.Signals) => {
94 try {
95 signalProcessGroup(pgid, signal);
96 } catch {
97 // The group may already be gone.
98 }
99};
100
101const groupExists = (pgid: number) => {
102 try {
103 signalProcessGroup(pgid, 0);
104 return true;
105 } catch (cause) {
106 return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH";
107 }
108};
109
110/**
111 * Builds a ledger for one state directory. `ownerPid` is the T3 server that
112 * owns the servers it tracks; it defaults to this process.
113 */
114export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: {
115 readonly stateDir: string;
116 readonly ownerPid?: number;
117}) {
118 const fs = yield* FileSystem.FileSystem;
119 const path = yield* Path.Path;
120 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
121 const platform = yield* HostProcessPlatform;
122 const directory = path.join(input.stateDir, ENTRY_DIRECTORY);
123 // Windows servers are not detached and are never recorded.
124 const recordable = platform === "linux" || platform === "darwin";
125
126 /** Observes `pid`, or nothing when it is not in `group` (if given). */
127 const observeLinux = (pid: number, group?: number) =>
128 Effect.gen(function* () {
129 const stat = yield* fs.readFileString(`/proc/${pid}/stat`);
130 // After the parenthesized comm: state, ppid, pgrp, session, … starttime (index 19).
131 const fields = stat
132 .slice(stat.lastIndexOf(")") + 2)
133 .trim()
134 .split(/\s+/);
135 const pgid = Number(fields[2]);
136 const startTime = fields[19];
137 if (!Number.isSafeInteger(pgid) || startTime === undefined) return undefined;
138 if (group !== undefined && pgid !== group) return undefined;
139 const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`);
140 return {
141 pid,
142 pgid,
143 startTime,
144 command: cmdline.replace(/\0$/, "").replaceAll("\0", " "),
145 zombie: fields[0] === "Z",
146 } satisfies ObservedProcess;
147 });
148
149 const psDarwin = (selection: ReadonlyArray<string>) =>
150 spawner
151 .string(
152 ChildProcess.make(
153 "/bin/ps",
154 ["-ww", "-o", "pid=,pgid=,stat=,lstart=,args=", ...selection],
155 {
156 env: { LC_ALL: "C", TZ: "UTC" },
157 extendEnv: true,
158 stdin: "ignore",
159 stderr: "ignore",
160 },
161 ),
162 )
163 .pipe(Effect.timeout("5 seconds"), Effect.map(parseDarwinPs));
164
165 const observe = (pid: number): Effect.Effect<ObservedProcess | undefined> => {
166 if (platform === "linux") return observeLinux(pid).pipe(Effect.orElseSucceed(() => undefined));
167 if (platform === "darwin") {
168 return psDarwin(["-p", String(pid)]).pipe(
169 Effect.map((found) => found.find((candidate) => candidate.pid === pid)),
170 Effect.orElseSucceed(() => undefined),
171 );
172 }
173 return Effect.succeed(undefined);
174 };
175
176 // Scans every process, so it is only used when the spawned process is gone.
177 const observeGroup = (pgid: number): Effect.Effect<ReadonlyArray<ObservedProcess>> => {
178 if (platform === "linux") {
179 return fs.readDirectory("/proc").pipe(
180 Effect.flatMap((names) =>
181 Effect.forEach(
182 names.filter((name) => /^\d+$/.test(name)),
183 (name) => observeLinux(Number(name), pgid).pipe(Effect.orElseSucceed(() => undefined)),
184 { concurrency: 16 },
185 ),
186 ),
187 Effect.map((found) => found.filter((candidate) => candidate !== undefined)),
188 Effect.orElseSucceed(() => []),
189 );
190 }
191 if (platform === "darwin") {
192 return psDarwin(["-A"]).pipe(
193 Effect.map((found) => found.filter((candidate) => candidate.pgid === pgid)),
194 Effect.orElseSucceed(() => []),
195 );
196 }
197 return Effect.succeed([]);
198 };
199
200 const isRunning = (identity: ProcessIdentity) =>
201 observe(identity.pid).pipe(
202 Effect.map(
203 (observed) =>
204 observed !== undefined && !observed.zombie && observed.startTime === identity.startTime,
205 ),
206 );
207
208 const ownerPid = input.ownerPid ?? process.pid;
209 const ownerProcess = yield* observe(ownerPid);
210 const owner =
211 ownerProcess === undefined ? undefined : { pid: ownerPid, startTime: ownerProcess.startTime };
212
213 const track: OpenCodeServerLedger["Service"]["track"] = (server) =>
214 Effect.gen(function* () {
215 if (!recordable) return Effect.void;
216 const pgid = server.pid;
217 // The spawned process leads the group. If it already exited (a wrapper
218 // that left the server running), any live member identifies the group:
219 // a group id is not reused while the group has members.
220 const leader = yield* observe(pgid);
221 const members =
222 leader !== undefined && !leader.zombie && leader.pgid === pgid
223 ? [leader]
224 : (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie);
225 const member =
226 members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ??
227 members[0];
228 if (owner === undefined || member === undefined) {
229 if (groupExists(pgid)) {
230 yield* Effect.logWarning(
231 "Could not record an OpenCode server; it will keep running if this server crashes",
232 { pid: pgid, port: server.port },
233 );
234 }
235 return Effect.void;
236 }
237 const entryPath = path.join(directory, `${pgid}.json`);
238 const entry: OpenCodeServerEntry = {
239 version: 1,
240 pgid,
241 pid: member.pid,
242 startTime: member.startTime,
243 command: member.command,
244 port: server.port,
245 stateDir: input.stateDir,
246 owner,
247 };
248 yield* writeFileStringAtomically({
249 filePath: entryPath,
250 contents: yield* encodeEntry(entry),
251 }).pipe(
252 Effect.provideService(FileSystem.FileSystem, fs),
253 Effect.provideService(Path.Path, path),
254 );
255 return fs
256 .remove(entryPath, { force: true })
257 .pipe(
258 Effect.catchCause((cause) =>
259 Effect.logWarning("Could not forget a stopped OpenCode server", { cause }),
260 ),
261 );
262 }).pipe(
263 Effect.catchCause((cause) =>
264 Effect.logWarning("Could not record an OpenCode server process", { cause }).pipe(
265 Effect.as(Effect.void),
266 ),
267 ),
268 );
269
270 // Signals only a group whose recorded member still has the same pid, start
271 // time, group, and full command line, so a recycled pid is never touched.
272 const stopOrphan = (entry: OpenCodeServerEntry) =>
273 Effect.gen(function* () {
274 const observed = yield* observe(entry.pid);
275 if (
276 observed === undefined ||
277 observed.zombie ||
278 observed.startTime !== entry.startTime ||
279 observed.pgid !== entry.pgid ||
280 observed.command !== entry.command
281 ) {
282 return;
283 }
284 yield* Effect.logInfo("Stopping an OpenCode server left by a previous T3 Code server", {
285 pid: entry.pgid,
286 port: entry.port,
287 });
288 signalGroup(entry.pgid, "SIGTERM");
289 for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && groupExists(entry.pgid); attempt++) {
290 yield* Effect.sleep(STOP_POLL_INTERVAL);
291 }
292 // The group never emptied, so its pgid cannot have been reused.
293 if (groupExists(entry.pgid)) signalGroup(entry.pgid, "SIGKILL");
294 });
295
296 const reapEntry = (entryPath: string) =>
297 Effect.gen(function* () {
298 const entry = decodeEntry(yield* fs.readFileString(entryPath));
299 if (Option.isSome(entry) && entry.value.stateDir === input.stateDir) {
300 if (yield* isRunning(entry.value.owner)) return;
301 yield* stopOrphan(entry.value);
302 }
303 yield* fs.remove(entryPath, { force: true });
304 }).pipe(
305 Effect.catchCause((cause) =>
306 Effect.logWarning("Could not clean up a recorded OpenCode server", { entryPath, cause }),
307 ),
308 );
309
310 /** Stops recorded servers whose owning T3 server is gone and drops stale entries. */
311 const reapOrphans = Effect.gen(function* () {
312 const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
313 yield* Effect.forEach(
314 names.filter((name) => ENTRY_FILE.test(name)),
315 (name) => reapEntry(path.join(directory, name)),
316 { concurrency: "unbounded", discard: true },
317 );
318 });
319
320 return { track, reapOrphans };
321});
322
323/** Tracks servers under `stateDir`, the server's own state directory. */
324export const layer = (input: { readonly stateDir: string }) =>
325 Layer.effect(
326 OpenCodeServerLedger,
327 Effect.gen(function* () {
328 const ledger = yield* make(input);
329 // Reaping waits for orphans to exit, so it must not hold up startup.
330 yield* ledger.reapOrphans.pipe(Effect.forkScoped);
331 return OpenCodeServerLedger.of({ track: ledger.track });
332 }),
333 );
334
335/** Records nothing. For tests that start OpenCode servers without a state directory. */
336export const layerTest = Layer.succeed(
337 OpenCodeServerLedger,
338 OpenCodeServerLedger.of({ track: () => Effect.succeed(Effect.void) }),
339);
packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts created+289
...@@ -0,0 +1,289 @@
1import { it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";
8import { expect } from "vite-plus/test";
9
10import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
11import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
12
13const unusedRuntimeMethod = () =>
14 Effect.fail(
15 new OpenCodeRuntime.OpenCodeRuntimeError({
16 operation: "unused",
17 detail: "unused test method",
18 }),
19 );
20
21const makeRuntime = Effect.gen(function* () {
22 const starts = yield* Ref.make(0);
23 const closes = yield* Ref.make(0);
24 const failNextStart = yield* Ref.make(false);
25 const started = yield* Deferred.make<void>();
26 const closed = yield* Deferred.make<void>();
27 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
28 startOpenCodeServerProcess: () =>
29 Effect.gen(function* () {
30 if (yield* Ref.getAndSet(failNextStart, false)) {
31 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
32 operation: "startOpenCodeServerProcess",
33 detail: "start failed",
34 });
35 }
36 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
37 yield* Deferred.succeed(started, undefined).pipe(Effect.ignore);
38 yield* Effect.addFinalizer(() =>
39 Ref.update(closes, (count) => count + 1).pipe(
40 Effect.andThen(Deferred.succeed(closed, undefined)),
41 Effect.ignore,
42 ),
43 );
44 return {
45 url: `http://127.0.0.1:${index}`,
46 version: "1.14.19",
47 isRunning: Effect.succeed(true),
48 exitCode: Effect.never,
49 };
50 }),
51 connectToOpenCodeServer: unusedRuntimeMethod,
52 runOpenCodeCommand: unusedRuntimeMethod,
53 createOpenCodeSdkClient: () => ({}) as never,
54 loadOpenCodeInventory: unusedRuntimeMethod,
55 loadOpenCodeSkills: unusedRuntimeMethod,
56 loadInventoryFromCli: unusedRuntimeMethod,
57 loadSkillsFromCli: unusedRuntimeMethod,
58 };
59 return { runtime, starts, closes, failNextStart, started, closed };
60});
61
62it.effect("shares concurrent borrowers and closes after the idle TTL", () =>
63 Effect.gen(function* () {
64 const testRuntime = yield* makeRuntime;
65 const release = yield* Deferred.make<void>();
66 yield* Effect.scoped(
67 Effect.gen(function* () {
68 const owner = yield* OpenCodeServerOwner.make({
69 binaryPath: "opencode",
70 directory: "/project",
71 });
72 const useServer = owner.withServer((server) =>
73 Deferred.await(release).pipe(Effect.as(server.url)),
74 );
75 const fibers = yield* Effect.all([useServer, useServer], {
76 concurrency: "unbounded",
77 }).pipe(Effect.forkChild);
78 yield* Deferred.await(testRuntime.started);
79 expect(yield* Ref.get(testRuntime.starts)).toBe(1);
80 yield* Deferred.succeed(release, undefined);
81 expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]);
82 yield* TestClock.adjust(Duration.seconds(31));
83 yield* Deferred.await(testRuntime.closed);
84 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
85 }),
86 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
87 }).pipe(Effect.provide(TestClock.layer())),
88);
89
90it.effect("retries a failed start and closes on owner scope shutdown", () =>
91 Effect.gen(function* () {
92 const testRuntime = yield* makeRuntime;
93 yield* Ref.set(testRuntime.failNextStart, true);
94 yield* Effect.scoped(
95 Effect.gen(function* () {
96 const owner = yield* OpenCodeServerOwner.make({
97 binaryPath: "opencode",
98 directory: "/project",
99 });
100 expect(
101 (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag,
102 ).toBe("Failure");
103 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
104 "http://127.0.0.1:1",
105 );
106 }),
107 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
108 expect(yield* Ref.get(testRuntime.starts)).toBe(1);
109 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
110 }),
111);
112
113it.effect("invalidates an exited process so the next borrower starts a new one", () =>
114 Effect.gen(function* () {
115 const starts = yield* Ref.make(0);
116 const processExits: Array<Deferred.Deferred<number>> = [];
117 const processClosed = yield* Deferred.make<void>();
118 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
119 startOpenCodeServerProcess: () =>
120 Effect.gen(function* () {
121 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
122 const exitCode = yield* Deferred.make<number>();
123 processExits.push(exitCode);
124 yield* Effect.addFinalizer(() =>
125 Deferred.succeed(processClosed, undefined).pipe(Effect.ignore),
126 );
127 return {
128 url: `http://127.0.0.1:${index}`,
129 version: "1.14.19",
130 isRunning: Effect.succeed(true),
131 exitCode: Deferred.await(exitCode),
132 };
133 }),
134 connectToOpenCodeServer: unusedRuntimeMethod,
135 runOpenCodeCommand: unusedRuntimeMethod,
136 createOpenCodeSdkClient: () => ({}) as never,
137 loadOpenCodeInventory: unusedRuntimeMethod,
138 loadOpenCodeSkills: unusedRuntimeMethod,
139 loadInventoryFromCli: unusedRuntimeMethod,
140 loadSkillsFromCli: unusedRuntimeMethod,
141 };
142
143 yield* Effect.scoped(
144 Effect.gen(function* () {
145 const owner = yield* OpenCodeServerOwner.make({
146 binaryPath: "opencode",
147 directory: "/project",
148 });
149 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
150 "http://127.0.0.1:1",
151 );
152 yield* Deferred.succeed(processExits[0]!, 1);
153 yield* Deferred.await(processClosed);
154 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
155 "http://127.0.0.1:2",
156 );
157 }),
158 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
159 expect(yield* Ref.get(starts)).toBe(2);
160 }),
161);
162
163it.effect("replaces a dead cached process before its exit watcher runs", () =>
164 Effect.gen(function* () {
165 const starts = yield* Ref.make(0);
166 const closes = yield* Ref.make(0);
167 const processRunning: Array<Ref.Ref<boolean>> = [];
168 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
169 startOpenCodeServerProcess: () =>
170 Effect.gen(function* () {
171 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
172 const isRunning = yield* Ref.make(true);
173 processRunning.push(isRunning);
174 yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1));
175 return {
176 url: `http://127.0.0.1:${index}`,
177 version: "1.14.19",
178 isRunning: Ref.get(isRunning),
179 exitCode: Effect.never,
180 };
181 }),
182 connectToOpenCodeServer: unusedRuntimeMethod,
183 runOpenCodeCommand: unusedRuntimeMethod,
184 createOpenCodeSdkClient: () => ({}) as never,
185 loadOpenCodeInventory: unusedRuntimeMethod,
186 loadOpenCodeSkills: unusedRuntimeMethod,
187 loadInventoryFromCli: unusedRuntimeMethod,
188 loadSkillsFromCli: unusedRuntimeMethod,
189 };
190
191 yield* Effect.scoped(
192 Effect.gen(function* () {
193 const owner = yield* OpenCodeServerOwner.make({
194 binaryPath: "opencode",
195 directory: "/project",
196 });
197 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
198 "http://127.0.0.1:1",
199 );
200 yield* Ref.set(processRunning[0]!, false);
201
202 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
203 "http://127.0.0.1:2",
204 );
205 expect(yield* Ref.get(starts)).toBe(2);
206 expect(yield* Ref.get(closes)).toBe(1);
207 }),
208 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
209 }),
210);
211
212it.effect("cleans up an interrupted startup and allows a retry", () =>
213 Effect.gen(function* () {
214 const starts = yield* Ref.make(0);
215 const firstStartEntered = yield* Deferred.make<void>();
216 const firstStartClosed = yield* Deferred.make<void>();
217 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
218 startOpenCodeServerProcess: () =>
219 Effect.gen(function* () {
220 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
221 yield* Effect.addFinalizer(() =>
222 index === 1
223 ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore)
224 : Effect.void,
225 );
226 if (index === 1) {
227 yield* Deferred.succeed(firstStartEntered, undefined);
228 return yield* Effect.never;
229 }
230 return {
231 url: `http://127.0.0.1:${index}`,
232 version: "1.14.19",
233 isRunning: Effect.succeed(true),
234 exitCode: Effect.never,
235 };
236 }),
237 connectToOpenCodeServer: unusedRuntimeMethod,
238 runOpenCodeCommand: unusedRuntimeMethod,
239 createOpenCodeSdkClient: () => ({}) as never,
240 loadOpenCodeInventory: unusedRuntimeMethod,
241 loadOpenCodeSkills: unusedRuntimeMethod,
242 loadInventoryFromCli: unusedRuntimeMethod,
243 loadSkillsFromCli: unusedRuntimeMethod,
244 };
245
246 yield* Effect.scoped(
247 Effect.gen(function* () {
248 const owner = yield* OpenCodeServerOwner.make({
249 binaryPath: "opencode",
250 directory: "/project",
251 });
252 const firstBorrower = yield* owner
253 .withServer((server) => Effect.succeed(server.url))
254 .pipe(Effect.forkChild);
255 yield* Deferred.await(firstStartEntered);
256 yield* Fiber.interrupt(firstBorrower);
257 yield* Deferred.await(firstStartClosed);
258 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
259 "http://127.0.0.1:2",
260 );
261 }),
262 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
263 }),
264);
265
266it.effect("releases an interrupted borrower and closes after the idle TTL", () =>
267 Effect.gen(function* () {
268 const testRuntime = yield* makeRuntime;
269 const borrowerEntered = yield* Deferred.make<void>();
270 yield* Effect.scoped(
271 Effect.gen(function* () {
272 const owner = yield* OpenCodeServerOwner.make({
273 binaryPath: "opencode",
274 directory: "/project",
275 });
276 const borrower = yield* owner
277 .withServer(() =>
278 Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)),
279 )
280 .pipe(Effect.forkChild);
281 yield* Deferred.await(borrowerEntered);
282 yield* Fiber.interrupt(borrower);
283 yield* TestClock.adjust(Duration.seconds(31));
284 yield* Deferred.await(testRuntime.closed);
285 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
286 }),
287 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
288 }).pipe(Effect.provide(TestClock.layer())),
289);
packages/provider-opencode/src/server/OpenCodeServerOwner.ts created+188
...@@ -0,0 +1,188 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Scope from "effect/Scope";
7import * as Semaphore from "effect/Semaphore";
8
9import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
10
11const OPENCODE_SERVER_IDLE_TTL = "30 seconds";
12
13interface OpenCodeServerOwnerState {
14 server: OpenCodeRuntime.OpenCodeServerProcess | null;
15 serverScope: Scope.Closeable | null;
16 borrowers: number;
17 idleCloseFiber: Fiber.Fiber<void, never> | null;
18}
19
20export class OpenCodeServerOwner extends Context.Service<
21 OpenCodeServerOwner,
22 {
23 readonly withServer: <A, E, R>(
24 use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect<A, E, R>,
25 ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
26 }
27>()("@t3tools/provider-opencode/server/OpenCodeServerOwner") {}
28
29/** Owns the lazy local OpenCode server shared by one provider instance. */
30export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: {
31 readonly binaryPath: string;
32 readonly directory: string;
33 readonly serverPassword?: string;
34 readonly environment?: NodeJS.ProcessEnv;
35 readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>;
36}) {
37 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
38 const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
39 Scope.close(scope, Exit.void),
40 );
41 const mutex = yield* Semaphore.make(1);
42 const state: OpenCodeServerOwnerState = {
43 server: null,
44 serverScope: null,
45 borrowers: 0,
46 idleCloseFiber: null,
47 };
48
49 const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () {
50 const fiber = state.idleCloseFiber;
51 state.idleCloseFiber = null;
52 if (fiber !== null) {
53 yield* Fiber.interrupt(fiber).pipe(Effect.ignore);
54 }
55 });
56
57 const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* (
58 expected?: OpenCodeRuntime.OpenCodeServerProcess,
59 ) {
60 if (expected !== undefined && state.server !== expected) {
61 return;
62 }
63 const scope = state.serverScope;
64 state.server = null;
65 state.serverScope = null;
66 if (scope !== null) {
67 yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
68 }
69 });
70
71 const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* (
72 server: OpenCodeRuntime.OpenCodeServerProcess,
73 ) {
74 yield* server.exitCode;
75 yield* mutex.withPermit(
76 Effect.gen(function* () {
77 if (state.server !== server) {
78 return;
79 }
80 yield* cancelIdleClose();
81 yield* closeServer(server);
82 }),
83 );
84 });
85
86 const acquireServer = mutex.withPermit(
87 Effect.gen(function* () {
88 yield* cancelIdleClose();
89 if (state.server !== null) {
90 if (yield* state.server.isRunning) {
91 state.borrowers += 1;
92 return state.server;
93 }
94 yield* closeServer(state.server);
95 }
96
97 return yield* Effect.uninterruptibleMask((restore) =>
98 Effect.gen(function* () {
99 const serverScope = yield* Scope.make();
100 const started = yield* Effect.exit(
101 restore(
102 runtime
103 .startOpenCodeServerProcess({
104 binaryPath: input.binaryPath,
105 directory: input.directory,
106 ...(input.serverPassword !== undefined
107 ? { serverPassword: input.serverPassword }
108 : {}),
109 ...(input.environment ? { environment: input.environment } : {}),
110 ...(input.verify ? { verify: input.verify } : {}),
111 })
112 .pipe(Effect.provideService(Scope.Scope, serverScope)),
113 ),
114 );
115 if (Exit.isFailure(started)) {
116 yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
117 return yield* Effect.failCause(started.cause);
118 }
119
120 const server = started.value;
121 state.server = server;
122 state.serverScope = serverScope;
123 state.borrowers = 1;
124 yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope));
125 return server;
126 }),
127 );
128 }),
129 );
130
131 const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) =>
132 mutex.withPermit(
133 Effect.gen(function* () {
134 if (state.server !== server) {
135 return;
136 }
137 state.borrowers = Math.max(0, state.borrowers - 1);
138 if (state.borrowers > 0) {
139 return;
140 }
141 yield* cancelIdleClose();
142 state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe(
143 Effect.andThen(
144 mutex.withPermit(
145 Effect.gen(function* () {
146 if (state.server !== server || state.borrowers > 0) {
147 return;
148 }
149 state.idleCloseFiber = null;
150 yield* closeServer(server);
151 }),
152 ),
153 ),
154 Effect.forkIn(ownerScope),
155 );
156 }),
157 );
158
159 yield* Effect.addFinalizer(() =>
160 mutex.withPermit(
161 Effect.gen(function* () {
162 yield* cancelIdleClose();
163 state.borrowers = 0;
164 yield* closeServer();
165 }),
166 ),
167 );
168
169 return OpenCodeServerOwner.of({
170 withServer: (use) =>
171 Effect.uninterruptibleMask((restore) =>
172 restore(acquireServer).pipe(
173 Effect.flatMap((server) =>
174 restore(use(server)).pipe(Effect.ensuring(releaseServer(server))),
175 ),
176 ),
177 ),
178 });
179});
180
181/** @public Service construction is part of the canonical Effect module API. */
182export const layer = (input: {
183 readonly binaryPath: string;
184 readonly directory: string;
185 readonly serverPassword?: string;
186 readonly environment?: NodeJS.ProcessEnv;
187 readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>;
188}) => Layer.effect(OpenCodeServerOwner, make(input));
packages/provider-opencode/src/server/adapter.test.ts created+2730
...@@ -0,0 +1,2730 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2";
4import {
5 CheckpointId,
6 NodeId,
7 ProjectId,
8 ProviderInstanceId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
12 RunAttemptId,
13 RunId,
14 MessageId,
15 ThreadId,
16 type OrchestrationV2ProviderThread,
17 type OrchestrationV2ProviderTurn,
18} from "@t3tools/contracts";
19import { OpenCodeSettings } from "../settings.ts";
20import * as Effect from "effect/Effect";
21import * as Clock from "effect/Clock";
22import * as DateTime from "effect/DateTime";
23import * as Deferred from "effect/Deferred";
24import * as Fiber from "effect/Fiber";
25import * as Exit from "effect/Exit";
26import * as Queue from "effect/Queue";
27import * as Layer from "effect/Layer";
28import * as Option from "effect/Option";
29import * as Schema from "effect/Schema";
30import * as Scope from "effect/Scope";
31import * as Stream from "effect/Stream";
32import * as TestClock from "effect/testing/TestClock";
33
34import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
35import { layerTestProviderHost } from "@t3tools/provider-testing/host";
36import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
37import type * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
38import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
39
40import {
41 advanceOpenCodePromptAdmission,
42 cancelOpenCodePromptAdmission,
43 openCodeBoundaryAfterProviderTurn,
44 openCodeChildPermissionRules,
45 openCodePermissionRules,
46 openCodePermissionRequestKind,
47 openCodeToolProjectionKind,
48 makeOpenCodeProtocolLogger,
49 makeOpenCodeAdapterV2,
50 OPENCODE_PROVIDER,
51 reconcileOpenCodePromptAdmissionStatus,
52} from "./adapter.ts";
53import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
54
55const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
56const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
57 serverUrl: "http://test.invalid",
58});
59
60function promiseGate<A>() {
61 let resolve!: (value: A) => void;
62 const promise = new Promise<A>((done) => {
63 resolve = done;
64 });
65 return { promise, resolve };
66}
67
68function asyncEventStream() {
69 const values: Array<{ value: unknown; handled: ReturnType<typeof promiseGate<void>> }> = [];
70 const waiters: Array<(value: IteratorResult<unknown>) => void> = [];
71 let previousHandled: ReturnType<typeof promiseGate<void>> | undefined;
72 let closed = false;
73 return {
74 close() {
75 closed = true;
76 for (const waiter of waiters.splice(0)) waiter({ done: true, value: undefined });
77 },
78 push(value: unknown) {
79 const handled = promiseGate<void>();
80 const waiter = waiters.shift();
81 if (waiter) {
82 previousHandled = handled;
83 waiter({ done: false, value });
84 } else values.push({ value, handled });
85 return handled.promise;
86 },
87 stream: {
88 [Symbol.asyncIterator]() {
89 return {
90 next: () => {
91 previousHandled?.resolve();
92 if (closed) return Promise.resolve({ done: true as const, value: undefined });
93 const entry = values.shift();
94 if (entry !== undefined) {
95 previousHandled = entry.handled;
96 return Promise.resolve({ done: false as const, value: entry.value });
97 }
98 return new Promise<IteratorResult<unknown>>((resolve) => waiters.push(resolve));
99 },
100 };
101 },
102 },
103 };
104}
105
106const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({});
107
108function runtimePolicy(
109 runtimeMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["runtimeMode"],
110 override: Partial<ProviderAdapter.ProviderAdapterV2RuntimePolicy> = {},
111): ProviderAdapter.ProviderAdapterV2RuntimePolicy {
112 return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
113 runtimeMode,
114 interactionMode: "default",
115 cwd: null,
116 ...override,
117 });
118}
119
120function permissionAction(rules: ReturnType<typeof openCodePermissionRules>, permission: string) {
121 return rules.findLast((rule) => rule.permission === "*" || rule.permission === permission)
122 ?.action;
123}
124
125function providerTurn(input: {
126 readonly id: string;
127 readonly ordinal: number;
128 readonly nativeId: string | null;
129}): OrchestrationV2ProviderTurn {
130 return {
131 id: ProviderTurnId.make(input.id),
132 providerThreadId: ProviderThreadId.make("provider-thread:opencode-test"),
133 nodeId: NodeId.make(`node:${input.id}`),
134 runAttemptId: null,
135 nativeTurnRef:
136 input.nativeId === null
137 ? null
138 : { driver: OPENCODE_PROVIDER, nativeId: input.nativeId, strength: "weak" },
139 ordinal: input.ordinal,
140 status: "completed",
141 startedAt: null,
142 completedAt: null,
143 };
144}
145
146const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(function* (
147 suffix: string,
148 nativeSessionId: string,
149 client: object,
150) {
151 const idAllocator = yield* IdAllocator.IdAllocatorV2;
152 const instanceId = ProviderInstanceId.make(`opencode-${suffix}`);
153 const threadId = ThreadId.make(`thread-opencode-${suffix}`);
154 const modelSelection = {
155 instanceId,
156 model: "anthropic/claude-sonnet",
157 options: [],
158 };
159 const policy = runtimePolicy("full-access", { cwd: "/workspace" });
160 const adapter = makeOpenCodeAdapterV2({
161 instanceId,
162 settings: OPEN_CODE_TEST_SETTINGS,
163 environment: {},
164 runtime: {
165 connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }),
166 createOpenCodeSdkClient: () => client,
167 } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape,
168 idAllocator,
169 host: {
170 paths: { cwd: "/workspace" },
171 resolveAttachmentPath: () => null,
172 } as unknown as ProviderHost.ProviderHostShape,
173 });
174 const runtime = yield* adapter.openSession({
175 threadId,
176 providerSessionId: ProviderSessionId.make(`session-opencode-${suffix}`),
177 modelSelection,
178 runtimePolicy: policy,
179 });
180 const providerThread = yield* runtime.ensureThread({
181 threadId,
182 modelSelection,
183 runtimePolicy: policy,
184 });
185 const now = yield* DateTime.now;
186 const startTurn = (
187 text = "hello",
188 startProviderThread: OrchestrationV2ProviderThread = providerThread,
189 ) =>
190 runtime.startTurn({
191 appThread: {
192 id: threadId,
193 projectId: ProjectId.make(`project-opencode-${suffix}`),
194 title: suffix,
195 providerInstanceId: modelSelection.instanceId,
196 modelSelection,
197 runtimeMode: "full-access",
198 interactionMode: "default",
199 branch: null,
200 worktreePath: null,
201 activeProviderThreadId: providerThread.id,
202 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
203 forkedFrom: null,
204 createdBy: "user",
205 creationSource: "web",
206 createdAt: now,
207 updatedAt: now,
208 archivedAt: null,
209 settledOverride: null,
210 settledAt: null,
211 lastVisitedAt: null,
212 deletedAt: null,
213 },
214 threadId,
215 runId: RunId.make(`run-opencode-${suffix}`),
216 runOrdinal: 1,
217 providerTurnOrdinal: 1,
218 attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`),
219 rootNodeId: NodeId.make(`node-opencode-${suffix}`),
220 providerThread: startProviderThread,
221 message: {
222 createdBy: "user",
223 creationSource: "web",
224 messageId: MessageId.make(`message-opencode-${suffix}`),
225 text,
226 attachments: [],
227 },
228 modelSelection,
229 runtimePolicy: policy,
230 });
231 return {
232 nativeSessionId,
233 now,
234 policy,
235 providerThread,
236 runId: RunId.make(`run-opencode-${suffix}`),
237 runtime,
238 startTurn,
239 threadId,
240 };
241});
242
243describe("OpenCodeAdapterV2", () => {
244 it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)(
245 "normalizes OpenCode step usage for %s turns",
246 (ending) =>
247 Effect.gen(function* () {
248 const nativeEvents = asyncEventStream();
249 let promptId = "";
250 const harness = yield* makeOpenCodeRuntimeHarness(`usage-${ending}`, "root", {
251 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
252 session: {
253 create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
254 promptAsync: async (input: { messageID: string }) => {
255 promptId = input.messageID;
256 return { data: true };
257 },
258 abort: async () => ({ data: true }),
259 children: async () => ({ data: [] }),
260 },
261 });
262 yield* harness.startTurn();
263 const received = yield* harness.runtime.events.pipe(
264 Stream.takeUntil((event) => event.type === "turn.terminal"),
265 Stream.runCollect,
266 Effect.forkScoped,
267 );
268 yield* Effect.promise(() =>
269 nativeEvents.push({
270 type: "message.updated",
271 properties: {
272 sessionID: "root",
273 info: { id: promptId, role: "user", time: { created: 1 } },
274 },
275 }),
276 );
277 const step = (id: string, messageID = "assistant") => ({
278 type: "message.part.updated",
279 properties: {
280 part: {
281 type: "step-finish",
282 id,
283 sessionID: "root",
284 messageID,
285 reason: "stop",
286 cost: 0.01,
287 tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 3, write: 4 } },
288 },
289 },
290 });
291 if (ending !== "unavailable") {
292 yield* Effect.promise(() => nativeEvents.push(step("one")));
293 yield* Effect.promise(() =>
294 nativeEvents.push({
295 type: "message.updated",
296 properties: {
297 sessionID: "root",
298 info: {
299 id: "assistant",
300 role: "assistant",
301 time: { created: 1 },
302 parentID: promptId,
303 },
304 },
305 }),
306 );
307 yield* Effect.promise(() => nativeEvents.push(step("one")));
308 yield* Effect.promise(() => nativeEvents.push(step("two")));
309 yield* Effect.promise(() =>
310 nativeEvents.push({
311 type: "message.part.removed",
312 properties: { sessionID: "root", messageID: "assistant", partID: "one" },
313 }),
314 );
315 yield* Effect.promise(() =>
316 nativeEvents.push({
317 type: "message.updated",
318 properties: {
319 sessionID: "root",
320 info: {
321 id: "old-assistant",
322 role: "assistant",
323 time: { created: 1 },
324 parentID: "old-prompt",
325 },
326 },
327 }),
328 );
329 yield* Effect.promise(() => nativeEvents.push(step("old", "old-assistant")));
330 }
331 if (ending === "unresolved")
332 yield* Effect.promise(() => nativeEvents.push(step("unknown", "unknown-assistant")));
333 if (ending === "reconnect") {
334 yield* Effect.promise(() =>
335 nativeEvents.push({ type: "server.connected", properties: {} }),
336 );
337 yield* Effect.promise(() =>
338 nativeEvents.push({ type: "server.connected", properties: {} }),
339 );
340 }
341 if (ending === "failed") {
342 yield* Effect.promise(() =>
343 nativeEvents.push({
344 type: "session.error",
345 properties: {
346 sessionID: "root",
347 error: { name: "UnknownError", data: { message: "failed" } },
348 },
349 }),
350 );
351 } else {
352 yield* Effect.promise(() =>
353 nativeEvents.push({
354 type: "session.status",
355 properties: { sessionID: "root", status: { type: "busy" } },
356 }),
357 );
358 yield* Effect.promise(() =>
359 nativeEvents.push({
360 type: "session.status",
361 properties: { sessionID: "root", status: { type: "idle" } },
362 }),
363 );
364 }
365 const events = yield* Fiber.join(received);
366 const completed = events.findLast((event) => event.type === "provider_turn.updated");
367 assert.deepEqual(
368 completed?.providerTurn.turnTokenUsage,
369 ending === "unavailable"
370 ? { usageStatus: "unavailable", usageScope: "main_agent", hasSubagents: false }
371 : {
372 usageStatus: ending === "completed" ? "complete" : "partial",
373 usageScope: "main_agent",
374 inputTokens: 34,
375 cachedInputTokens: 6,
376 cacheCreationTokens: 8,
377 outputTokens: 14,
378 reasoningTokens: 4,
379 hasSubagents: false,
380 },
381 );
382 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
383 );
384
385 it.effect.each(["permission", "question"] as const)(
386 "cancels an undelivered %s reply at its deadline",
387 (kind) =>
388 Effect.gen(function* () {
389 const nativeEvents = asyncEventStream();
390 const called = promiseGate<void>();
391 let signal: AbortSignal | undefined;
392 let deliver = false;
393 const harness = yield* makeOpenCodeRuntimeHarness(`reply-${kind}`, "root", {
394 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
395 session: {
396 create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
397 promptAsync: async () => ({ data: true }),
398 abort: async () => ({ data: true }),
399 children: async () => ({ data: [] }),
400 },
401 [kind]: {
402 reply: async (_input: unknown, options: { signal: AbortSignal }) => {
403 if (deliver) return { data: true };
404 signal = options.signal;
405 called.resolve();
406 return new Promise(() => {});
407 },
408 },
409 });
410 yield* harness.startTurn();
411 const received = yield* harness.runtime.events.pipe(
412 Stream.filter((event) => event.type === "runtime_request.updated"),
413 Stream.take(1),
414 Stream.runCollect,
415 Effect.forkScoped,
416 );
417 yield* Effect.promise(() =>
418 nativeEvents.push({
419 type: `${kind}.asked`,
420 properties:
421 kind === "permission"
422 ? {
423 id: "request",
424 sessionID: "root",
425 permission: "bash",
426 patterns: ["*"],
427 always: [],
428 metadata: {},
429 }
430 : {
431 id: "request",
432 sessionID: "root",
433 questions: [
434 {
435 header: "Choice",
436 question: "Which?",
437 options: [{ label: "Yes", description: "Proceed" }],
438 },
439 ],
440 },
441 }),
442 );
443 const request = (yield* Fiber.join(received))[0]!.runtimeRequest;
444 const response = {
445 requestId: request.id,
446 ...(kind === "permission"
447 ? { decision: "accept" as const }
448 : { answers: { Choice: "Yes" } }),
449 };
450 const reply = yield* harness.runtime
451 .respondToRuntimeRequest(response)
452 .pipe(Effect.exit, Effect.forkScoped);
453 yield* Effect.promise(() => called.promise);
454 yield* TestClock.adjust("10 seconds");
455 assert.isTrue(Exit.isFailure(yield* Fiber.join(reply)));
456 assert.isTrue(signal?.aborted);
457 deliver = true;
458 // Failed delivery leaves the request available for an explicit retry.
459 yield* harness.runtime.respondToRuntimeRequest(response);
460 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
461 );
462
463 it.effect("aborts external root and descendants before closing the event stream", () =>
464 Effect.gen(function* () {
465 const scope = yield* Scope.make();
466 const nativeEvents = asyncEventStream();
467 const calls: string[] = [];
468 yield* makeOpenCodeRuntimeHarness("release", "root", {
469 event: {
470 subscribe: async (_input: unknown, options: { signal: AbortSignal }) => {
471 options.signal.addEventListener("abort", () => {
472 calls.push("stream.close");
473 nativeEvents.close();
474 });
475 return { stream: nativeEvents.stream };
476 },
477 },
478 session: {
479 create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
480 abort: async ({ sessionID }: { sessionID: string }) => {
481 calls.push(`abort:${sessionID}`);
482 return { data: true };
483 },
484 children: async ({ sessionID }: { sessionID: string }) => {
485 calls.push(`children:${sessionID}`);
486 return { data: sessionID === "root" ? [{ id: "child" }] : [] };
487 },
488 },
489 }).pipe(Effect.provideService(Scope.Scope, scope));
490 yield* Scope.close(scope, Exit.void);
491 assert.deepEqual(calls, [
492 "abort:root",
493 "children:root",
494 "abort:child",
495 "children:child",
496 "stream.close",
497 ]);
498 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
499 );
500
501 it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)(
502 "reports descendant cleanup %s",
503 (failure) =>
504 Effect.gen(function* () {
505 const nativeEvents = asyncEventStream();
506 const called = promiseGate<void>();
507 let childSignal: AbortSignal | undefined;
508 const harness = yield* makeOpenCodeRuntimeHarness(`cleanup-${failure}`, "root", {
509 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
510 session: {
511 create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
512 promptAsync: async () => ({ data: true }),
513 get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }),
514 messages: async () => ({ data: [] }),
515 children: async ({ sessionID }: { sessionID: string }) => {
516 if (failure === "enumeration") throw new Error("cannot enumerate");
517 return { data: sessionID === "root" ? [{ id: "child" }] : [] };
518 },
519 abort: async (
520 { sessionID }: { sessionID: string },
521 options: { signal: AbortSignal },
522 ) => {
523 if (sessionID === "root") return { data: true };
524 if (failure === "timeout" && childSignal?.aborted) return { data: true };
525 childSignal = options.signal;
526 called.resolve();
527 if (failure === "timeout") {
528 return new Promise(() => {});
529 }
530 if (failure === "not-found") throw { status: 404 };
531 throw new Error("child abort failed");
532 },
533 },
534 });
535 yield* harness.startTurn();
536 const snapshot = yield* harness.runtime.readThreadSnapshot({
537 providerThread: harness.providerThread,
538 });
539 const stop = yield* harness.runtime
540 .interruptTurn({
541 providerThread: harness.providerThread,
542 providerTurnId: snapshot.providerTurns.at(-1)!.id,
543 })
544 .pipe(Effect.exit, Effect.forkScoped);
545 if (failure === "timeout") {
546 yield* Effect.promise(() => called.promise);
547 yield* TestClock.adjust("15 seconds");
548 }
549 const result = yield* Fiber.join(stop);
550 assert.equal(Exit.isSuccess(result), failure === "not-found");
551 if (failure === "timeout") assert.isTrue(childSignal?.aborted);
552 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
553 );
554
555 it.effect(
556 "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads",
557 () =>
558 Effect.gen(function* () {
559 const nativeEvents = asyncEventStream();
560 const nativeSessionId = "native-opencode-tool-lifecycle";
561 const harness = yield* makeOpenCodeRuntimeHarness("tool-lifecycle", nativeSessionId, {
562 event: {
563 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
564 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
565 return { stream: nativeEvents.stream };
566 },
567 },
568 session: {
569 create: async () => ({
570 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
571 }),
572 promptAsync: async () => ({ data: true }),
573 },
574 });
575 yield* harness.startTurn();
576 const received = yield* harness.runtime.events.pipe(
577 Stream.takeUntil(
578 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
579 ),
580 Stream.runCollect,
581 Effect.forkScoped,
582 );
583 const states = [
584 { status: "pending", input: { command: "pwd" }, raw: "" },
585 {
586 status: "running",
587 input: { command: "pwd" },
588 title: "Working directory",
589 time: { start: 1 },
590 },
591 {
592 status: "completed",
593 input: { command: "pwd" },
594 output: "/repo\n",
595 title: "Working directory",
596 metadata: {},
597 time: { start: 1, end: 2 },
598 },
599 {
600 status: "error",
601 input: { command: "pwd" },
602 error: "Command failed",
603 time: { start: 3, end: 4 },
604 },
605 ] satisfies ReadonlyArray<ToolPart["state"]>;
606 for (const state of states) {
607 yield* Effect.promise(() =>
608 nativeEvents.push({
609 type: "message.part.updated",
610 properties: {
611 sessionID: nativeSessionId,
612 part: {
613 id: state.status === "error" ? "part-failed" : "part-working",
614 sessionID: nativeSessionId,
615 messageID: "late-assistant",
616 type: "tool",
617 callID: state.status === "error" ? "call-failed" : "call-working",
618 tool: "bash",
619 state,
620 },
621 },
622 }),
623 );
624 }
625 yield* Effect.promise(() =>
626 nativeEvents.push({
627 type: "message.part.updated",
628 properties: {
629 sessionID: nativeSessionId,
630 part: {
631 id: "part-read",
632 sessionID: nativeSessionId,
633 messageID: "late-assistant",
634 type: "tool",
635 callID: "call-read",
636 tool: "read",
637 state: { status: "pending", input: { filePath: "/outside/file" }, raw: "" },
638 },
639 },
640 }),
641 );
642 yield* Effect.promise(() =>
643 nativeEvents.push({
644 type: "permission.asked",
645 properties: {
646 id: "read-permission",
647 sessionID: nativeSessionId,
648 permission: "external_directory",
649 patterns: ["/outside/*"],
650 always: [],
651 metadata: {},
652 tool: { messageID: "late-assistant", callID: "call-read" },
653 },
654 }),
655 );
656 yield* Effect.promise(() =>
657 nativeEvents.push({
658 type: "message.part.updated",
659 properties: {
660 sessionID: nativeSessionId,
661 part: {
662 id: "part-text",
663 sessionID: nativeSessionId,
664 messageID: "late-assistant",
665 type: "text",
666 text: "Tool results received",
667 time: { start: 4 },
668 },
669 },
670 }),
671 );
672 yield* Effect.promise(() =>
673 nativeEvents.push({
674 type: "message.updated",
675 properties: {
676 sessionID: nativeSessionId,
677 info: {
678 id: "late-assistant",
679 sessionID: nativeSessionId,
680 role: "assistant",
681 time: { created: 1, completed: 4 },
682 },
683 },
684 }),
685 );
686 yield* Effect.promise(() =>
687 nativeEvents.push({
688 type: "session.compacted",
689 properties: { sessionID: nativeSessionId },
690 }),
691 );
692 const events = yield* Fiber.join(received);
693 const items = events.flatMap((event) =>
694 event.type === "turn_item.updated" ? [event.turnItem] : [],
695 );
696 const commands = items.filter((item) => item.type === "command_execution");
697 assert.deepEqual(
698 commands.map((item) => item.status),
699 ["pending", "running", "completed", "failed"],
700 );
701 assert.deepEqual(
702 commands.map((item) => item.input),
703 ["pwd", "pwd", "pwd", "pwd"],
704 );
705 assert.equal(commands[2]?.output, "/repo\n");
706 assert.equal(commands[3]?.output, "Command failed");
707 assert.isTrue(
708 events.some(
709 (event) =>
710 event.type === "runtime_request.updated" && event.runtimeRequest.kind === "file-read",
711 ),
712 );
713 const assistant = items.filter((item) => item.type === "assistant_message");
714 assert.equal(assistant.at(-1)?.text, "Tool results received");
715 assert.equal(assistant.at(-1)?.status, "completed");
716 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
717 );
718
719 // Event order from a live OpenCode 1.18.32 run of a `task` call with
720 // background=true: the task part completes at launch, the root session
721 // settles, and the child session stays busy until its own work ends.
722 it.effect("reports a background task child as pending work after the root turn settles", () =>
723 Effect.gen(function* () {
724 const nativeEvents = asyncEventStream();
725 const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event));
726 const root = "ses_root";
727 const child = "ses_child";
728 let promptId = "";
729 const harness = yield* makeOpenCodeRuntimeHarness("background-child", root, {
730 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
731 session: {
732 create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }),
733 get: async () => ({
734 data: { id: child, parentID: root, permission: [], time: { created: 2, updated: 2 } },
735 }),
736 update: async () => ({ data: { id: child, parentID: root } }),
737 promptAsync: async (input: { messageID: string }) => {
738 promptId = input.messageID;
739 return { data: true };
740 },
741 abort: async () => ({ data: true }),
742 children: async () => ({ data: [] }),
743 },
744 });
745 const hasPendingBackgroundWork = harness.runtime.hasPendingBackgroundWork;
746 if (hasPendingBackgroundWork === undefined) {
747 return yield* Effect.die("OpenCode runtime must expose hasPendingBackgroundWork.");
748 }
749 yield* harness.startTurn();
750 const terminal = yield* harness.runtime.events.pipe(
751 Stream.filter((event) => event.type === "turn.terminal"),
752 Stream.runHead,
753 Effect.forkScoped,
754 );
755 const taskPart = (status: "running" | "completed") => ({
756 type: "message.part.updated",
757 properties: {
758 sessionID: root,
759 part: {
760 id: "prt_task",
761 sessionID: root,
762 messageID: "msg_root_assistant",
763 type: "tool",
764 tool: "task",
765 callID: "call_task",
766 state: {
767 status,
768 input: {
769 description: "Background sleep task",
770 prompt: "sleep",
771 subagent_type: "general",
772 },
773 title: "Background sleep task",
774 metadata: { parentSessionId: root, sessionId: child, background: true },
775 time: { start: 3, ...(status === "completed" ? { end: 3 } : {}) },
776 ...(status === "completed" ? { output: "Background task started" } : {}),
777 },
778 },
779 },
780 });
781 const status = (sessionID: string, type: "busy" | "idle") => ({
782 type: "session.status",
783 properties: { sessionID, status: { type } },
784 });
785
786 yield* push({
787 type: "message.updated",
788 properties: {
789 sessionID: root,
790 info: { id: promptId, sessionID: root, role: "user", time: { created: 1 } },
791 },
792 });
793 yield* push(status(root, "busy"));
794 yield* push(taskPart("running"));
795 yield* push({
796 type: "session.created",
797 properties: {
798 sessionID: child,
799 info: { id: child, parentID: root, time: { created: 2, updated: 2 } },
800 },
801 });
802 yield* push(status(child, "busy"));
803 yield* push(taskPart("completed"));
804 yield* push(status(root, "idle"));
805 assert.equal(Option.getOrUndefined(yield* Fiber.join(terminal))?.status, "completed");
806 assert.isTrue(yield* hasPendingBackgroundWork, "the running child must pin idle release");
807
808 yield* push(status(child, "idle"));
809 yield* push({ type: "session.idle", properties: { sessionID: child } });
810 assert.isFalse(yield* hasPendingBackgroundWork, "an idle child must not pin idle release");
811 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
812 );
813
814 it.effect("titles OpenCode reads and searches from their input", () =>
815 Effect.gen(function* () {
816 const nativeEvents = asyncEventStream();
817 const nativeSessionId = "native-opencode-search";
818 const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, {
819 event: {
820 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
821 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
822 return { stream: nativeEvents.stream };
823 },
824 },
825 session: {
826 create: async () => ({
827 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
828 }),
829 promptAsync: async () => ({ data: true }),
830 },
831 });
832 yield* harness.startTurn();
833 const received = yield* harness.runtime.events.pipe(
834 Stream.takeUntil(
835 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
836 ),
837 Stream.runCollect,
838 Effect.forkScoped,
839 );
840 for (const [tool, input, output] of [
841 ["read", { filePath: "src/env.ts" }, "---\nfile body"],
842 ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"],
843 ["websearch", { query: "OpenCode documentation" }, "---\nfile body"],
844 ["glob", { pattern: "missing", path: "apps/web" }, ""],
845 ["codesearch", {}, " \n\t"],
846 ] as const) {
847 yield* Effect.promise(() =>
848 nativeEvents.push({
849 type: "message.part.updated",
850 properties: {
851 sessionID: nativeSessionId,
852 part: {
853 id: `part-${tool}`,
854 sessionID: nativeSessionId,
855 messageID: "assistant-search",
856 type: "tool",
857 callID: `call-${tool}`,
858 tool,
859 state: {
860 status: "completed",
861 input,
862 output,
863 title: tool,
864 metadata: {},
865 time: { start: 1, end: 2 },
866 },
867 },
868 },
869 }),
870 );
871 }
872 yield* Effect.promise(() =>
873 nativeEvents.push({
874 type: "session.compacted",
875 properties: { sessionID: nativeSessionId },
876 }),
877 );
878 const items = (yield* Fiber.join(received)).flatMap((event) =>
879 event.type === "turn_item.updated" ? [event.turnItem] : [],
880 );
881 const read = items.find((item) => item.type === "dynamic_tool");
882 assert.equal(read?.title, "Read src/env.ts");
883 const grep = items.find((item) => item.type === "file_search");
884 assert.equal(grep?.title, "Searched TODO in web");
885 assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO");
886 assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [
887 { fileName: "apps/web", preview: "---\nfile body" },
888 ]);
889 const webSearch = items.find((item) => item.type === "web_search");
890 assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [
891 "OpenCode documentation",
892 ]);
893 assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [
894 { snippet: "---\nfile body" },
895 ]);
896 const emptyFileSearch = items.find(
897 (item) => item.type === "file_search" && item.pattern === "missing",
898 );
899 assert.ok(emptyFileSearch?.type === "file_search");
900 assert.equal(emptyFileSearch.results, undefined);
901 const emptyWebSearch = items.find(
902 (item) => item.type === "web_search" && item.patterns === undefined,
903 );
904 assert.ok(emptyWebSearch?.type === "web_search");
905 assert.equal(emptyWebSearch.results, undefined);
906 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
907 );
908
909 it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () =>
910 Effect.gen(function* () {
911 const nativeEvents = asyncEventStream();
912 const nativeSessionId = "native-opencode-mcp";
913 let statusReads = 0;
914 const harness = yield* makeOpenCodeRuntimeHarness("mcp-presentation", nativeSessionId, {
915 event: {
916 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
917 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
918 return { stream: nativeEvents.stream };
919 },
920 },
921 session: {
922 create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }),
923 promptAsync: async () => ({ data: true }),
924 },
925 mcp: {
926 status: async () => {
927 statusReads++;
928 return {
929 data: {
930 "my.server_with_underscores": { status: "connected" },
931 ambiguous: { status: "connected" },
932 ambiguous_server: { status: "connected" },
933 code: { status: "connected" },
934 apply: { status: "connected" },
935 },
936 };
937 },
938 },
939 });
940 yield* harness.startTurn();
941 const received = yield* harness.runtime.events.pipe(
942 Stream.takeUntil(
943 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
944 ),
945 Stream.runCollect,
946 Effect.forkScoped,
947 );
948 for (const status of ["running", "completed", "error"] as const) {
949 yield* Effect.promise(() =>
950 nativeEvents.push({
951 type: "message.part.updated",
952 properties: {
953 sessionID: nativeSessionId,
954 part: {
955 id: "mcp-part",
956 sessionID: nativeSessionId,
957 messageID: "mcp-assistant",
958 type: "tool",
959 callID: "mcp-call",
960 tool: "my_server_with_underscores_edit_document",
961 state: {
962 status,
963 input: { document: "remote-doc" },
964 title: "Edit remote document",
965 metadata: {},
966 output: "saved",
967 error: "failed",
968 time: { start: 1, end: 2 },
969 },
970 },
971 },
972 }),
973 );
974 }
975 yield* Effect.promise(() =>
976 nativeEvents.push({
977 type: "message.part.updated",
978 properties: {
979 sessionID: nativeSessionId,
980 part: {
981 id: "ambiguous-part",
982 sessionID: nativeSessionId,
983 messageID: "mcp-assistant",
984 type: "tool",
985 callID: "ambiguous-call",
986 tool: "ambiguous_server_edit_document",
987 state: {
988 status: "completed",
989 input: {},
990 title: "Ambiguous tool",
991 metadata: {},
992 output: "",
993 time: { start: 1, end: 2 },
994 },
995 },
996 },
997 }),
998 );
999 for (const tool of ["code_search", "apply_patch"]) {
1000 yield* Effect.promise(() =>
1001 nativeEvents.push({
1002 type: "message.part.updated",
1003 properties: {
1004 sessionID: nativeSessionId,
1005 part: {
1006 id: `native-${tool}`,
1007 sessionID: nativeSessionId,
1008 messageID: "mcp-assistant",
1009 type: "tool",
1010 callID: `native-${tool}`,
1011 tool,
1012 state: {
1013 status: "completed",
1014 input: { query: "needle", filePath: "local.ts" },
1015 title: `Native ${tool}`,
1016 metadata: {},
1017 output: "done",
1018 time: { start: 1, end: 2 },
1019 },
1020 },
1021 },
1022 }),
1023 );
1024 }
1025 yield* Effect.promise(() =>
1026 nativeEvents.push({
1027 type: "session.compacted",
1028 properties: { sessionID: nativeSessionId },
1029 }),
1030 );
1031 const allItems = (yield* Fiber.join(received)).flatMap((event) =>
1032 event.type === "turn_item.updated" ? [event.turnItem] : [],
1033 );
1034 assert.equal(
1035 allItems.find((item) => item.title === "Native code_search")?.type,
1036 "web_search",
1037 );
1038 assert.equal(
1039 allItems.find((item) => item.title === "Native apply_patch")?.type,
1040 "file_change",
1041 );
1042 const items = allItems.filter((item) => item.type === "dynamic_tool");
1043 assert.deepEqual(
1044 items.slice(0, 3).map((item) => item.status),
1045 ["running", "completed", "failed"],
1046 );
1047 assert.deepEqual(
1048 items.slice(0, 3).map((item) => item.title),
1049 ["Edit remote document", "Edit remote document", "edit document"],
1050 );
1051 for (const item of items.slice(0, 3)) {
1052 assert.deepEqual(item.toolSource, {
1053 key: "mcp:my.server_with_underscores",
1054 name: "my.server with underscores",
1055 kind: "integration",
1056 });
1057 assert.deepEqual(item.input, { document: "remote-doc" });
1058 }
1059 assert.equal(items[3]?.toolName, "ambiguous_server_edit_document");
1060 assert.isUndefined(items[3]?.toolSource);
1061 assert.equal(statusReads, 1);
1062 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1063 );
1064
1065 it.effect.each(["failure", "timeout"] as const)("retries MCP status after %s", (failure) =>
1066 Effect.gen(function* () {
1067 const nativeEvents = asyncEventStream();
1068 const called = promiseGate<void>();
1069 const sessionId = `mcp-status-${failure}`;
1070 let statusReads = 0;
1071 let signal: AbortSignal | undefined;
1072 const harness = yield* makeOpenCodeRuntimeHarness(sessionId, sessionId, {
1073 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
1074 session: {
1075 create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }),
1076 promptAsync: async () => ({ data: true }),
1077 },
1078 mcp: {
1079 status: async (_input: unknown, options: { signal?: AbortSignal }) => {
1080 statusReads++;
1081 if (statusReads === 1) {
1082 signal = options?.signal;
1083 called.resolve();
1084 if (failure === "timeout") return new Promise(() => {});
1085 throw new Error("MCP status unavailable");
1086 }
1087 return { data: { weather: { status: "connected" } } };
1088 },
1089 },
1090 });
1091 yield* harness.startTurn();
1092 const received = yield* harness.runtime.events.pipe(
1093 Stream.takeUntil(
1094 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
1095 ),
1096 Stream.runCollect,
1097 Effect.forkScoped,
1098 );
1099 for (const status of ["running", "completed"] as const) {
1100 const emitted = yield* Effect.promise(() =>
1101 nativeEvents.push({
1102 type: "message.part.updated",
1103 properties: {
1104 sessionID: sessionId,
1105 part: {
1106 id: "weather-part",
1107 sessionID: sessionId,
1108 messageID: "weather-message",
1109 type: "tool",
1110 callID: "weather-call",
1111 tool: "weather_edit_document",
1112 state: {
1113 status,
1114 input: { document: "remote-doc" },
1115 title: "Edit remote document",
1116 metadata: {},
1117 output: "saved",
1118 time: { start: 1, end: 2 },
1119 },
1120 },
1121 },
1122 }),
1123 ).pipe(Effect.forkScoped);
1124 if (status === "running" && failure === "timeout") {
1125 yield* Effect.promise(() => called.promise);
1126 yield* TestClock.adjust("1 second");
1127 }
1128 yield* Fiber.join(emitted);
1129 }
1130 yield* Effect.promise(() =>
1131 nativeEvents.push({ type: "session.compacted", properties: { sessionID: sessionId } }),
1132 );
1133 const completed = (yield* Fiber.join(received)).find(
1134 (event) =>
1135 event.type === "turn_item.updated" &&
1136 event.turnItem.type === "dynamic_tool" &&
1137 event.turnItem.status === "completed",
1138 );
1139 assert.equal(statusReads, 2);
1140 assert.equal(
1141 completed?.type === "turn_item.updated" && completed.turnItem.title,
1142 "Edit remote document",
1143 );
1144 assert.deepEqual(completed?.type === "turn_item.updated" && completed.turnItem.toolSource, {
1145 key: "mcp:weather",
1146 name: "weather",
1147 kind: "integration",
1148 });
1149 if (failure === "timeout") assert.isTrue(signal?.aborted);
1150 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1151 );
1152
1153 it.effect("admits a native command on its user receipt before generation completes", () =>
1154 Effect.gen(function* () {
1155 const nativeEvents = asyncEventStream();
1156 const release = promiseGate<void>();
1157 const calls = yield* Queue.unbounded<{
1158 messageID: string;
1159 command: string;
1160 arguments: string;
1161 model: string;
1162 }>();
1163 let promptCalls = 0;
1164 const sessionId = "native-command";
1165 const harness = yield* makeOpenCodeRuntimeHarness("native-command", sessionId, {
1166 event: {
1167 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1168 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1169 return { stream: nativeEvents.stream };
1170 },
1171 },
1172 command: { list: async () => ({ data: [{ name: "review" }] }) },
1173 session: {
1174 create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }),
1175 command: async (input: {
1176 messageID: string;
1177 command: string;
1178 arguments: string;
1179 model: string;
1180 }) => {
1181 Queue.offerUnsafe(calls, input);
1182 await release.promise;
1183 return { data: true };
1184 },
1185 promptAsync: async () => {
1186 promptCalls++;
1187 return { data: true };
1188 },
1189 },
1190 });
1191 const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped);
1192 const call = yield* Queue.take(calls);
1193 assert.equal(call.command, "review");
1194 assert.equal(call.arguments, "staged changes");
1195 assert.equal(call.model, "anthropic/claude-sonnet");
1196 yield* Effect.promise(() =>
1197 nativeEvents.push({
1198 type: "message.updated",
1199 properties: {
1200 sessionID: sessionId,
1201 info: {
1202 id: call.messageID,
1203 sessionID: sessionId,
1204 role: "user",
1205 time: { created: DateTime.toEpochMillis(harness.now) },
1206 },
1207 },
1208 }),
1209 );
1210 yield* Fiber.join(start);
1211 assert.equal(promptCalls, 0);
1212 release.resolve();
1213 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1214 );
1215
1216 it.effect("sends unadvertised slash commands as ordinary prompts", () =>
1217 Effect.gen(function* () {
1218 const nativeEvents = asyncEventStream();
1219 const prompts: unknown[] = [];
1220 const harness = yield* makeOpenCodeRuntimeHarness("unknown-command", "unknown-command", {
1221 event: {
1222 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1223 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1224 return { stream: nativeEvents.stream };
1225 },
1226 },
1227 command: { list: async () => ({ data: [{ name: "review" }] }) },
1228 session: {
1229 create: async () => ({
1230 data: { id: "unknown-command", time: { created: 1, updated: 1 } },
1231 }),
1232 promptAsync: async (input: { parts: unknown }) => {
1233 prompts.push(input.parts);
1234 return { data: true };
1235 },
1236 },
1237 });
1238 yield* harness.startTurn("/unknown words");
1239 assert.deepEqual(prompts, [[{ type: "text", text: "/unknown words" }]]);
1240 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1241 );
1242
1243 it.effect("compacts with the native summarize API and emits a completed compaction", () =>
1244 Effect.gen(function* () {
1245 const nativeEvents = asyncEventStream();
1246 const summarizeCalls: Array<unknown> = [];
1247 let promptCalls = 0;
1248 const harness = yield* makeOpenCodeRuntimeHarness("compact", "native-opencode-compact", {
1249 event: {
1250 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1251 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1252 return { stream: nativeEvents.stream };
1253 },
1254 },
1255 session: {
1256 create: async () => ({
1257 data: { id: "native-opencode-compact", time: { created: 1, updated: 1 } },
1258 }),
1259 summarize: async (input: unknown) => {
1260 summarizeCalls.push(input);
1261 return { data: true };
1262 },
1263 promptAsync: async () => {
1264 promptCalls += 1;
1265 return { data: true };
1266 },
1267 },
1268 });
1269 yield* harness.startTurn("/compact");
1270 const events = yield* harness.runtime.events.pipe(
1271 Stream.takeUntil((event) => event.type === "turn.terminal"),
1272 Stream.runCollect,
1273 );
1274 assert.deepEqual(summarizeCalls, [
1275 {
1276 sessionID: "native-opencode-compact",
1277 providerID: "anthropic",
1278 modelID: "claude-sonnet",
1279 auto: false,
1280 },
1281 ]);
1282 assert.equal(promptCalls, 0);
1283 assert.equal(
1284 events.filter(
1285 (event) =>
1286 event.type === "turn_item.updated" &&
1287 event.turnItem.type === "compaction" &&
1288 event.turnItem.status === "completed",
1289 ).length,
1290 1,
1291 );
1292 assert.isTrue(
1293 events.some((event) => event.type === "turn.terminal" && event.status === "completed"),
1294 );
1295 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1296 );
1297
1298 it.effect("ends a turn on the provider thread it started on", () =>
1299 Effect.gen(function* () {
1300 const nativeEvents = asyncEventStream();
1301 const harness = yield* makeOpenCodeRuntimeHarness(
1302 "forked-row",
1303 "native-opencode-forked-row",
1304 {
1305 event: {
1306 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1307 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1308 return { stream: nativeEvents.stream };
1309 },
1310 },
1311 session: {
1312 create: async () => ({
1313 data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } },
1314 }),
1315 summarize: async () => ({ data: true }),
1316 },
1317 },
1318 );
1319 // A forked run starts on its own row for the same native session, while
1320 // the adapter tracks the session under the id it minted.
1321 const forkedRow = {
1322 ...harness.providerThread,
1323 id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"),
1324 };
1325 yield* harness.startTurn("/compact", forkedRow);
1326 const terminal = yield* harness.runtime.events.pipe(
1327 Stream.filter(
1328 (event): event is Extract<typeof event, { type: "turn.terminal" }> =>
1329 event.type === "turn.terminal",
1330 ),
1331 Stream.runHead,
1332 );
1333 assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id);
1334 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1335 );
1336
1337 it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () =>
1338 Effect.gen(function* () {
1339 const nativeEvents = asyncEventStream();
1340 const nativeSessionId = "native-opencode-reverted-snapshot";
1341 const messages = [
1342 {
1343 info: {
1344 id: "user-kept",
1345 sessionID: nativeSessionId,
1346 role: "user",
1347 time: { created: 1 },
1348 },
1349 parts: [{ type: "text", text: "keep this prompt" }],
1350 },
1351 {
1352 info: {
1353 id: "assistant-kept",
1354 sessionID: nativeSessionId,
1355 role: "assistant",
1356 time: { created: 2, completed: 3 },
1357 },
1358 parts: [{ type: "text", text: "keep this answer" }],
1359 },
1360 {
1361 info: {
1362 id: "user-reverted",
1363 sessionID: nativeSessionId,
1364 role: "user",
1365 time: { created: 4 },
1366 },
1367 parts: [{ type: "text", text: "remove this prompt" }],
1368 },
1369 {
1370 info: {
1371 id: "assistant-reverted",
1372 sessionID: nativeSessionId,
1373 role: "assistant",
1374 time: { created: 5, completed: 6 },
1375 },
1376 parts: [{ type: "text", text: "remove this answer" }],
1377 },
1378 ];
1379 const harness = yield* makeOpenCodeRuntimeHarness("reverted-snapshot", nativeSessionId, {
1380 event: {
1381 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1382 options.signal?.addEventListener("abort", () => nativeEvents.close(), {
1383 once: true,
1384 });
1385 return { stream: nativeEvents.stream };
1386 },
1387 },
1388 session: {
1389 create: async () => ({
1390 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
1391 }),
1392 get: async () => ({
1393 data: {
1394 id: nativeSessionId,
1395 time: { created: 1, updated: 6 },
1396 revert: { messageID: "user-reverted" },
1397 },
1398 }),
1399 messages: async () => ({ data: messages }),
1400 },
1401 });
1402
1403 const snapshot = yield* harness.runtime.readThreadSnapshot({
1404 providerThread: harness.providerThread,
1405 });
1406
1407 assert.deepEqual(
1408 snapshot.messages.map((message) => message.text),
1409 ["keep this prompt", "keep this answer"],
1410 );
1411 const providerPayload = snapshot.providerPayload as ReadonlyArray<{
1412 readonly info: { readonly id: string };
1413 }>;
1414 assert.deepEqual(
1415 providerPayload.map((entry) => entry.info.id),
1416 ["user-kept", "assistant-kept"],
1417 );
1418 assert.equal(snapshot.providerThread.nativeConversationHeadRef?.nativeId, "user-kept");
1419 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1420 );
1421
1422 it.effect("keeps a newly admitted prompt alive across stale idle and delayed busy evidence", () =>
1423 Effect.gen(function* () {
1424 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1425 const nativeEvents = asyncEventStream();
1426 const prompt = promiseGate<void>();
1427 const promptStarted = promiseGate<void>();
1428 const steerPrompt = promiseGate<void>();
1429 const steerPromptStarted = promiseGate<void>();
1430 const stopPromptStarted = promiseGate<void>();
1431 const statusCalled = promiseGate<void>();
1432 const steerStatusCalled = promiseGate<void>();
1433 const abortCalled = promiseGate<void>();
1434 let status: "idle" | "busy" = "busy";
1435 let promptCalls = 0;
1436 let statusCalls = 0;
1437 const promptInputs: Array<{ readonly messageID?: string }> = [];
1438 const client = {
1439 event: {
1440 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1441 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1442 return { stream: nativeEvents.stream };
1443 },
1444 },
1445 session: {
1446 create: async () => ({
1447 data: { id: "native-opencode-race", time: { created: 1, updated: 1 } },
1448 }),
1449 get: async () => ({
1450 data: { id: "native-opencode-race", time: { created: 1, updated: 1 } },
1451 }),
1452 promptAsync: async (
1453 input: { readonly messageID?: string },
1454 options?: { readonly signal?: AbortSignal },
1455 ) => {
1456 promptInputs.push(input);
1457 promptCalls += 1;
1458 if (promptCalls === 1) {
1459 promptStarted.resolve();
1460 await prompt.promise;
1461 } else if (promptCalls === 2) {
1462 steerPromptStarted.resolve();
1463 await steerPrompt.promise;
1464 } else {
1465 stopPromptStarted.resolve();
1466 await new Promise<void>((_resolve, reject) =>
1467 options?.signal?.addEventListener("abort", () => reject(options.signal!.reason), {
1468 once: true,
1469 }),
1470 );
1471 }
1472 return { data: true };
1473 },
1474 status: async () => ({
1475 data: (() => {
1476 statusCalls += 1;
1477 if (statusCalls === 1) statusCalled.resolve();
1478 else steerStatusCalled.resolve();
1479 return { "native-opencode-race": { type: status } };
1480 })(),
1481 }),
1482 messages: async () => ({ data: [] }),
1483 children: async () => ({ data: [] }),
1484 abort: async () => {
1485 abortCalled.resolve();
1486 return { data: true };
1487 },
1488 },
1489 mcp: { add: async () => ({ data: true }) },
1490 };
1491 const adapter = makeOpenCodeAdapterV2({
1492 instanceId: ProviderInstanceId.make("opencode-test"),
1493 settings: OPEN_CODE_TEST_SETTINGS,
1494 environment: {},
1495 runtime: {
1496 connectToOpenCodeServer: () =>
1497 Effect.succeed({ url: "http://test.invalid", external: true }),
1498 createOpenCodeSdkClient: () => client,
1499 } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape,
1500 idAllocator,
1501 host: {
1502 paths: { cwd: "/workspace" },
1503 resolveAttachmentPath: () => null,
1504 } as unknown as ProviderHost.ProviderHostShape,
1505 });
1506 const threadId = ThreadId.make("thread-opencode-admission-race");
1507 const providerSessionId = ProviderSessionId.make("session-opencode-admission-race");
1508 const modelSelection = {
1509 instanceId: ProviderInstanceId.make("opencode-test"),
1510 model: "anthropic/claude-sonnet",
1511 options: [],
1512 };
1513 const policy = runtimePolicy("full-access", { cwd: "/workspace" });
1514 const runtime = yield* adapter.openSession({
1515 threadId,
1516 providerSessionId,
1517 modelSelection,
1518 runtimePolicy: policy,
1519 });
1520 const providerThread = yield* runtime.ensureThread({
1521 threadId,
1522 modelSelection,
1523 runtimePolicy: policy,
1524 });
1525 const now = yield* DateTime.now;
1526 const attemptId = RunAttemptId.make("attempt-opencode-admission-race");
1527 const runId = RunId.make("run-opencode-admission-race");
1528 const start = yield* runtime
1529 .startTurn({
1530 appThread: {
1531 id: threadId,
1532 projectId: ProjectId.make("project-opencode-admission-race"),
1533 title: "race",
1534 providerInstanceId: modelSelection.instanceId,
1535 modelSelection,
1536 runtimeMode: "full-access",
1537 interactionMode: "default",
1538 branch: null,
1539 worktreePath: null,
1540 activeProviderThreadId: providerThread.id,
1541 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
1542 forkedFrom: null,
1543 createdBy: "user",
1544 creationSource: "web",
1545 createdAt: now,
1546 updatedAt: now,
1547 archivedAt: null,
1548 settledOverride: null,
1549 settledAt: null,
1550 lastVisitedAt: null,
1551 deletedAt: null,
1552 },
1553 threadId,
1554 runId,
1555 runOrdinal: 1,
1556 providerTurnOrdinal: 1,
1557 attemptId,
1558 rootNodeId: NodeId.make("node-opencode-admission-race"),
1559 providerThread,
1560 message: {
1561 createdBy: "user",
1562 creationSource: "web",
1563 messageId: MessageId.make("message-opencode-admission-race"),
1564 text: "hello",
1565 attachments: [],
1566 },
1567 modelSelection,
1568 runtimePolicy: policy,
1569 })
1570 .pipe(Effect.forkScoped);
1571
1572 yield* Effect.promise(() => promptStarted.promise);
1573 yield* Effect.promise(() =>
1574 nativeEvents.push({
1575 type: "session.status",
1576 properties: { sessionID: "native-opencode-race", status: { type: "idle" } },
1577 }),
1578 );
1579 prompt.resolve();
1580 yield* Fiber.join(start);
1581 assert.match(promptInputs[0]?.messageID ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{28}$/);
1582 yield* Effect.promise(() =>
1583 nativeEvents.push({
1584 type: "session.status",
1585 properties: { sessionID: "native-opencode-race", status: { type: "busy" } },
1586 }),
1587 );
1588 status = "busy";
1589 yield* Effect.promise(() =>
1590 nativeEvents.push({
1591 type: "message.updated",
1592 properties: {
1593 sessionID: "native-opencode-race",
1594 info: {
1595 id: "stale-user-message",
1596 sessionID: "native-opencode-race",
1597 role: "user",
1598 time: { created: DateTime.toEpochMillis(now) },
1599 },
1600 },
1601 }),
1602 );
1603 assert.equal(statusCalls, 0, "an unrelated user message must not release prompt admission");
1604 yield* Effect.promise(() =>
1605 nativeEvents.push({
1606 type: "session.status",
1607 properties: { sessionID: "native-opencode-race", status: { type: "idle" } },
1608 }),
1609 );
1610 yield* Effect.promise(() =>
1611 nativeEvents.push({
1612 type: "message.updated",
1613 properties: {
1614 sessionID: "native-opencode-race",
1615 info: {
1616 id: promptInputs[0]!.messageID!,
1617 sessionID: "native-opencode-race",
1618 role: "user",
1619 time: { created: DateTime.toEpochMillis(now) },
1620 },
1621 },
1622 }),
1623 );
1624
1625 yield* Effect.promise(() => statusCalled.promise);
1626
1627 const snapshot = yield* runtime.readThreadSnapshot({ providerThread });
1628 assert.equal(snapshot.providerTurns.at(-1)?.status, "running");
1629 const activeTurn = snapshot.providerTurns.at(-1)!;
1630 const steer = yield* runtime
1631 .steerTurn({
1632 threadId,
1633 runId,
1634 providerThread,
1635 providerTurnId: activeTurn.id,
1636 message: {
1637 messageId: MessageId.make("message-opencode-steer-race"),
1638 text: "follow up",
1639 attachments: [],
1640 createdBy: "user",
1641 creationSource: "web",
1642 },
1643 })
1644 .pipe(Effect.forkScoped);
1645 yield* Effect.promise(() => steerPromptStarted.promise);
1646 assert.notEqual(promptInputs[1]?.messageID, promptInputs[0]?.messageID);
1647 steerPrompt.resolve();
1648 yield* Fiber.join(steer);
1649 yield* Effect.promise(() =>
1650 nativeEvents.push({
1651 type: "session.status",
1652 properties: { sessionID: "native-opencode-race", status: { type: "busy" } },
1653 }),
1654 );
1655 yield* Effect.promise(() =>
1656 nativeEvents.push({
1657 type: "message.updated",
1658 properties: {
1659 sessionID: "native-opencode-race",
1660 info: {
1661 id: promptInputs[0]!.messageID!,
1662 sessionID: "native-opencode-race",
1663 role: "user",
1664 time: { created: DateTime.toEpochMillis(now) },
1665 },
1666 },
1667 }),
1668 );
1669 yield* Effect.promise(() =>
1670 nativeEvents.push({
1671 type: "session.status",
1672 properties: { sessionID: "native-opencode-race", status: { type: "idle" } },
1673 }),
1674 );
1675 yield* Effect.promise(() =>
1676 nativeEvents.push({
1677 type: "message.updated",
1678 properties: {
1679 sessionID: "native-opencode-race",
1680 info: {
1681 id: promptInputs[1]!.messageID!,
1682 sessionID: "native-opencode-race",
1683 role: "user",
1684 time: { created: DateTime.toEpochMillis(now) },
1685 },
1686 },
1687 }),
1688 );
1689 yield* Effect.promise(() => steerStatusCalled.promise);
1690 const pendingSteer = yield* runtime
1691 .steerTurn({
1692 threadId,
1693 runId,
1694 providerThread,
1695 providerTurnId: activeTurn.id,
1696 message: {
1697 messageId: MessageId.make("message-opencode-stop-pending"),
1698 text: "pending when stopped",
1699 attachments: [],
1700 createdBy: "user",
1701 creationSource: "web",
1702 },
1703 })
1704 .pipe(Effect.exit, Effect.forkScoped);
1705 yield* Effect.promise(() => stopPromptStarted.promise);
1706 const interrupt = yield* runtime
1707 .interruptTurn({ providerThread, providerTurnId: activeTurn.id })
1708 .pipe(Effect.forkScoped);
1709 yield* Effect.promise(() => abortCalled.promise);
1710 yield* Fiber.join(interrupt);
1711 assert.isTrue(Exit.isFailure(yield* Fiber.join(pendingSteer)));
1712 const promptWhileStopping = yield* Effect.exit(
1713 runtime.steerTurn({
1714 threadId,
1715 runId,
1716 providerThread,
1717 providerTurnId: activeTurn.id,
1718 message: {
1719 messageId: MessageId.make("message-opencode-after-stop"),
1720 text: "must not pass the pending stop",
1721 attachments: [],
1722 createdBy: "user",
1723 creationSource: "web",
1724 },
1725 }),
1726 );
1727 assert.isTrue(Exit.isFailure(promptWhileStopping));
1728 assert.equal(promptCalls, 3);
1729 yield* Effect.promise(() =>
1730 nativeEvents.push({
1731 type: "session.status",
1732 properties: { sessionID: "native-opencode-race", status: { type: "idle" } },
1733 }),
1734 );
1735 const interrupted = yield* runtime.readThreadSnapshot({ providerThread });
1736 assert.equal(interrupted.providerTurns.at(-1)?.status, "interrupted");
1737 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1738 );
1739
1740 it.effect("interrupts an initial prompt while its SDK request is pending", () =>
1741 Effect.gen(function* () {
1742 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1743 const nativeEvents = asyncEventStream();
1744 const promptStarted = promiseGate<void>();
1745 const abortCalled = promiseGate<void>();
1746 let abortCalls = 0;
1747 const client = {
1748 event: {
1749 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1750 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1751 return { stream: nativeEvents.stream };
1752 },
1753 },
1754 session: {
1755 create: async () => ({
1756 data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } },
1757 }),
1758 get: async () => ({
1759 data: { id: "native-opencode-initial-stop", time: { created: 1, updated: 1 } },
1760 }),
1761 promptAsync: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => {
1762 promptStarted.resolve();
1763 await new Promise<void>((_resolve, reject) => {
1764 const signal = options?.signal;
1765 if (signal?.aborted) {
1766 reject(signal.reason);
1767 return;
1768 }
1769 signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
1770 });
1771 return { data: true };
1772 },
1773 status: async () => ({
1774 data: { "native-opencode-initial-stop": { type: "idle" as const } },
1775 }),
1776 messages: async () => ({ data: [] }),
1777 children: async () => ({ data: [] }),
1778 abort: async () => {
1779 abortCalls += 1;
1780 abortCalled.resolve();
1781 return { data: true };
1782 },
1783 },
1784 mcp: { add: async () => ({ data: true }) },
1785 };
1786 const adapter = makeOpenCodeAdapterV2({
1787 instanceId: ProviderInstanceId.make("opencode-initial-stop-test"),
1788 settings: OPEN_CODE_TEST_SETTINGS,
1789 environment: {},
1790 runtime: {
1791 connectToOpenCodeServer: () =>
1792 Effect.succeed({ url: "http://test.invalid", external: true }),
1793 createOpenCodeSdkClient: () => client,
1794 } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape,
1795 idAllocator,
1796 host: {
1797 paths: { cwd: "/workspace" },
1798 resolveAttachmentPath: () => null,
1799 } as unknown as ProviderHost.ProviderHostShape,
1800 });
1801 const threadId = ThreadId.make("thread-opencode-initial-stop");
1802 const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop");
1803 const modelSelection = {
1804 instanceId: ProviderInstanceId.make("opencode-initial-stop-test"),
1805 model: "anthropic/claude-sonnet",
1806 options: [],
1807 };
1808 const policy = runtimePolicy("full-access", { cwd: "/workspace" });
1809 const runtime = yield* adapter.openSession({
1810 threadId,
1811 providerSessionId,
1812 modelSelection,
1813 runtimePolicy: policy,
1814 });
1815 const providerThread = yield* runtime.ensureThread({
1816 threadId,
1817 modelSelection,
1818 runtimePolicy: policy,
1819 });
1820 const terminalEvents = yield* runtime.events.pipe(
1821 Stream.takeUntil((event) => event.type === "turn.terminal"),
1822 Stream.runCollect,
1823 Effect.forkScoped,
1824 );
1825 const now = yield* DateTime.now;
1826 const start = yield* runtime
1827 .startTurn({
1828 appThread: {
1829 id: threadId,
1830 projectId: ProjectId.make("project-opencode-initial-stop"),
1831 title: "initial stop",
1832 providerInstanceId: modelSelection.instanceId,
1833 modelSelection,
1834 runtimeMode: "full-access",
1835 interactionMode: "default",
1836 branch: null,
1837 worktreePath: null,
1838 activeProviderThreadId: providerThread.id,
1839 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
1840 forkedFrom: null,
1841 createdBy: "user",
1842 creationSource: "web",
1843 createdAt: now,
1844 updatedAt: now,
1845 archivedAt: null,
1846 settledOverride: null,
1847 settledAt: null,
1848 lastVisitedAt: null,
1849 deletedAt: null,
1850 },
1851 threadId,
1852 runId: RunId.make("run-opencode-initial-stop"),
1853 runOrdinal: 1,
1854 providerTurnOrdinal: 1,
1855 attemptId: RunAttemptId.make("attempt-opencode-initial-stop"),
1856 rootNodeId: NodeId.make("node-opencode-initial-stop"),
1857 providerThread,
1858 message: {
1859 createdBy: "user",
1860 creationSource: "web",
1861 messageId: MessageId.make("message-opencode-initial-stop"),
1862 text: "hello",
1863 attachments: [],
1864 },
1865 modelSelection,
1866 runtimePolicy: policy,
1867 })
1868 .pipe(Effect.forkScoped);
1869
1870 yield* Effect.promise(() => promptStarted.promise);
1871 const running = yield* runtime.readThreadSnapshot({ providerThread });
1872 const activeTurn = running.providerTurns.at(-1)!;
1873 const interrupt = yield* runtime
1874 .interruptTurn({ providerThread, providerTurnId: activeTurn.id })
1875 .pipe(Effect.forkScoped);
1876
1877 yield* Effect.promise(() => abortCalled.promise);
1878 yield* Fiber.join(start);
1879 yield* Fiber.join(interrupt);
1880 yield* Effect.promise(() =>
1881 nativeEvents.push({
1882 type: "session.status",
1883 properties: {
1884 sessionID: "native-opencode-initial-stop",
1885 status: { type: "idle" },
1886 },
1887 }),
1888 );
1889
1890 const events = Array.from(yield* Fiber.join(terminalEvents));
1891 const terminals = events.filter((event) => event.type === "turn.terminal");
1892 assert.equal(abortCalls, 1);
1893 assert.lengthOf(terminals, 1);
1894 assert.equal(terminals[0]?.status, "interrupted");
1895 assert.isNull(terminals[0]?.failure ?? null);
1896 assert.isFalse(
1897 events.some(
1898 (event) =>
1899 (event.type === "turn.terminal" && event.status === "failed") ||
1900 (event.type === "provider_session.updated" && event.providerSession.status === "error"),
1901 ),
1902 );
1903 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1904 );
1905
1906 it.effect("fails an active turn when the OpenCode event stream ends cleanly", () =>
1907 Effect.gen(function* () {
1908 const nativeEvents = asyncEventStream();
1909 const harness = yield* makeOpenCodeRuntimeHarness(
1910 "clean-event-eof",
1911 "native-opencode-clean-event-eof",
1912 {
1913 event: {
1914 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
1915 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
1916 return { stream: nativeEvents.stream };
1917 },
1918 },
1919 session: {
1920 create: async () => ({
1921 data: { id: "native-opencode-clean-event-eof", time: { created: 1, updated: 1 } },
1922 }),
1923 promptAsync: async () => ({ data: true }),
1924 },
1925 },
1926 );
1927 yield* harness.startTurn();
1928 const terminalEvents = yield* harness.runtime.events.pipe(
1929 Stream.runCollect,
1930 Effect.forkScoped,
1931 );
1932
1933 nativeEvents.close();
1934 const received = Array.from(yield* Fiber.join(terminalEvents));
1935 assert.isTrue(
1936 received.some(
1937 (event) =>
1938 event.type === "provider_session.updated" && event.providerSession.status === "error",
1939 ),
1940 );
1941 const terminal = received.find((event) => event.type === "turn.terminal");
1942 assert.equal(terminal?.status, "failed");
1943 assert.equal(terminal?.failure?.class, "transport_error");
1944 assert.equal(terminal?.threadDisposition, "broken");
1945 assert.equal((yield* Effect.exit(harness.startTurn()))._tag, "Failure");
1946 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
1947 );
1948
1949 it.effect("fails compaction when its response races stream termination", () =>
1950 Effect.gen(function* () {
1951 const nativeEvents = asyncEventStream();
1952 const summarizeStarted = promiseGate<void>();
1953 const summarizeResult = promiseGate<{ data: boolean }>();
1954 const baseClock = yield* Clock.Clock;
1955 const eofClockRead = yield* Deferred.make<void>();
1956 const releaseEofClockRead = yield* Deferred.make<void>();
1957 let blockNextClockRead = false;
1958 const blockingClock: Clock.Clock = {
1959 ...baseClock,
1960 currentTimeMillis: Effect.suspend(() => {
1961 if (!blockNextClockRead) return baseClock.currentTimeMillis;
1962 blockNextClockRead = false;
1963 return Deferred.succeed(eofClockRead, undefined).pipe(
1964 Effect.andThen(Deferred.await(releaseEofClockRead)),
1965 Effect.andThen(baseClock.currentTimeMillis),
1966 );
1967 }),
1968 };
1969 const harness = yield* makeOpenCodeRuntimeHarness(
1970 "compaction-eof-race",
1971 "native-opencode-compaction-eof-race",
1972 {
1973 event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
1974 session: {
1975 create: async () => ({
1976 data: { id: "native-opencode-compaction-eof-race", time: { created: 1, updated: 1 } },
1977 }),
1978 summarize: () => {
1979 summarizeStarted.resolve();
1980 return summarizeResult.promise;
1981 },
1982 },
1983 },
1984 ).pipe(Effect.provideService(Clock.Clock, blockingClock));
1985 const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped);
1986 const start = yield* harness.startTurn("/compact").pipe(Effect.forkScoped);
1987 yield* Effect.promise(() => summarizeStarted.promise);
1988 blockNextClockRead = true;
1989 nativeEvents.close();
1990 yield* Deferred.await(eofClockRead);
1991 summarizeResult.resolve({ data: true });
1992 yield* Fiber.join(start);
1993 yield* Deferred.succeed(releaseEofClockRead, undefined);
1994 const received = Array.from(yield* Fiber.join(events));
1995 const terminals = received.filter((event) => event.type === "turn.terminal");
1996 assert.lengthOf(terminals, 1);
1997 assert.equal(terminals[0]?.status, "failed");
1998 assert.equal(terminals[0]?.failure?.class, "transport_error");
1999 assert.isFalse(
2000 received.some(
2001 (event) =>
2002 event.type === "turn_item.updated" &&
2003 event.turnItem.type === "compaction" &&
2004 event.turnItem.status === "completed",
2005 ),
2006 );
2007 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2008 );
2009
2010 it.effect("does not register a turn after the OpenCode event stream ends", () =>
2011 Effect.gen(function* () {
2012 const nativeEvents = asyncEventStream();
2013 let promptCalls = 0;
2014 const harness = yield* makeOpenCodeRuntimeHarness(
2015 "event-eof-start-race",
2016 "native-opencode-event-eof-start-race",
2017 {
2018 event: {
2019 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
2020 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
2021 return { stream: nativeEvents.stream };
2022 },
2023 },
2024 session: {
2025 create: async () => ({
2026 data: {
2027 id: "native-opencode-event-eof-start-race",
2028 time: { created: 1, updated: 1 },
2029 },
2030 }),
2031 promptAsync: async () => {
2032 promptCalls += 1;
2033 return { data: true };
2034 },
2035 },
2036 },
2037 );
2038 const baseClock = yield* Clock.Clock;
2039 const startClockRead = yield* Deferred.make<void>();
2040 const releaseStartClockRead = yield* Deferred.make<void>();
2041 let blockNextClockRead = true;
2042 const blockingClock: Clock.Clock = {
2043 ...baseClock,
2044 currentTimeMillis: Effect.suspend(() => {
2045 if (!blockNextClockRead) return baseClock.currentTimeMillis;
2046 blockNextClockRead = false;
2047 return Deferred.succeed(startClockRead, undefined).pipe(
2048 Effect.andThen(Deferred.await(releaseStartClockRead)),
2049 Effect.andThen(baseClock.currentTimeMillis),
2050 );
2051 }),
2052 };
2053 const start = yield* harness
2054 .startTurn()
2055 .pipe(Effect.provideService(Clock.Clock, blockingClock), Effect.exit, Effect.forkScoped);
2056 yield* Deferred.await(startClockRead);
2057
2058 const events = yield* harness.runtime.events.pipe(Stream.runCollect, Effect.forkScoped);
2059 nativeEvents.close();
2060 yield* Fiber.join(events);
2061 yield* Deferred.succeed(releaseStartClockRead, undefined);
2062
2063 assert.isTrue(Exit.isFailure(yield* Fiber.join(start)));
2064 assert.equal(promptCalls, 0);
2065 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2066 );
2067
2068 it("holds stale idle through prompt admission until the new user message is observed", () => {
2069 const admission = {
2070 admissionPending: true,
2071 admissionAccepted: false,
2072 admissionMessageObserved: false,
2073 idleDuringAdmission: false,
2074 };
2075
2076 assert.equal(advanceOpenCodePromptAdmission(admission, "idle"), "hold");
2077 assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "hold");
2078 assert.isTrue(admission.admissionPending);
2079 assert.equal(advanceOpenCodePromptAdmission(admission, "user-message"), "reconcile-idle");
2080 assert.isTrue(admission.admissionPending);
2081 });
2082
2083 it("releases admission only after prompt acceptance and new-turn evidence", () => {
2084 const admission = {
2085 admissionPending: true,
2086 admissionAccepted: false,
2087 admissionMessageObserved: false,
2088 idleDuringAdmission: false,
2089 };
2090
2091 assert.equal(advanceOpenCodePromptAdmission(admission, "busy"), "hold");
2092 assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release");
2093 assert.isFalse(admission.admissionPending);
2094 });
2095
2096 it("releases admission when an assistant completes under a provider-assigned message id", () => {
2097 const admission = {
2098 admissionPending: true,
2099 admissionAccepted: false,
2100 admissionMessageObserved: false,
2101 idleDuringAdmission: true,
2102 };
2103
2104 assert.equal(advanceOpenCodePromptAdmission(admission, "assistant-completed"), "release");
2105 assert.isTrue(admission.admissionAccepted);
2106 assert.isTrue(admission.admissionMessageObserved);
2107 assert.isFalse(admission.admissionPending);
2108 });
2109
2110 it("invalidates pending admission before aborting a turn", () => {
2111 const admission = {
2112 admissionGeneration: 4,
2113 admissionPending: true,
2114 admissionAccepted: false,
2115 admissionMessageObserved: false,
2116 idleDuringAdmission: false,
2117 };
2118
2119 cancelOpenCodePromptAdmission(admission, 5);
2120
2121 assert.equal(admission.admissionGeneration, 5);
2122 assert.isFalse(admission.admissionPending);
2123 assert.equal(advanceOpenCodePromptAdmission(admission, "accepted"), "release");
2124 });
2125
2126 it.effect("reconciles current idle and busy status replies after prompt admission", () =>
2127 Effect.gen(function* () {
2128 for (const expected of ["idle", "busy"] as const) {
2129 const admission = { admissionGeneration: 4, admissionPending: true };
2130 const status = yield* Deferred.make<"idle" | "busy" | "unknown">();
2131 const fiber = yield* reconcileOpenCodePromptAdmissionStatus(
2132 admission,
2133 4,
2134 Deferred.await(status),
2135 ).pipe(Effect.forkChild({ startImmediately: true }));
2136
2137 yield* Deferred.succeed(status, expected);
2138
2139 assert.equal(yield* Fiber.join(fiber), expected);
2140 assert.isFalse(admission.admissionPending);
2141 }
2142 }),
2143 );
2144
2145 it.effect("keeps admission pending after a transient status failure", () =>
2146 Effect.gen(function* () {
2147 const admission = { admissionGeneration: 4, admissionPending: true };
2148 assert.equal(
2149 yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("unknown")),
2150 "unknown",
2151 );
2152 assert.isTrue(admission.admissionPending);
2153 assert.equal(
2154 yield* reconcileOpenCodePromptAdmissionStatus(admission, 4, Effect.succeed("idle")),
2155 "idle",
2156 );
2157 assert.isFalse(admission.admissionPending);
2158 }),
2159 );
2160
2161 it.effect("retries a transient status failure without another idle event", () =>
2162 Effect.gen(function* () {
2163 const nativeSessionId = "native-opencode-status-retry";
2164 const nativeEvents = asyncEventStream();
2165 const promptRelease = promiseGate<void>();
2166 const promptCalls = yield* Queue.unbounded<string>();
2167 const statusCalls = yield* Queue.unbounded<number>();
2168 let statusCallCount = 0;
2169 const client = {
2170 event: {
2171 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
2172 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
2173 return { stream: nativeEvents.stream };
2174 },
2175 },
2176 session: {
2177 create: async () => ({
2178 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
2179 }),
2180 get: async () => ({
2181 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
2182 }),
2183 promptAsync: async (input: { readonly messageID?: string }) => {
2184 Queue.offerUnsafe(promptCalls, input.messageID!);
2185 await promptRelease.promise;
2186 return { data: true };
2187 },
2188 status: async () => {
2189 statusCallCount += 1;
2190 Queue.offerUnsafe(statusCalls, statusCallCount);
2191 if (statusCallCount === 1) throw new Error("transient status failure");
2192 return { data: { [nativeSessionId]: { type: "idle" as const } } };
2193 },
2194 messages: async () => ({ data: [] }),
2195 children: async () => ({ data: [] }),
2196 abort: async () => ({ data: true }),
2197 },
2198 mcp: { add: async () => ({ data: true }) },
2199 };
2200 const harness = yield* makeOpenCodeRuntimeHarness("status-retry", nativeSessionId, client);
2201 const terminalEvents = yield* harness.runtime.events.pipe(
2202 Stream.takeUntil((event) => event.type === "turn.terminal"),
2203 Stream.runCollect,
2204 Effect.forkScoped,
2205 );
2206 const start = yield* harness.startTurn().pipe(Effect.forkScoped);
2207 const admissionMessageId = yield* Queue.take(promptCalls);
2208
2209 yield* Effect.promise(() =>
2210 nativeEvents.push({
2211 type: "session.status",
2212 properties: { sessionID: nativeSessionId, status: { type: "idle" } },
2213 }),
2214 );
2215 promptRelease.resolve();
2216 yield* Fiber.join(start);
2217 const userMessage = {
2218 type: "message.updated",
2219 properties: {
2220 sessionID: nativeSessionId,
2221 info: {
2222 id: admissionMessageId,
2223 sessionID: nativeSessionId,
2224 role: "user",
2225 time: { created: DateTime.toEpochMillis(harness.now) },
2226 },
2227 },
2228 };
2229 yield* Effect.promise(() => nativeEvents.push(userMessage));
2230 assert.equal(yield* Queue.take(statusCalls), 1);
2231
2232 yield* Effect.promise(() => nativeEvents.push(userMessage));
2233 assert.equal(statusCallCount, 1, "duplicate events must share the generation's retry worker");
2234 yield* TestClock.adjust("250 millis");
2235 assert.equal(yield* Queue.take(statusCalls), 2);
2236
2237 const events = Array.from(yield* Fiber.join(terminalEvents));
2238 const terminals = events.filter((event) => event.type === "turn.terminal");
2239 assert.equal(statusCallCount, 2);
2240 assert.lengthOf(terminals, 1);
2241 assert.equal(terminals[0]?.status, "completed");
2242 assert.isNull(terminals[0]?.failure ?? null);
2243 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2244 );
2245
2246 it.effect("does not let a queued status retry adopt a newer steer generation", () =>
2247 Effect.gen(function* () {
2248 const nativeSessionId = "native-opencode-stale-status-retry";
2249 const nativeEvents = asyncEventStream();
2250 const firstPromptRelease = promiseGate<void>();
2251 const promptCalls = yield* Queue.unbounded<string>();
2252 const statusCalls = yield* Queue.unbounded<number>();
2253 let promptCallCount = 0;
2254 let statusCallCount = 0;
2255 const client = {
2256 event: {
2257 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
2258 options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
2259 return { stream: nativeEvents.stream };
2260 },
2261 },
2262 session: {
2263 create: async () => ({
2264 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
2265 }),
2266 get: async () => ({
2267 data: { id: nativeSessionId, time: { created: 1, updated: 1 } },
2268 }),
2269 promptAsync: async (input: { readonly messageID?: string }) => {
2270 promptCallCount += 1;
2271 Queue.offerUnsafe(promptCalls, input.messageID!);
2272 if (promptCallCount === 1) {
2273 await firstPromptRelease.promise;
2274 }
2275 return { data: true };
2276 },
2277 status: async () => {
2278 statusCallCount += 1;
2279 Queue.offerUnsafe(statusCalls, statusCallCount);
2280 if (statusCallCount === 1) throw new Error("transient status failure");
2281 return { data: { [nativeSessionId]: { type: "idle" as const } } };
2282 },
2283 messages: async () => ({ data: [] }),
2284 children: async () => ({ data: [] }),
2285 abort: async () => ({ data: true }),
2286 },
2287 mcp: { add: async () => ({ data: true }) },
2288 };
2289 const harness = yield* makeOpenCodeRuntimeHarness(
2290 "stale-status-retry",
2291 nativeSessionId,
2292 client,
2293 );
2294 const start = yield* harness.startTurn().pipe(Effect.forkScoped);
2295 const firstAdmissionMessageId = yield* Queue.take(promptCalls);
2296
2297 yield* Effect.promise(() =>
2298 nativeEvents.push({
2299 type: "session.status",
2300 properties: { sessionID: nativeSessionId, status: { type: "idle" } },
2301 }),
2302 );
2303 firstPromptRelease.resolve();
2304 yield* Fiber.join(start);
2305 yield* Effect.promise(() =>
2306 nativeEvents.push({
2307 type: "message.updated",
2308 properties: {
2309 sessionID: nativeSessionId,
2310 info: {
2311 id: firstAdmissionMessageId,
2312 sessionID: nativeSessionId,
2313 role: "user",
2314 time: { created: DateTime.toEpochMillis(harness.now) },
2315 },
2316 },
2317 }),
2318 );
2319 assert.equal(yield* Queue.take(statusCalls), 1);
2320
2321 const running = yield* harness.runtime.readThreadSnapshot({
2322 providerThread: harness.providerThread,
2323 });
2324 const activeTurn = running.providerTurns.at(-1)!;
2325 yield* harness.runtime.steerTurn({
2326 threadId: harness.threadId,
2327 runId: harness.runId,
2328 providerThread: harness.providerThread,
2329 providerTurnId: activeTurn.id,
2330 message: {
2331 messageId: MessageId.make("message-opencode-stale-status-retry-steer"),
2332 text: "new generation",
2333 attachments: [],
2334 createdBy: "user",
2335 creationSource: "web",
2336 },
2337 });
2338 yield* Queue.take(promptCalls);
2339 yield* TestClock.adjust("250 millis");
2340
2341 const afterRetry = yield* harness.runtime.readThreadSnapshot({
2342 providerThread: harness.providerThread,
2343 });
2344 assert.equal(statusCallCount, 1);
2345 assert.equal(afterRetry.providerTurns.at(-1)?.status, "running");
2346 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2347 );
2348
2349 it.effect("ignores a delayed status reply after steering starts a newer admission", () =>
2350 Effect.gen(function* () {
2351 const admission = { admissionGeneration: 4, admissionPending: true };
2352 const status = yield* Deferred.make<"idle" | "busy" | "unknown">();
2353 const fiber = yield* reconcileOpenCodePromptAdmissionStatus(
2354 admission,
2355 4,
2356 Deferred.await(status),
2357 ).pipe(Effect.forkChild({ startImmediately: true }));
2358
2359 admission.admissionGeneration = 5;
2360 admission.admissionPending = true;
2361 yield* Deferred.succeed(status, "idle");
2362
2363 assert.equal(yield* Fiber.join(fiber), "stale");
2364 assert.equal(admission.admissionGeneration, 5);
2365 assert.isTrue(admission.admissionPending);
2366 }),
2367 );
2368
2369 it.effect("does not revive admission when abort wins a pending status lookup", () =>
2370 Effect.gen(function* () {
2371 const admission = { admissionGeneration: 4, admissionPending: true };
2372 const status = yield* Deferred.make<"idle" | "busy" | "unknown">();
2373 const fiber = yield* reconcileOpenCodePromptAdmissionStatus(
2374 admission,
2375 4,
2376 Deferred.await(status),
2377 ).pipe(Effect.forkChild({ startImmediately: true }));
2378
2379 cancelOpenCodePromptAdmission(admission, 5);
2380 yield* Deferred.succeed(status, "idle");
2381
2382 assert.equal(yield* Fiber.join(fiber), "stale");
2383 assert.isFalse(admission.admissionPending);
2384 }),
2385 );
2386
2387 it.effect("logs bounded structural protocol diagnostics without native payload values", () =>
2388 Effect.gen(function* () {
2389 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2390 const records: Array<unknown> = [];
2391 const nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger = {
2392 filePath: "/tmp/provider-native.ndjson",
2393 write: (event) => Effect.sync(() => void records.push(event)),
2394 close: () => Effect.void,
2395 };
2396 const logProtocolEvent = makeOpenCodeProtocolLogger({
2397 nativeEventLogger,
2398 idAllocator,
2399 providerInstanceId: ProviderInstanceId.make("opencode-test"),
2400 providerSessionId: ProviderSessionId.make("provider-session-opencode-test"),
2401 threadId: ThreadId.make("thread-opencode-test"),
2402 });
2403 const secret = "secret-opencode-prompt";
2404
2405 yield* logProtocolEvent({
2406 direction: "outgoing",
2407 messageKind: "request",
2408 method: "session.prompt",
2409 payload: { prompt: secret, nested: { token: secret } },
2410 });
2411
2412 const serialized = encodeUnknownJson(records);
2413 assert.notInclude(serialized, secret);
2414 assert.include(serialized, '"protocol":"opencode-sdk.sse"');
2415 assert.include(serialized, '"method":"session.prompt"');
2416 assert.include(serialized, '"fieldCount":2');
2417 }).pipe(Effect.provide(IdAllocator.layer)),
2418 );
2419
2420 it.effect("adopts the handed-over provider thread identity on session create", () =>
2421 Effect.gen(function* () {
2422 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2423 const host = yield* ProviderHost.ProviderHost;
2424 let createCount = 0;
2425 const createInputs: Array<unknown> = [];
2426 const fakeClient = {
2427 event: {
2428 subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({
2429 // Emits nothing and ends when the pump's abort signal fires.
2430 stream: {
2431 [Symbol.asyncIterator]: () => ({
2432 next: () =>
2433 new Promise<IteratorResult<never>>((resolve) => {
2434 const done = () => resolve({ done: true, value: undefined });
2435 if (options?.signal?.aborted) return done();
2436 options?.signal?.addEventListener("abort", done, { once: true });
2437 }),
2438 }),
2439 },
2440 }),
2441 },
2442 session: {
2443 create: async (input: unknown) => {
2444 createCount += 1;
2445 createInputs.push(input);
2446 return { data: { id: `ses_native_${createCount}`, time: { created: 1, updated: 1 } } };
2447 },
2448 },
2449 } as unknown as OpencodeClient;
2450 const unused = (operation: string) => () => Effect.die(`${operation} is not used`);
2451 const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = {
2452 startOpenCodeServerProcess: unused("startOpenCodeServerProcess"),
2453 connectToOpenCodeServer: () =>
2454 Effect.succeed({
2455 url: "test://opencode",
2456 version: "test",
2457 exitCode: null,
2458 external: true,
2459 }),
2460 runOpenCodeCommand: unused("runOpenCodeCommand"),
2461 createOpenCodeSdkClient: () => fakeClient,
2462 loadOpenCodeInventory: unused("loadOpenCodeInventory"),
2463 loadInventoryFromCli: unused("loadInventoryFromCli"),
2464 loadOpenCodeSkills: unused("loadOpenCodeSkills"),
2465 loadSkillsFromCli: unused("loadSkillsFromCli"),
2466 };
2467 const instanceId = ProviderInstanceId.make("opencode");
2468 const threadId = ThreadId.make("thread-opencode-adopt");
2469 const modelSelection = { instanceId, model: "default" };
2470 const adapter = makeOpenCodeAdapterV2({
2471 instanceId,
2472 settings: OPENCODE_TEST_SETTINGS,
2473 environment: {},
2474 runtime,
2475 idAllocator,
2476 host,
2477 });
2478 const session = yield* adapter.openSession({
2479 threadId,
2480 providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"),
2481 modelSelection,
2482 runtimePolicy: runtimePolicy("full-access"),
2483 });
2484 const now = yield* DateTime.now;
2485 // The placeholder row the orchestrator creates for a first run: no
2486 // native identity yet. The adapter must bind the created session to
2487 // this row instead of minting a second session-keyed row.
2488 const placeholder: OrchestrationV2ProviderThread = {
2489 id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"),
2490 driver: OPENCODE_PROVIDER,
2491 providerInstanceId: instanceId,
2492 providerSessionId: null,
2493 appThreadId: threadId,
2494 ownerNodeId: null,
2495 nativeThreadRef: null,
2496 nativeConversationHeadRef: null,
2497 status: "not_loaded",
2498 firstRunOrdinal: 1,
2499 lastRunOrdinal: 1,
2500 handoffIds: [],
2501 forkedFrom: null,
2502 createdAt: now,
2503 updatedAt: now,
2504 };
2505 const adopted = yield* session.ensureThread({
2506 threadId,
2507 modelSelection,
2508 runtimePolicy: runtimePolicy("full-access"),
2509 existingProviderThread: placeholder,
2510 });
2511 assert.equal(adopted.id, placeholder.id);
2512 assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1");
2513 // Without a handed-over row the adapter still derives its own id.
2514 const minted = yield* session.ensureThread({
2515 threadId,
2516 modelSelection,
2517 runtimePolicy: runtimePolicy("full-access"),
2518 });
2519 assert.notEqual(minted.id, placeholder.id);
2520 assert.equal(minted.nativeThreadRef?.nativeId, "ses_native_2");
2521 // OpenCode names a session from its first prompt only when create
2522 // leaves the title unset, so the adapter never sends one.
2523 for (const input of createInputs) {
2524 assert.notProperty(input, "title");
2525 }
2526 }).pipe(
2527 Effect.scoped,
2528 Effect.provide(
2529 Layer.mergeAll(
2530 IdAllocator.layer,
2531 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
2532 ),
2533 ),
2534 ),
2535 );
2536
2537 it("maps native permission families to orchestration request kinds", () => {
2538 assert.equal(openCodePermissionRequestKind("bash"), "command");
2539 assert.equal(openCodePermissionRequestKind("read"), "file-read");
2540 assert.equal(openCodePermissionRequestKind("grep"), "file-read");
2541 assert.equal(openCodePermissionRequestKind("external_directory"), "file-read");
2542 assert.equal(openCodePermissionRequestKind("external_directory", "edit"), "file-change");
2543 assert.equal(openCodePermissionRequestKind("edit"), "file-change");
2544 assert.equal(openCodePermissionRequestKind("apply_patch"), "file-change");
2545 assert.equal(openCodePermissionRequestKind("todowrite"), "command");
2546 assert.equal(openCodePermissionRequestKind("custom", "todowrite"), "command");
2547 });
2548
2549 it("maps OpenCode tools to semantic turn-item families", () => {
2550 assert.equal(openCodeToolProjectionKind("bash"), "command_execution");
2551 assert.equal(openCodeToolProjectionKind("edit"), "file_change");
2552 assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool");
2553 assert.equal(openCodeToolProjectionKind("lsp"), "file_search");
2554 assert.equal(openCodeToolProjectionKind("websearch"), "web_search");
2555 assert.equal(openCodeToolProjectionKind("codesearch"), "web_search");
2556 assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool");
2557 assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool");
2558 });
2559
2560 it("maps runtime modes to safe OpenCode permission rules", () => {
2561 const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required"));
2562 assert.equal(permissionAction(approvalRequired, "read"), "allow");
2563 assert.equal(permissionAction(approvalRequired, "edit"), "ask");
2564 assert.equal(permissionAction(approvalRequired, "bash"), "ask");
2565 assert.equal(permissionAction(approvalRequired, "doom_loop"), "ask");
2566 assert.equal(permissionAction(approvalRequired, "unknown_plugin_tool"), "ask");
2567 assert.equal(permissionAction(approvalRequired, "question"), "allow");
2568
2569 const autoAcceptEdits = openCodePermissionRules(runtimePolicy("auto-accept-edits"));
2570 assert.equal(permissionAction(autoAcceptEdits, "edit"), "allow");
2571 assert.equal(permissionAction(autoAcceptEdits, "bash"), "ask");
2572
2573 const fullAccess = openCodePermissionRules(runtimePolicy("full-access"));
2574 assert.equal(permissionAction(fullAccess, "bash"), "allow");
2575 assert.equal(permissionAction(fullAccess, "edit"), "allow");
2576
2577 const granularApproval = openCodePermissionRules(
2578 runtimePolicy("full-access", {
2579 approvalPolicy: { granular: { request_permissions: true } },
2580 }),
2581 );
2582 assert.equal(permissionAction(granularApproval, "bash"), "ask");
2583 assert.equal(permissionAction(granularApproval, "read"), "allow");
2584
2585 const approvalRequiredWorkspaceWrite = openCodePermissionRules(
2586 runtimePolicy("approval-required", {
2587 sandboxPolicy: {
2588 type: "workspaceWrite",
2589 writableRoots: ["/tmp/opencode-workspace"],
2590 networkAccess: false,
2591 },
2592 }),
2593 );
2594 assert.equal(permissionAction(approvalRequiredWorkspaceWrite, "edit"), "ask");
2595 });
2596
2597 it("enforces non-interactive sandbox policy through OpenCode permissions", () => {
2598 const readOnly = openCodePermissionRules(
2599 runtimePolicy("full-access", {
2600 approvalPolicy: "never",
2601 sandboxPolicy: {
2602 type: "readOnly",
2603 access: { type: "fullAccess" },
2604 networkAccess: false,
2605 },
2606 }),
2607 );
2608 assert.equal(permissionAction(readOnly, "read"), "allow");
2609 assert.equal(permissionAction(readOnly, "edit"), "deny");
2610 assert.equal(permissionAction(readOnly, "bash"), "deny");
2611 assert.equal(permissionAction(readOnly, "webfetch"), "deny");
2612 assert.equal(permissionAction(readOnly, "doom_loop"), "deny");
2613 assert.equal(permissionAction(readOnly, "unknown_plugin_tool"), "deny");
2614 assert.equal(permissionAction(readOnly, "external_directory"), "allow");
2615
2616 const workspaceWrite = openCodePermissionRules(
2617 runtimePolicy("auto-accept-edits", {
2618 approvalPolicy: "never",
2619 sandboxPolicy: {
2620 type: "workspaceWrite",
2621 writableRoots: ["/tmp/opencode-workspace"],
2622 networkAccess: true,
2623 },
2624 }),
2625 );
2626 assert.equal(permissionAction(workspaceWrite, "edit"), "allow");
2627 assert.equal(permissionAction(workspaceWrite, "bash"), "deny");
2628 assert.equal(permissionAction(workspaceWrite, "webfetch"), "allow");
2629 assert.deepInclude(workspaceWrite, {
2630 permission: "external_directory",
2631 pattern: "/tmp/opencode-workspace/*",
2632 action: "allow",
2633 });
2634 });
2635
2636 it("preserves OpenCode's recursion guard on task-created child sessions", () => {
2637 const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [
2638 { permission: "task", pattern: "*", action: "deny" },
2639 ]);
2640
2641 assert.equal(permissionAction(childRules, "read"), "allow");
2642 assert.equal(permissionAction(childRules, "bash"), "allow");
2643 assert.equal(permissionAction(childRules, "task"), "deny");
2644
2645 const approvalRequiredPolicy = runtimePolicy("approval-required");
2646 const parentRules = openCodePermissionRules(approvalRequiredPolicy);
2647 const childApprovalRules = openCodeChildPermissionRules(approvalRequiredPolicy, [
2648 ...parentRules.filter((rule) => rule.action === "deny"),
2649 { permission: "task", pattern: "*", action: "deny" },
2650 ]);
2651 assert.equal(permissionAction(childApprovalRules, "bash"), "ask");
2652 assert.equal(permissionAction(childApprovalRules, "task"), "deny");
2653 });
2654
2655 it("uses the next native user message as the exclusive fork and revert boundary", () => {
2656 const first = providerTurn({ id: "turn:first", ordinal: 1, nativeId: "msg-user-1" });
2657 const synthetic = providerTurn({ id: "turn:synthetic", ordinal: 2, nativeId: null });
2658 const third = providerTurn({ id: "turn:third", ordinal: 3, nativeId: "msg-user-3" });
2659
2660 assert.equal(
2661 openCodeBoundaryAfterProviderTurn([third, first, synthetic], first.id),
2662 "msg-user-3",
2663 );
2664 assert.isUndefined(openCodeBoundaryAfterProviderTurn([first, synthetic, third], third.id));
2665 });
2666});
2667
2668it.effect.each([false, true])(
2669 "OpenCode rewind forks history and validates the retained boundary, invalid=%s",
2670 (invalid) =>
2671 Effect.gen(function* () {
2672 const events = asyncEventStream();
2673 const nativeSessionId = "rewind-source";
2674 const forkId = "rewind-fork";
2675 const calls: string[] = [];
2676 const removed = {
2677 info: { id: "first-user", sessionID: nativeSessionId, role: "user", time: { created: 1 } },
2678 parts: [],
2679 };
2680 const harness = yield* makeOpenCodeRuntimeHarness("rewind-fork", nativeSessionId, {
2681 event: {
2682 subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
2683 options.signal?.addEventListener("abort", () => events.close(), { once: true });
2684 return { stream: events.stream };
2685 },
2686 },
2687 session: {
2688 create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }),
2689 get: async ({ sessionID }: { sessionID: string }) => ({
2690 data: { id: sessionID, time: { created: 1, updated: 2 } },
2691 }),
2692 messages: async ({ sessionID }: { sessionID: string }) => ({
2693 data: sessionID === nativeSessionId || invalid ? [removed] : [],
2694 }),
2695 fork: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
2696 assert.equal(sessionID, nativeSessionId);
2697 assert.equal(messageID, "first-user");
2698 calls.push("fork");
2699 return { data: { id: forkId, time: { created: 1, updated: 2 } } };
2700 },
2701 update: async () => {
2702 calls.push("permissions");
2703 return { data: {} };
2704 },
2705 revert: async () => {
2706 throw new Error("Native revert would change workspace files");
2707 },
2708 },
2709 });
2710 const effect = harness.runtime.rollbackThread({
2711 providerThread: harness.providerThread,
2712 target: {
2713 type: "thread_start",
2714 checkpointId: CheckpointId.make("rewind-checkpoint"),
2715 appRunOrdinal: 0,
2716 },
2717 providerThreadTurns: [],
2718 });
2719 if (invalid) {
2720 const error = yield* effect.pipe(Effect.flip);
2721 assert.equal(error._tag, "ProviderAdapterRollbackThreadError");
2722 assert.deepEqual(calls, ["fork"]);
2723 } else {
2724 const result = yield* effect;
2725 assert.equal(result.providerThread.nativeThreadRef?.nativeId, forkId);
2726 assert.equal(result.messages.length, 0);
2727 assert.deepEqual(calls, ["fork", "permissions"]);
2728 }
2729 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
2730);
packages/provider-opencode/src/server/adapter.ts created+3797
...@@ -0,0 +1,3797 @@
1import type {
2 Event as OpenCodeEvent,
3 Message as OpenCodeMessage,
4 Part as OpenCodePart,
5 PermissionRequest,
6 PermissionRuleset,
7 QuestionRequest,
8 Session as OpenCodeSession,
9 Todo as OpenCodeTodo,
10 ToolPart,
11} from "@opencode-ai/sdk/v2";
12import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
13import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
14import { causeErrorTag } from "@t3tools/shared/observability";
15import {
16 defaultInstanceIdForDriver,
17 type ModelSelection,
18 type OrchestrationV2AppThread,
19 type OrchestrationV2ConversationMessage,
20 type OrchestrationV2ExecutionNode,
21 type OrchestrationV2PlanStep,
22 type OrchestrationV2ProviderCapabilities,
23 type OrchestrationV2ProviderFailure,
24 type OrchestrationV2ProviderRef,
25 type OrchestrationV2ProviderSession,
26 type OrchestrationV2ProviderThread,
27 type OrchestrationV2ProviderTurn,
28 type OrchestrationV2RuntimeRequest,
29 type OrchestrationV2Subagent,
30 type OrchestrationV2TurnItem,
31 type PlanId,
32 ProviderDriverKind,
33 type ProviderInstanceId,
34 type ProviderRequestKind,
35 type ProviderSessionId,
36 type RuntimeRequestId,
37 type ThreadId,
38} from "@t3tools/contracts";
39import { OpenCodeSettings } from "../settings.ts";
40import * as Cause from "effect/Cause";
41import * as DateTime from "effect/DateTime";
42import * as Deferred from "effect/Deferred";
43import * as Duration from "effect/Duration";
44import * as Effect from "effect/Effect";
45import * as Exit from "effect/Exit";
46import * as Fiber from "effect/Fiber";
47import * as Layer from "effect/Layer";
48import * as Option from "effect/Option";
49import * as Queue from "effect/Queue";
50import * as Random from "effect/Random";
51import * as Schema from "effect/Schema";
52import * as Semaphore from "effect/Semaphore";
53import * as Scope from "effect/Scope";
54import * as Stream from "effect/Stream";
55
56import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
57import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation";
58import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
59import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
60import {
61 structuralProtocolMethod,
62 summarizeNativeProtocolPayload,
63} from "@t3tools/provider-core/server/nativeProtocolLogging";
64import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
65import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions";
66import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
67import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
68import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
69import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
70import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
71import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt";
72import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
73import {
74 ProviderAdapterDriverCreateError,
75 type ProviderAdapterDriver,
76 type ProviderAdapterDriverCreateInput,
77} from "@t3tools/provider-core/server/adapterDriver";
78import {
79 makeSubagentChildThread,
80 subagentThreadTitle,
81} from "@t3tools/provider-core/server/subagentProjection";
82import { openCodeToolTurnItem } from "./toolItems.ts";
83
84export { openCodeToolProjectionKind } from "./toolItems.ts";
85
86export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode");
87export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER);
88export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const;
89const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({});
90
91let openCodeMessageIdEpochMillis = -1;
92let openCodeMessageIdCounter = 0;
93
94const makeOpenCodeMessageId = Effect.fnUntraced(function* () {
95 const epochMillis = DateTime.toEpochMillis(yield* DateTime.now);
96 if (epochMillis !== openCodeMessageIdEpochMillis) {
97 openCodeMessageIdEpochMillis = epochMillis;
98 openCodeMessageIdCounter = 0;
99 }
100 openCodeMessageIdCounter += 1;
101 const encodedTime = BigInt.asUintN(
102 48,
103 BigInt(epochMillis) * 0x1000n + BigInt(openCodeMessageIdCounter),
104 )
105 .toString(16)
106 .padStart(12, "0");
107 const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
108 const random = (yield* Effect.forEach(Array.from({ length: 28 }), () =>
109 Random.nextIntBetween(0, alphabet.length - 1),
110 ))
111 .map((index) => alphabet[index])
112 .join("");
113 return `msg_${encodedTime}${random}`;
114});
115
116/**
117 * OpenCode's session, message, part, and interaction-request identifiers are
118 * durable. It does not expose a first-class turn object: the initiating user
119 * message is the best native turn correlation point, and session idle is the
120 * authoritative terminal signal.
121 */
122const OpenCodeProviderCapabilitiesV2 = {
123 sessions: {
124 // The current adapter owns one directory-bound client/server per session.
125 // Keep it isolated until its runtime is made safe for cross-thread pooling.
126 supportsMultipleProviderThreadsPerSession: false,
127 supportsModelSwitchInSession: true,
128 supportsProviderSwitchingViaHandoff: true,
129 supportsRuntimeModeSwitchInSession: false,
130 pendingRequestsSurviveRestart: false,
131 },
132 threads: {
133 canCreateEmptyThread: true,
134 canReadThreadSnapshot: true,
135 canRollbackThread: true,
136 canForkThread: true,
137 canForkFromTurn: true,
138 canForkFromSubagentThread: true,
139 exposesNativeThreadId: true,
140 },
141 turns: {
142 exposesNativeTurnId: false,
143 emitsTurnStarted: true,
144 emitsTurnCompleted: true,
145 supportsInterrupt: true,
146 supportsActiveSteering: true,
147 supportsSteeringByInterruptRestart: true,
148 supportsQueuedMessages: true,
149 terminalStatusQuality: "strong",
150 },
151 streaming: {
152 streamsAssistantText: true,
153 streamsReasoning: true,
154 streamsToolOutput: true,
155 streamsPlanText: false,
156 emitsMessageCompleted: true,
157 },
158 tools: {
159 exposesToolItemIds: true,
160 emitsToolStarted: true,
161 emitsToolCompleted: true,
162 emitsToolOutput: true,
163 supportsMcpTools: true,
164 supportsDynamicToolCallbacks: false,
165 },
166 approvals: {
167 supportsCommandApproval: true,
168 supportsFileReadApproval: true,
169 supportsFileChangeApproval: true,
170 supportsApplyPatchApproval: true,
171 approvalsHaveNativeRequestIds: true,
172 approvalCallbacksAreLiveOnly: true,
173 approvalsCanOriginateFromSubagents: true,
174 },
175 planning: {
176 emitsPlanUpdated: true,
177 emitsTodoList: true,
178 emitsProposedPlan: false,
179 supportsStructuredQuestions: true,
180 planDeltasHaveItemIds: false,
181 },
182 subagents: {
183 supportsSubagents: true,
184 exposesSubagentThreadIds: true,
185 emitsSubagentLifecycle: true,
186 canWaitForSubagents: true,
187 canCloseSubagents: false,
188 canForkSubagentThread: true,
189 },
190 context: {
191 acceptsSystemContext: false,
192 acceptsDeveloperContext: false,
193 acceptsSyntheticUserContext: true,
194 canGenerateSummaries: true,
195 canConsumeHandoffSummaries: true,
196 supportsDeltaHandoff: true,
197 supportsFullThreadHandoff: true,
198 maxRecommendedHandoffChars: null,
199 },
200 checkpointing: {
201 appCanCheckpointFilesystem: true,
202 supportsNestedCheckpointScopes: true,
203 providerCanRollbackConversation: true,
204 providerRollbackReturnsSnapshot: true,
205 providerCanReadConversationSnapshot: true,
206 },
207 identity: {
208 nativeThreadIds: "strong",
209 nativeTurnIds: "weak",
210 nativeItemIds: "strong",
211 nativeRequestIds: "strong",
212 },
213 runtimePolicy: {
214 enforcement: "native",
215 },
216} satisfies OrchestrationV2ProviderCapabilities;
217
218type TerminalTurnStatus = Extract<
219 OrchestrationV2ProviderTurn["status"],
220 "completed" | "interrupted" | "failed" | "cancelled"
221>;
222
223type OpenCodeStepUsage = Pick<
224 Extract<OpenCodePart, { readonly type: "step-finish" }>,
225 "id" | "tokens"
226>;
227
228interface OpenCodeTurnTokenUsageAccumulator {
229 readonly partIds: Set<string>;
230 readonly promptMessageIds: Set<string>;
231 readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">;
232 // Native removal does not undo usage. Keep unresolved counts until this turn settles.
233 readonly unresolvedStepsByMessageId: Map<string, Map<string, OpenCodeStepUsage>>;
234 inputTokens: number;
235 cachedInputTokens: number;
236 cacheCreationTokens: number;
237 outputTokens: number;
238 reasoningTokens: number;
239 hasSubagents: boolean;
240 complete: boolean;
241}
242
243function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator {
244 return {
245 partIds: new Set(),
246 promptMessageIds: new Set(),
247 assistantOwnershipByMessageId: new Map(),
248 unresolvedStepsByMessageId: new Map(),
249 inputTokens: 0,
250 cachedInputTokens: 0,
251 cacheCreationTokens: 0,
252 outputTokens: 0,
253 reasoningTokens: 0,
254 hasSubagents: false,
255 complete: true,
256 };
257}
258
259function accumulateOpenCodeStepUsage(
260 accumulator: OpenCodeTurnTokenUsageAccumulator,
261 part: OpenCodeStepUsage,
262): void {
263 if (accumulator.partIds.has(part.id)) return;
264 accumulator.partIds.add(part.id);
265 accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write;
266 accumulator.cachedInputTokens += part.tokens.cache.read;
267 accumulator.cacheCreationTokens += part.tokens.cache.write;
268 accumulator.outputTokens += part.tokens.output + part.tokens.reasoning;
269 accumulator.reasoningTokens += part.tokens.reasoning;
270}
271
272interface ActiveOpenCodeTurn {
273 readonly usage: OpenCodeTurnTokenUsageAccumulator;
274 readonly isRoot: boolean;
275 readonly threadId: ThreadId;
276 readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null;
277 readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"];
278 readonly appThread: OrchestrationV2AppThread;
279 readonly modelSelection: ModelSelection;
280 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
281 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
282 /** The provider thread the turn started on, which its terminal names. */
283 readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"];
284 readonly providerTurnOrdinal: number;
285 readonly runOrdinal: number;
286 readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"];
287 readonly startedAt: DateTime.Utc;
288 readonly itemOrdinals: Map<string, number>;
289 readonly parts: Map<string, Exclude<OpenCodePart, ToolPart>>;
290 readonly partIdsByMessage: Map<string, Set<string>>;
291 readonly toolNamesByCallId: Map<string, string>;
292 mcpServerNames?: ReadonlyArray<string>;
293 readonly providerTurn: OrchestrationV2ProviderTurn;
294 nextItemOrdinal: number;
295 nativeUserMessageId: string | null;
296 admissionMessageId: string | null;
297 interrupted: boolean;
298 finalized: boolean;
299 planId: PlanId | null;
300 admissionGeneration: number;
301 admissionReconciliationGeneration: number | null;
302 admissionPending: boolean;
303 admissionAccepted: boolean;
304 admissionMessageObserved: boolean;
305 idleDuringAdmission: boolean;
306 admissionSettled: Deferred.Deferred<void>;
307 admissionAbortController: AbortController | null;
308}
309
310type OpenCodeAdmissionSignal =
311 | "accepted"
312 | "assistant-completed"
313 | "busy"
314 | "idle"
315 | "user-message";
316type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release";
317
318export function advanceOpenCodePromptAdmission(
319 admission: Pick<
320 ActiveOpenCodeTurn,
321 "admissionAccepted" | "admissionMessageObserved" | "admissionPending" | "idleDuringAdmission"
322 >,
323 signal: OpenCodeAdmissionSignal,
324): OpenCodeAdmissionAction {
325 if (!admission.admissionPending) return "release";
326 if (signal === "assistant-completed") {
327 admission.admissionAccepted = true;
328 admission.admissionMessageObserved = true;
329 admission.admissionPending = false;
330 return "release";
331 }
332 if (signal === "idle") {
333 admission.idleDuringAdmission = true;
334 return "hold";
335 }
336 if (signal === "accepted") admission.admissionAccepted = true;
337 if (signal === "busy" || signal === "user-message") admission.admissionMessageObserved = true;
338 if (!admission.admissionAccepted || !admission.admissionMessageObserved) return "hold";
339 if (admission.idleDuringAdmission) return "reconcile-idle";
340 admission.admissionPending = false;
341 return "release";
342}
343
344export function cancelOpenCodePromptAdmission(
345 admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">,
346 nextGeneration: number,
347): void {
348 admission.admissionGeneration = nextGeneration;
349 admission.admissionPending = false;
350}
351
352export const reconcileOpenCodePromptAdmissionStatus = Effect.fn(
353 "reconcileOpenCodePromptAdmissionStatus",
354)(function* (
355 admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">,
356 generation: number,
357 readStatus: Effect.Effect<"busy" | "idle" | "unknown">,
358) {
359 if (admission.admissionGeneration !== generation || !admission.admissionPending) {
360 return "stale" as const;
361 }
362 const status = yield* readStatus;
363 if (admission.admissionGeneration !== generation || !admission.admissionPending) {
364 return "stale" as const;
365 }
366 if (status !== "unknown") admission.admissionPending = false;
367 return status;
368});
369
370interface OpenCodeSubagentContext {
371 readonly nativeItemId: string;
372 readonly nodeId: OrchestrationV2Subagent["id"];
373 readonly parentState: OpenCodeThreadState;
374 readonly parentTurn: ActiveOpenCodeTurn;
375 readonly prompt: string;
376 readonly title: string | null;
377 readonly startedAt: DateTime.Utc;
378 childSessionId: string | null;
379 childThreadId: ThreadId | null;
380 childProviderThreadId: OrchestrationV2ProviderThread["id"] | null;
381 model: string | null;
382 result: string | null;
383}
384
385interface OpenCodeThreadState {
386 readonly nativeSessionId: string;
387 providerThread: OrchestrationV2ProviderThread;
388 appThread: OrchestrationV2AppThread | null;
389 activeTurn: ActiveOpenCodeTurn | null;
390 readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>;
391 readonly messages: Map<string, OrchestrationV2ConversationMessage>;
392 readonly runtimeRequests: Map<string, OrchestrationV2RuntimeRequest>;
393 readonly messageRoles: Map<string, "user" | "assistant">;
394 readonly userMessageIds: Array<string>;
395 parentSubagent: OpenCodeSubagentContext | null;
396 nextChildTurnOrdinal: number;
397 nextAdmissionGeneration: number;
398}
399
400interface OpenCodeRequestOwner {
401 readonly state: OpenCodeThreadState;
402 readonly turn: ActiveOpenCodeTurn;
403 /** The subagent on the owner's thread that leads to the asking session. */
404 readonly subagent: OpenCodeSubagentContext | null;
405}
406
407/**
408 * The top-level thread and active turn that own a session's requests, walking
409 * up through (possibly nested) subagents.
410 */
411function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined {
412 let owner = state;
413 let subagent: OpenCodeSubagentContext | null = null;
414 while (owner.parentSubagent !== null) {
415 subagent = owner.parentSubagent;
416 owner = subagent.parentState;
417 }
418 return owner.activeTurn === null ? undefined : { state: owner, turn: owner.activeTurn, subagent };
419}
420
421interface PendingOpenCodeRequest {
422 readonly requestId: RuntimeRequestId;
423 readonly nativeRequestId: string;
424 /** The session that asked, which may be a subagent's. */
425 readonly nativeSessionId: string;
426 readonly turn: ActiveOpenCodeTurn;
427 readonly state: OpenCodeThreadState;
428 readonly nodeId: OrchestrationV2ExecutionNode["id"];
429 readonly parentNodeId: OrchestrationV2ExecutionNode["id"];
430 readonly turnItemId: OrchestrationV2TurnItem["id"];
431 readonly requestKind: OpenCodePermissionRequestKind | "user_input";
432 readonly createdAt: DateTime.Utc;
433 readonly permission?: PermissionRequest;
434 readonly question?: QuestionRequest;
435}
436
437export interface OpenCodeAdapterV2Options {
438 readonly instanceId: ProviderInstanceId;
439 readonly settings: OpenCodeSettings;
440 readonly environment: NodeJS.ProcessEnv;
441 readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape;
442 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
443 readonly host: ProviderHost.ProviderHostShape;
444 readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger;
445}
446
447export interface OpenCodeProtocolLogEvent {
448 readonly direction: "incoming" | "outgoing";
449 readonly messageKind: "request" | "response" | "notification" | "error";
450 readonly method: string;
451 readonly payload: unknown;
452}
453
454function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) {
455 return {
456 direction: event.direction,
457 messageKind: event.messageKind,
458 method: structuralProtocolMethod(event.method),
459 payload: summarizeNativeProtocolPayload(event.payload),
460 };
461}
462
463export function makeOpenCodeProtocolLogger(input: {
464 readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined;
465 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
466 readonly providerInstanceId: ProviderInstanceId;
467 readonly providerSessionId: ProviderSessionId;
468 readonly threadId: ThreadId;
469}): (event: OpenCodeProtocolLogEvent) => Effect.Effect<void, never> {
470 return (event) =>
471 Effect.gen(function* () {
472 if (!input.nativeEventLogger) return;
473 const observedAt = DateTime.formatIso(yield* DateTime.now);
474 const method = structuralProtocolMethod(event.method);
475 yield* input.nativeEventLogger.write(
476 {
477 observedAt,
478 event: {
479 id: yield* input.idAllocator.allocate.rawEvent({
480 providerSessionId: input.providerSessionId,
481 method,
482 }),
483 kind: "protocol",
484 protocol: OPENCODE_SDK_PROTOCOL,
485 provider: OPENCODE_PROVIDER,
486 providerInstanceId: input.providerInstanceId,
487 providerSessionId: input.providerSessionId,
488 createdAt: observedAt,
489 threadId: input.threadId,
490 payload: formatOpenCodeProtocolLogPayload(event),
491 },
492 },
493 input.threadId,
494 );
495 }).pipe(
496 Effect.catchCause((cause) =>
497 Cause.hasInterrupts(cause)
498 ? Effect.interrupt
499 : Effect.logWarning("Failed to write native OpenCode event log.", {
500 errorTag: causeErrorTag(cause),
501 reasonCount: cause.reasons.length,
502 provider: OPENCODE_PROVIDER,
503 threadId: input.threadId,
504 }),
505 ),
506 );
507}
508
509function protocolError(
510 detail: string,
511 payload?: unknown,
512): ProviderAdapter.ProviderAdapterProtocolError {
513 return new ProviderAdapter.ProviderAdapterProtocolError({
514 driver: OPENCODE_PROVIDER,
515 detail,
516 ...(payload === undefined ? {} : { payload }),
517 });
518}
519
520function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string {
521 const nativeId = providerThread.nativeThreadRef?.nativeId;
522 if (nativeId === null || nativeId === undefined) {
523 throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`);
524 }
525 return nativeId;
526}
527
528function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc {
529 if (value === undefined) return fallback;
530 return Option.getOrElse(DateTime.make(value), () => fallback);
531}
532
533function nonEmptyString(value: unknown): string | undefined {
534 return typeof value === "string" && value.trim().length > 0 ? value : undefined;
535}
536
537function recordValue(input: unknown, key: string): unknown {
538 return typeof input === "object" && input !== null && key in input
539 ? (input as Record<string, unknown>)[key]
540 : undefined;
541}
542
543function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined {
544 for (const key of keys) {
545 const value = nonEmptyString(recordValue(input, key));
546 if (value !== undefined) return value;
547 }
548 return undefined;
549}
550
551function sdkResponseForRawLog(value: unknown): unknown {
552 if (typeof value !== "object" || value === null) return value;
553 if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null };
554 if ("stream" in value) return { subscribed: true };
555 return value;
556}
557
558type OpenCodePermissionRequestKind = Extract<
559 ProviderRequestKind,
560 "command" | "file-read" | "file-change"
561>;
562
563export function openCodePermissionRequestKind(
564 permission: string,
565 toolName?: string,
566): OpenCodePermissionRequestKind {
567 const normalized = permission.toLowerCase();
568 const normalizedTool = toolName?.toLowerCase() ?? "";
569 if (
570 normalized === "edit" ||
571 normalized === "write" ||
572 normalized === "patch" ||
573 normalized === "apply_patch" ||
574 normalizedTool === "edit" ||
575 normalizedTool === "write" ||
576 normalizedTool === "patch" ||
577 normalizedTool === "apply_patch"
578 ) {
579 return "file-change";
580 }
581 if (
582 normalized === "read" ||
583 normalized === "glob" ||
584 normalized === "grep" ||
585 normalized === "lsp" ||
586 normalized === "external_directory" ||
587 normalizedTool === "read" ||
588 normalizedTool.includes("glob") ||
589 normalizedTool.includes("grep") ||
590 normalizedTool.includes("search")
591 ) {
592 return "file-read";
593 }
594 return "command";
595}
596
597const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [
598 "question",
599 "read",
600 "glob",
601 "grep",
602 "lsp",
603 "todowrite",
604 "task",
605 "skill",
606] as const;
607
608const OPENCODE_RESTRICTED_PERMISSIONS = [
609 "bash",
610 "edit",
611 "webfetch",
612 "websearch",
613 "codesearch",
614 "external_directory",
615 "doom_loop",
616] as const;
617
618/**
619 * OpenCode does not provide an OS sandbox, so permission rules are also the
620 * enforcement boundary for non-interactive policies. Read/planning tools are
621 * safe by default; edits are auto-approved only for workspace-write modes,
622 * while shell/network/external access remains gated unless policy explicitly
623 * allows it.
624 */
625export function openCodePermissionRules(
626 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
627): PermissionRuleset {
628 const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy");
629 const sandboxType = recordString(sandboxPolicy, "type");
630 const rawApprovalPolicy = runtimePolicy.approvalPolicy;
631 const approvalPolicy = nonEmptyString(rawApprovalPolicy);
632 const requiresApproval =
633 approvalPolicy === undefined
634 ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) ||
635 runtimePolicy.runtimeMode !== "full-access"
636 : approvalPolicy !== "never";
637 const externallySandboxed = sandboxType === "externalSandbox";
638 const dangerFullAccess = sandboxType === "dangerFullAccess";
639 const implicitFullAccess =
640 sandboxType === undefined && runtimePolicy.runtimeMode === "full-access";
641
642 if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) {
643 return [{ permission: "*", pattern: "*", action: "allow" }];
644 }
645
646 // Task sessions initially inherit only parent deny rules. Seed explicit
647 // denies before the effective ask/allow overrides so a child is safe during
648 // the short interval before emitSubagent installs its complete policy.
649 const rules: PermissionRuleset = [
650 { permission: "*", pattern: "*", action: "deny" },
651 ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({
652 permission,
653 pattern: "*",
654 action: "deny" as const,
655 })),
656 ];
657
658 if (requiresApproval) {
659 rules.push({ permission: "*", pattern: "*", action: "ask" });
660 for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) {
661 rules.push({ permission, pattern: "*", action: "ask" });
662 }
663 }
664
665 rules.push(
666 ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({
667 permission,
668 pattern: "*",
669 action: "allow" as const,
670 })),
671 );
672
673 rules.push(
674 { permission: "read", pattern: "*.env", action: requiresApproval ? "ask" : "deny" },
675 { permission: "read", pattern: "*.env.*", action: requiresApproval ? "ask" : "deny" },
676 { permission: "read", pattern: "*.env.example", action: "allow" },
677 );
678
679 if (
680 runtimePolicy.runtimeMode === "auto-accept-edits" ||
681 (!requiresApproval && sandboxType === "workspaceWrite")
682 ) {
683 rules.push({ permission: "edit", pattern: "*", action: "allow" });
684 }
685
686 if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) {
687 for (const permission of ["webfetch", "websearch", "codesearch"] as const) {
688 rules.push({ permission, pattern: "*", action: "allow" });
689 }
690 }
691
692 if (!requiresApproval && sandboxType === "readOnly") {
693 const access = recordValue(sandboxPolicy, "access");
694 if (recordString(access, "type") === "fullAccess") {
695 rules.push({ permission: "external_directory", pattern: "*", action: "allow" });
696 }
697 }
698
699 if (!requiresApproval && sandboxType === "workspaceWrite") {
700 const writableRoots = recordValue(sandboxPolicy, "writableRoots");
701 if (Array.isArray(writableRoots)) {
702 for (const root of writableRoots) {
703 if (typeof root === "string" && root.trim().length > 0) {
704 rules.push({
705 permission: "external_directory",
706 pattern: `${root.replace(/\/$/, "")}/*`,
707 action: "allow",
708 });
709 }
710 }
711 }
712 }
713
714 return rules;
715}
716
717function permissionRuleEquals(
718 left: PermissionRuleset[number],
719 right: PermissionRuleset[number],
720): boolean {
721 return (
722 left.permission === right.permission &&
723 left.pattern === right.pattern &&
724 left.action === right.action
725 );
726}
727
728/**
729 * OpenCode task sessions inherit only the parent's deny/external-directory
730 * rules and then add agent-specific restrictions such as disabling nested
731 * tasks. Install the complete parent policy while retaining only rules that
732 * were added specifically for the selected child agent.
733 */
734export function openCodeChildPermissionRules(
735 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
736 nativeChildRules: PermissionRuleset,
737): PermissionRuleset {
738 const parentRules = openCodePermissionRules(runtimePolicy);
739 const inheritedRules = parentRules.filter(
740 (rule) => rule.permission === "external_directory" || rule.action === "deny",
741 );
742 const childSpecificRules = nativeChildRules.filter(
743 (childRule) =>
744 !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)),
745 );
746 return [...parentRules, ...childSpecificRules];
747}
748
749/**
750 * OpenCode's fork/revert boundary is exclusive. To retain the selected app
751 * turn, address the next native user message; omitting a boundary retains the
752 * current head when the selected turn is already last.
753 */
754export function openCodeBoundaryAfterProviderTurn(
755 providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>,
756 selectedProviderTurnId: OrchestrationV2ProviderTurn["id"],
757): string | undefined {
758 const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId);
759 if (selected === undefined) return undefined;
760 return providerTurns
761 .filter((turn) => turn.ordinal > selected.ordinal)
762 .toSorted((left, right) => left.ordinal - right.ordinal)
763 .map((turn) => turn.nativeTurnRef?.nativeId)
764 .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined);
765}
766
767function toolStatus(part: ToolPart): {
768 readonly node: OrchestrationV2ExecutionNode["status"];
769 readonly item: OrchestrationV2TurnItem["status"];
770} {
771 switch (part.state.status) {
772 case "pending":
773 return { node: "pending", item: "pending" };
774 case "running":
775 return { node: "running", item: "running" };
776 case "completed":
777 return { node: "completed", item: "completed" };
778 case "error":
779 return { node: "failed", item: "failed" };
780 }
781}
782
783function toolInput(part: ToolPart): Record<string, unknown> {
784 return part.state.input;
785}
786
787function toolOutput(part: ToolPart): string | undefined {
788 if (part.state.status === "completed") return part.state.output;
789 if (part.state.status === "error") return part.state.error;
790 return undefined;
791}
792
793function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc {
794 return dateTimeFromEpoch(
795 part.state.status === "pending" ? undefined : part.state.time.start,
796 now,
797 );
798}
799
800function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null {
801 return part.state.status === "completed" || part.state.status === "error"
802 ? dateTimeFromEpoch(part.state.time.end, now)
803 : null;
804}
805
806function toolTitle(part: ToolPart): string | null {
807 return part.state.status === "running" || part.state.status === "completed"
808 ? (part.state.title ?? null)
809 : null;
810}
811
812function toolModel(part: ToolPart): string | null {
813 const metadata =
814 part.state.status === "running" ||
815 part.state.status === "completed" ||
816 part.state.status === "error"
817 ? part.state.metadata
818 : undefined;
819 const model = recordValue(metadata, "model");
820 const providerId = recordString(model, "providerID", "providerId");
821 const modelId = recordString(model, "modelID", "modelId", "id");
822 return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null;
823}
824
825function taskSessionId(part: ToolPart): string | null {
826 const metadata =
827 part.state.status === "running" ||
828 part.state.status === "completed" ||
829 part.state.status === "error"
830 ? part.state.metadata
831 : undefined;
832 return recordString(metadata, "sessionId", "sessionID") ?? null;
833}
834
835function makeProviderThread(input: {
836 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
837 readonly providerInstanceId: ProviderInstanceId;
838 readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
839 readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
840 readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
841 readonly nativeSession: OpenCodeSession;
842 readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"];
843 readonly now: DateTime.Utc;
844}): OrchestrationV2ProviderThread {
845 const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now);
846 return {
847 id: input.idAllocator.derive.providerThread({
848 driver: OPENCODE_PROVIDER,
849 nativeThreadId: input.nativeSession.id,
850 }),
851 driver: OPENCODE_PROVIDER,
852 providerInstanceId: input.providerInstanceId,
853 providerSessionId: input.providerSessionId,
854 appThreadId: input.appThreadId,
855 ownerNodeId: input.ownerNodeId ?? null,
856 nativeThreadRef: {
857 driver: OPENCODE_PROVIDER,
858 nativeId: input.nativeSession.id,
859 strength: "strong",
860 },
861 nativeConversationHeadRef: null,
862 status: "idle",
863 firstRunOrdinal: null,
864 lastRunOrdinal: null,
865 handoffIds: [],
866 forkedFrom: input.forkedFrom ?? null,
867 createdAt,
868 updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now),
869 };
870}
871
872function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") {
873 return {
874 driver: OPENCODE_PROVIDER,
875 nativeId,
876 strength,
877 } satisfies OrchestrationV2ProviderRef;
878}
879
880function openCodeErrorMessage(event: Extract<OpenCodeEvent, { type: "session.error" }>): string {
881 const error = event.properties.error;
882 if (error === undefined) return "OpenCode session failed without an error payload.";
883 return recordString(error.data, "message") ?? error.name;
884}
885
886function terminalStatusForError(
887 event: Extract<OpenCodeEvent, { type: "session.error" }>,
888 turn: ActiveOpenCodeTurn,
889): TerminalTurnStatus {
890 return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed";
891}
892
893function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean {
894 return event.properties.error?.name === "MessageAbortedError";
895}
896
897function isOpenCodeNotFound(cause: unknown): boolean {
898 const seen = new Set<unknown>();
899 const queue: Array<unknown> = [cause];
900 for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) {
901 const node = queue.shift();
902 if (node === null || typeof node !== "object" || seen.has(node)) {
903 continue;
904 }
905 seen.add(node);
906 const record = node as Record<string, unknown>;
907
908 const response = record.response;
909 const statuses = [
910 record.status,
911 record.statusCode,
912 response !== null && typeof response === "object"
913 ? (response as { readonly status?: unknown }).status
914 : undefined,
915 ].filter((status): status is number => typeof status === "number");
916 if (statuses.includes(404)) {
917 return true;
918 }
919 if (statuses.length > 0) {
920 continue;
921 }
922
923 const name = record.name;
924 if (typeof name === "string" && name.toLowerCase() === "notfounderror") {
925 return true;
926 }
927
928 for (const key of ["cause", "body", "error", "data"] as const) {
929 if (record[key] !== undefined) {
930 queue.push(record[key]);
931 }
932 }
933 }
934 return false;
935}
936
937function unwrapData<A>(operation: string, result: { readonly data?: A }): NonNullable<A> {
938 if (result.data === undefined) {
939 throw new OpenCodeRuntime.OpenCodeRuntimeError({
940 operation,
941 detail: `OpenCode ${operation} returned no response payload.`,
942 });
943 }
944 return result.data as NonNullable<A>;
945}
946
947export function makeOpenCodeAdapterV2(
948 options: OpenCodeAdapterV2Options,
949): ProviderAdapter.ProviderAdapterV2Shape {
950 const { idAllocator, runtime, host } = options;
951
952 return ProviderAdapter.ProviderAdapterV2.of({
953 instanceId: options.instanceId,
954 driver: OPENCODE_PROVIDER,
955 getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2),
956 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
957 openSession: Effect.fn("OpenCodeAdapterV2.openSession")(
958 function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) {
959 const scope = yield* Effect.scope;
960 const cwd = input.runtimePolicy.cwd ?? host.paths.cwd;
961 const connection = yield* runtime.connectToOpenCodeServer({
962 binaryPath: options.settings.binaryPath,
963 directory: cwd,
964 serverUrl: options.settings.serverUrl,
965 environment: options.environment,
966 });
967 const client = runtime.createOpenCodeSdkClient({
968 baseUrl: connection.url,
969 directory: cwd,
970 ...(connection.external && options.settings.serverPassword
971 ? { serverPassword: options.settings.serverPassword }
972 : {}),
973 });
974
975 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
976 const hasT3Mcp = mcpSession !== undefined && !connection.external;
977 const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp);
978 if (hasT3Mcp) {
979 yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () =>
980 client.mcp.add({
981 name: "t3-code",
982 config: {
983 type: "remote",
984 url: mcpSession.endpoint,
985 headers: { Authorization: mcpSession.authorizationHeader },
986 oauth: false,
987 },
988 }),
989 );
990 }
991
992 const now = yield* DateTime.now;
993 let sessionEntity: OrchestrationV2ProviderSession = {
994 id: input.providerSessionId,
995 driver: OPENCODE_PROVIDER,
996 providerInstanceId: options.instanceId,
997 status: "ready",
998 cwd,
999 model: input.modelSelection.model,
1000 capabilities: OpenCodeProviderCapabilitiesV2,
1001 createdAt: now,
1002 updatedAt: now,
1003 lastError: null,
1004 };
1005 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>();
1006 let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null;
1007 const threads = new Map<string, OpenCodeThreadState>();
1008 const commandReceipts = new Map<string, Deferred.Deferred<void>>();
1009 const commandControllers = new Map<string, Set<AbortController>>();
1010 const pendingRequests = new Map<string, PendingOpenCodeRequest>();
1011 const pendingRequestsByNativeId = new Map<string, PendingOpenCodeRequest>();
1012 const subagentsByNativeItemId = new Map<string, OpenCodeSubagentContext>();
1013 const subagentsByChildSessionId = new Map<string, OpenCodeSubagentContext>();
1014 // Permission and question requests can originate from child sessions
1015 // (task subagents and their descendants). Related sessions map back
1016 // to the root thread state whose active turn owns the request; asks
1017 // arriving before the relation is known resolve it via session.get.
1018 const relatedSessionOwners = new Map<string, OpenCodeThreadState>();
1019 // Sessions of this runtime that OpenCode reports busy. A background
1020 // task child keeps running after its parent turn settles, so idle
1021 // release must not close the server under it.
1022 const busySessionIds = new Set<string>();
1023 // Requests settled before their session relation resolved: a late
1024 // routing attempt must never resurrect them. Bounded because entries
1025 // only matter for the seconds a routing retry can still be running.
1026 const settledNativeRequestIds = new Set<string>();
1027 const pendingChildRequestRoutes = new Set<string>();
1028 const rememberSettledRequest = (nativeRequestId: string) => {
1029 if (settledNativeRequestIds.size >= 2_048) settledNativeRequestIds.clear();
1030 settledNativeRequestIds.add(nativeRequestId);
1031 };
1032 const abortController = new AbortController();
1033 let closing = false;
1034 let hasConnected = false;
1035
1036 const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) =>
1037 Queue.offer(events, event).pipe(Effect.asVoid);
1038
1039 const logProtocolEvent = makeOpenCodeProtocolLogger({
1040 nativeEventLogger: options.nativeEventLogger,
1041 idAllocator,
1042 providerInstanceId: options.instanceId,
1043 providerSessionId: input.providerSessionId,
1044 threadId: input.threadId,
1045 });
1046
1047 const sdkCall = <A>(
1048 method: string,
1049 payload: unknown,
1050 call: (signal: AbortSignal) => Promise<A>,
1051 ): Effect.Effect<A, OpenCodeRuntime.OpenCodeRuntimeError> =>
1052 logProtocolEvent({
1053 direction: "outgoing",
1054 messageKind: "request",
1055 method,
1056 payload,
1057 }).pipe(
1058 Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)),
1059 Effect.tap((response) =>
1060 logProtocolEvent({
1061 direction: "incoming",
1062 messageKind: "response",
1063 method,
1064 payload: sdkResponseForRawLog(response),
1065 }),
1066 ),
1067 );
1068
1069 const abortDescendants = (rootId: string) =>
1070 Effect.gen(function* () {
1071 const visited = new Set([rootId]);
1072 const semaphore = Semaphore.makeUnsafe(8);
1073 const visit = (
1074 sessionId: string,
1075 abort: boolean,
1076 ): Effect.Effect<OpenCodeRuntime.OpenCodeRuntimeError | undefined> =>
1077 Effect.gen(function* () {
1078 const abortResult = abort
1079 ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) =>
1080 client.session.abort({ sessionID: sessionId }, { signal }),
1081 ).pipe(
1082 semaphore.withPermit,
1083 Effect.catchIf(isOpenCodeNotFound, () => Effect.void),
1084 Effect.result,
1085 )
1086 : undefined;
1087 const childrenResult = yield* sdkCall(
1088 "session.children",
1089 { sessionID: sessionId },
1090 (signal) => client.session.children({ sessionID: sessionId }, { signal }),
1091 ).pipe(
1092 semaphore.withPermit,
1093 Effect.catchIf(isOpenCodeNotFound, () => Effect.void),
1094 Effect.result,
1095 );
1096 const firstFailure =
1097 abortResult?._tag === "Failure" ? abortResult.failure : undefined;
1098 if (childrenResult._tag === "Failure")
1099 return firstFailure ?? childrenResult.failure;
1100 const fresh = (childrenResult.success?.data ?? []).filter((child) => {
1101 if (visited.has(child.id)) return false;
1102 visited.add(child.id);
1103 return true;
1104 });
1105 const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), {
1106 concurrency: 8,
1107 });
1108 return firstFailure ?? failures.find((failure) => failure !== undefined);
1109 });
1110 const failure = yield* visit(rootId, false);
1111 if (failure) return yield* Effect.fail(failure);
1112 }).pipe(Effect.timeout("15 seconds"));
1113
1114 const updateProviderSession = (
1115 status: OrchestrationV2ProviderSession["status"],
1116 lastError: string | null = sessionEntity.lastError,
1117 ) =>
1118 Effect.gen(function* () {
1119 const updatedAt = yield* DateTime.now;
1120 sessionEntity = { ...sessionEntity, status, lastError, updatedAt };
1121 yield* emitProviderEvent({
1122 type: "provider_session.updated",
1123 driver: OPENCODE_PROVIDER,
1124 providerSession: sessionEntity,
1125 });
1126 });
1127
1128 const updateProviderThread = (
1129 state: OpenCodeThreadState,
1130 patch: Partial<OrchestrationV2ProviderThread>,
1131 ) =>
1132 Effect.gen(function* () {
1133 const updatedAt = yield* DateTime.now;
1134 state.providerThread = { ...state.providerThread, ...patch, updatedAt };
1135 yield* emitProviderEvent({
1136 type: "provider_thread.updated",
1137 driver: OPENCODE_PROVIDER,
1138 providerThread: state.providerThread,
1139 });
1140 });
1141
1142 const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => {
1143 const existing = turn.itemOrdinals.get(nativeItemId);
1144 if (existing !== undefined) return existing;
1145 const ordinal = turn.nextItemOrdinal++;
1146 turn.itemOrdinals.set(nativeItemId, ordinal);
1147 return ordinal;
1148 };
1149
1150 const emitProviderTurn = (
1151 state: OpenCodeThreadState,
1152 turn: ActiveOpenCodeTurn,
1153 status: OrchestrationV2ProviderTurn["status"],
1154 completedAt: DateTime.Utc | null,
1155 ) => {
1156 const providerTurn: OrchestrationV2ProviderTurn = {
1157 ...turn.providerTurn,
1158 nativeTurnRef:
1159 turn.nativeUserMessageId === null
1160 ? turn.providerTurn.nativeTurnRef
1161 : providerRef(turn.nativeUserMessageId, "weak"),
1162 status,
1163 completedAt,
1164 ...(completedAt === null
1165 ? {}
1166 : {
1167 turnTokenUsage:
1168 turn.usage.partIds.size === 0
1169 ? {
1170 usageScope: "main_agent" as const,
1171 usageStatus: "unavailable" as const,
1172 hasSubagents: turn.usage.hasSubagents,
1173 }
1174 : {
1175 usageScope: "main_agent" as const,
1176 usageStatus:
1177 status === "completed" &&
1178 turn.usage.complete &&
1179 turn.usage.unresolvedStepsByMessageId.size === 0
1180 ? ("complete" as const)
1181 : ("partial" as const),
1182 inputTokens: turn.usage.inputTokens,
1183 cachedInputTokens: turn.usage.cachedInputTokens,
1184 cacheCreationTokens: turn.usage.cacheCreationTokens,
1185 outputTokens: turn.usage.outputTokens,
1186 reasoningTokens: turn.usage.reasoningTokens,
1187 hasSubagents: turn.usage.hasSubagents,
1188 },
1189 }),
1190 };
1191 Object.assign(turn.providerTurn, providerTurn);
1192 state.providerTurns.set(String(providerTurn.id), providerTurn);
1193 return emitProviderEvent({
1194 type: "provider_turn.updated",
1195 driver: OPENCODE_PROVIDER,
1196 threadId: turn.threadId,
1197 providerTurn,
1198 });
1199 };
1200
1201 const emitTextPart = Effect.fnUntraced(function* (
1202 state: OpenCodeThreadState,
1203 turn: ActiveOpenCodeTurn,
1204 part: Extract<OpenCodePart, { type: "text" | "reasoning" }>,
1205 forceCompleted = false,
1206 ) {
1207 if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return;
1208 if (part.text.length === 0) return;
1209 const emittedAt = yield* DateTime.now;
1210 const isCompleted = forceCompleted || part.time?.end !== undefined;
1211 const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt);
1212 const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null;
1213 const nativeItemRef = providerRef(part.id);
1214 const nodeId = idAllocator.derive.nodeFromProviderItem({
1215 driver: OPENCODE_PROVIDER,
1216 nativeItemId: part.id,
1217 });
1218 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1219 driver: OPENCODE_PROVIDER,
1220 nativeItemId: part.id,
1221 });
1222 const ordinal = itemOrdinal(turn, part.id);
1223 yield* emitProviderEvent({
1224 type: "node.updated",
1225 driver: OPENCODE_PROVIDER,
1226 node: {
1227 id: nodeId,
1228 threadId: turn.threadId,
1229 runId: turn.runId,
1230 parentNodeId: turn.rootNodeId,
1231 rootNodeId: turn.rootNodeId,
1232 kind: part.type === "text" ? "assistant_message" : "reasoning",
1233 status: isCompleted ? "completed" : "running",
1234 countsForRun: false,
1235 providerThreadId: state.providerThread.id,
1236 providerTurnId: turn.providerTurnId,
1237 nativeItemRef,
1238 runtimeRequestId: null,
1239 checkpointScopeId: null,
1240 startedAt,
1241 completedAt,
1242 },
1243 });
1244 if (part.type === "text") {
1245 const messageId = idAllocator.derive.messageFromProviderItem({
1246 driver: OPENCODE_PROVIDER,
1247 nativeItemId: part.id,
1248 });
1249 const message: OrchestrationV2ConversationMessage = {
1250 createdBy: "agent",
1251 creationSource: "provider",
1252 id: messageId,
1253 threadId: turn.threadId,
1254 runId: turn.runId,
1255 nodeId,
1256 role: "assistant",
1257 text: part.text,
1258 attachments: [],
1259 streaming: !isCompleted,
1260 createdAt: startedAt,
1261 updatedAt: emittedAt,
1262 };
1263 state.messages.set(String(message.id), message);
1264 yield* emitProviderEvent({
1265 type: "message.updated",
1266 driver: OPENCODE_PROVIDER,
1267 message,
1268 });
1269 yield* emitProviderEvent({
1270 type: "turn_item.updated",
1271 driver: OPENCODE_PROVIDER,
1272 turnItem: {
1273 id: turnItemId,
1274 threadId: turn.threadId,
1275 runId: turn.runId,
1276 nodeId,
1277 providerThreadId: state.providerThread.id,
1278 providerTurnId: turn.providerTurnId,
1279 nativeItemRef,
1280 parentItemId: null,
1281 ordinal,
1282 status: isCompleted ? "completed" : "running",
1283 title: null,
1284 startedAt,
1285 completedAt,
1286 updatedAt: emittedAt,
1287 type: "assistant_message",
1288 messageId,
1289 text: part.text,
1290 streaming: !isCompleted,
1291 },
1292 });
1293 return;
1294 }
1295 yield* emitProviderEvent({
1296 type: "turn_item.updated",
1297 driver: OPENCODE_PROVIDER,
1298 turnItem: {
1299 id: turnItemId,
1300 threadId: turn.threadId,
1301 runId: turn.runId,
1302 nodeId,
1303 providerThreadId: state.providerThread.id,
1304 providerTurnId: turn.providerTurnId,
1305 nativeItemRef,
1306 parentItemId: null,
1307 ordinal,
1308 status: isCompleted ? "completed" : "running",
1309 title: null,
1310 startedAt,
1311 completedAt,
1312 updatedAt: emittedAt,
1313 type: "reasoning",
1314 text: part.text,
1315 streaming: !isCompleted,
1316 },
1317 });
1318 });
1319
1320 const emitSubagent = Effect.fnUntraced(function* (
1321 state: OpenCodeThreadState,
1322 turn: ActiveOpenCodeTurn,
1323 part: ToolPart,
1324 ) {
1325 const now = yield* DateTime.now;
1326 const nativeItemRef = providerRef(part.id);
1327 const nodeId = idAllocator.derive.nodeFromProviderItem({
1328 driver: OPENCODE_PROVIDER,
1329 nativeItemId: part.id,
1330 });
1331 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1332 driver: OPENCODE_PROVIDER,
1333 nativeItemId: part.id,
1334 });
1335 turn.usage.hasSubagents = true;
1336 const input = toolInput(part);
1337 const prompt = recordString(input, "prompt") ?? "";
1338 const title = toolTitle(part) ?? recordString(input, "description") ?? null;
1339 let context = subagentsByNativeItemId.get(part.id);
1340 if (context === undefined) {
1341 context = {
1342 nativeItemId: part.id,
1343 nodeId,
1344 parentState: state,
1345 parentTurn: turn,
1346 prompt,
1347 title,
1348 startedAt: toolStartedAt(part, now),
1349 childSessionId: null,
1350 childThreadId: null,
1351 childProviderThreadId: null,
1352 model: null,
1353 result: null,
1354 };
1355 subagentsByNativeItemId.set(part.id, context);
1356 }
1357 context.model = toolModel(part) ?? context.model;
1358 const childSessionId = taskSessionId(part);
1359 if (childSessionId !== null && context.childSessionId === null) {
1360 context.childSessionId = childSessionId;
1361 context.childThreadId = idAllocator.derive.threadFromProviderThread({
1362 driver: OPENCODE_PROVIDER,
1363 nativeThreadId: childSessionId,
1364 });
1365 context.childProviderThreadId = idAllocator.derive.providerThread({
1366 driver: OPENCODE_PROVIDER,
1367 nativeThreadId: childSessionId,
1368 });
1369 subagentsByChildSessionId.set(childSessionId, context);
1370 relatedSessionOwners.set(childSessionId, state);
1371 const childModelSelection: ModelSelection = {
1372 instanceId: options.instanceId,
1373 model: context.model ?? turn.modelSelection.model,
1374 };
1375 const childThread = makeSubagentChildThread({
1376 parentThread: turn.appThread,
1377 childThreadId: context.childThreadId,
1378 parentNodeId: nodeId,
1379 activeProviderThreadId: context.childProviderThreadId,
1380 providerInstanceId: options.instanceId,
1381 modelSelection: childModelSelection,
1382 title: subagentThreadTitle({
1383 parentTitle: turn.appThread.title,
1384 title,
1385 prompt,
1386 ordinal: itemOrdinal(turn, part.id),
1387 }),
1388 now,
1389 createdBy: "agent",
1390 creationSource: "provider",
1391 });
1392 const childProviderThread: OrchestrationV2ProviderThread = {
1393 id: context.childProviderThreadId,
1394 driver: OPENCODE_PROVIDER,
1395 providerInstanceId: options.instanceId,
1396 providerSessionId: inputProviderSessionId,
1397 appThreadId: context.childThreadId,
1398 ownerNodeId: nodeId,
1399 nativeThreadRef: providerRef(childSessionId),
1400 nativeConversationHeadRef: null,
1401 status: "active",
1402 firstRunOrdinal: null,
1403 lastRunOrdinal: null,
1404 handoffIds: [],
1405 forkedFrom: null,
1406 createdAt: now,
1407 updatedAt: now,
1408 };
1409 const childSessionResponse = yield* sdkCall(
1410 "session.get",
1411 { sessionID: childSessionId },
1412 () => client.session.get({ sessionID: childSessionId }),
1413 );
1414 const nativeChildSession = unwrapData("session.get", childSessionResponse);
1415 const childPermission = openCodeChildPermissionRules(
1416 turn.runtimePolicy,
1417 nativeChildSession.permission ?? [],
1418 );
1419 yield* sdkCall(
1420 "session.update",
1421 { sessionID: childSessionId, permission: childPermission },
1422 () =>
1423 client.session.update({
1424 sessionID: childSessionId,
1425 permission: childPermission,
1426 }),
1427 );
1428 threads.set(childSessionId, {
1429 nativeSessionId: childSessionId,
1430 providerThread: childProviderThread,
1431 appThread: childThread,
1432 activeTurn: null,
1433 providerTurns: new Map(),
1434 messages: new Map(),
1435 runtimeRequests: new Map(),
1436 messageRoles: new Map(),
1437 userMessageIds: [],
1438 parentSubagent: context,
1439 nextChildTurnOrdinal: 1,
1440 nextAdmissionGeneration: 1,
1441 });
1442 yield* emitProviderEvent({
1443 type: "app_thread.created",
1444 driver: OPENCODE_PROVIDER,
1445 appThread: childThread,
1446 });
1447 yield* emitProviderEvent({
1448 type: "provider_thread.updated",
1449 driver: OPENCODE_PROVIDER,
1450 providerThread: childProviderThread,
1451 });
1452 }
1453 const output = toolOutput(part);
1454 if (part.state.status === "completed" && output !== undefined) context.result = output;
1455 const status = toolStatus(part);
1456 const completedAt = toolCompletedAt(part, now);
1457 const subagentStatus: OrchestrationV2Subagent["status"] =
1458 status.item === "failed"
1459 ? "failed"
1460 : status.item === "completed"
1461 ? "completed"
1462 : status.item === "pending"
1463 ? "pending"
1464 : "running";
1465 const subagent: OrchestrationV2Subagent = {
1466 id: nodeId,
1467 threadId: turn.threadId,
1468 runId: turn.runId,
1469 parentNodeId: turn.rootNodeId,
1470 origin: "provider_native",
1471 createdBy: "agent",
1472 driver: OPENCODE_PROVIDER,
1473 providerInstanceId: options.instanceId,
1474 providerThreadId: context.childProviderThreadId,
1475 childThreadId: context.childThreadId,
1476 nativeTaskRef: nativeItemRef,
1477 prompt,
1478 title,
1479 model: context.model,
1480 status: subagentStatus,
1481 result: context.result,
1482 startedAt: context.startedAt,
1483 completedAt,
1484 updatedAt: now,
1485 };
1486 yield* emitProviderEvent({
1487 type: "node.updated",
1488 driver: OPENCODE_PROVIDER,
1489 node: {
1490 id: nodeId,
1491 threadId: turn.threadId,
1492 runId: turn.runId,
1493 parentNodeId: turn.rootNodeId,
1494 rootNodeId: turn.rootNodeId,
1495 kind: "subagent",
1496 status: status.node,
1497 countsForRun: false,
1498 providerThreadId: context.childProviderThreadId ?? state.providerThread.id,
1499 providerTurnId: turn.providerTurnId,
1500 nativeItemRef,
1501 runtimeRequestId: null,
1502 checkpointScopeId: null,
1503 startedAt: context.startedAt,
1504 completedAt,
1505 },
1506 });
1507 yield* emitProviderEvent({
1508 type: "subagent.updated",
1509 driver: OPENCODE_PROVIDER,
1510 subagent,
1511 });
1512 yield* emitProviderEvent({
1513 type: "turn_item.updated",
1514 driver: OPENCODE_PROVIDER,
1515 turnItem: {
1516 id: turnItemId,
1517 threadId: turn.threadId,
1518 runId: turn.runId,
1519 nodeId,
1520 providerThreadId: state.providerThread.id,
1521 providerTurnId: turn.providerTurnId,
1522 nativeItemRef,
1523 parentItemId: null,
1524 ordinal: itemOrdinal(turn, part.id),
1525 status: status.item,
1526 title,
1527 startedAt: context.startedAt,
1528 completedAt,
1529 updatedAt: now,
1530 type: "subagent",
1531 subagentId: nodeId,
1532 origin: "provider_native",
1533 driver: OPENCODE_PROVIDER,
1534 providerInstanceId: options.instanceId,
1535 childThreadId: context.childThreadId,
1536 prompt,
1537 result: context.result,
1538 },
1539 });
1540 });
1541
1542 const emitToolPart = Effect.fnUntraced(function* (
1543 state: OpenCodeThreadState,
1544 turn: ActiveOpenCodeTurn,
1545 part: ToolPart,
1546 ) {
1547 const normalizedTool = part.tool.toLowerCase();
1548 if (normalizedTool === "task") {
1549 yield* emitSubagent(state, turn, part);
1550 return;
1551 }
1552 // question.asked carries the respondable semantic item. Projecting
1553 // the implementation tool as well would duplicate it in the UI.
1554 if (normalizedTool === "question") return;
1555 const now = yield* DateTime.now;
1556 const status = toolStatus(part);
1557 const startedAt = toolStartedAt(part, now);
1558 const completedAt = toolCompletedAt(part, now);
1559 const nativeItemRef = providerRef(part.id);
1560 const nodeId = idAllocator.derive.nodeFromProviderItem({
1561 driver: OPENCODE_PROVIDER,
1562 nativeItemId: part.id,
1563 });
1564 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1565 driver: OPENCODE_PROVIDER,
1566 nativeItemId: part.id,
1567 });
1568 const base = {
1569 id: turnItemId,
1570 threadId: turn.threadId,
1571 runId: turn.runId,
1572 nodeId,
1573 providerThreadId: state.providerThread.id,
1574 providerTurnId: turn.providerTurnId,
1575 nativeItemRef,
1576 parentItemId: null,
1577 ordinal: itemOrdinal(turn, part.id),
1578 status: status.item,
1579 title: toolTitle(part),
1580 startedAt,
1581 completedAt,
1582 updatedAt: now,
1583 } satisfies Pick<
1584 OrchestrationV2TurnItem,
1585 | "id"
1586 | "threadId"
1587 | "runId"
1588 | "nodeId"
1589 | "providerThreadId"
1590 | "providerTurnId"
1591 | "nativeItemRef"
1592 | "parentItemId"
1593 | "ordinal"
1594 | "status"
1595 | "title"
1596 | "startedAt"
1597 | "completedAt"
1598 | "updatedAt"
1599 >;
1600 const input = toolInput(part);
1601 const output = toolOutput(part);
1602 const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch";
1603 if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) {
1604 const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) =>
1605 client.mcp.status(undefined, { signal, throwOnError: true }),
1606 ).pipe(
1607 Effect.timeout("1 second"),
1608 Effect.map((response) => Object.keys(response.data ?? {})),
1609 Effect.catch(() => Effect.succeed(undefined)),
1610 );
1611 if (serverNames !== undefined) turn.mcpServerNames = serverNames;
1612 }
1613 const matchingServers = turn.mcpServerNames?.filter(
1614 (name) =>
1615 !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`),
1616 );
1617 const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined;
1618 const presentation =
1619 serverName === undefined
1620 ? {}
1621 : mcpToolPresentation({
1622 serverName,
1623 toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1),
1624 title: toolTitle(part) === part.tool ? undefined : toolTitle(part),
1625 });
1626 const turnItem: OrchestrationV2TurnItem = matchingServers?.length
1627 ? {
1628 ...base,
1629 type: "dynamic_tool",
1630 ...presentation,
1631 toolName: part.tool,
1632 input,
1633 ...(output === undefined ? {} : { output }),
1634 }
1635 : openCodeToolTurnItem(base, {
1636 name: part.tool,
1637 input,
1638 output,
1639 completedMetadata:
1640 part.state.status === "completed" ? part.state.metadata : undefined,
1641 });
1642 yield* emitProviderEvent({
1643 type: "node.updated",
1644 driver: OPENCODE_PROVIDER,
1645 node: {
1646 id: nodeId,
1647 threadId: turn.threadId,
1648 runId: turn.runId,
1649 parentNodeId: turn.rootNodeId,
1650 rootNodeId: turn.rootNodeId,
1651 kind: "tool_call",
1652 status: status.node,
1653 countsForRun: false,
1654 providerThreadId: state.providerThread.id,
1655 providerTurnId: turn.providerTurnId,
1656 nativeItemRef,
1657 runtimeRequestId: null,
1658 checkpointScopeId: null,
1659 startedAt,
1660 completedAt,
1661 },
1662 });
1663 yield* emitProviderEvent({
1664 type: "turn_item.updated",
1665 driver: OPENCODE_PROVIDER,
1666 turnItem,
1667 });
1668 });
1669
1670 const emitTodo = Effect.fnUntraced(function* (
1671 state: OpenCodeThreadState,
1672 turn: ActiveOpenCodeTurn,
1673 todos: ReadonlyArray<OpenCodeTodo>,
1674 ) {
1675 const now = yield* DateTime.now;
1676 if (turn.planId === null) {
1677 turn.planId = yield* idAllocator.allocate.plan({
1678 threadId: turn.threadId,
1679 ...(turn.runId === null ? {} : { runId: turn.runId }),
1680 driver: OPENCODE_PROVIDER,
1681 });
1682 }
1683 const planId = turn.planId;
1684 const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`;
1685 const nodeId = idAllocator.derive.nodeFromProviderItem({
1686 driver: OPENCODE_PROVIDER,
1687 nativeItemId,
1688 });
1689 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1690 driver: OPENCODE_PROVIDER,
1691 nativeItemId,
1692 });
1693 const steps: Array<OrchestrationV2PlanStep> = todos.map((todo, index) => ({
1694 id: `${nativeItemId}:${index + 1}`,
1695 text: todo.content.trim() || `Todo ${index + 1}`,
1696 status:
1697 todo.status === "completed"
1698 ? "completed"
1699 : todo.status === "in_progress"
1700 ? "running"
1701 : "pending",
1702 }));
1703 const completed = steps.length > 0 && steps.every((step) => step.status === "completed");
1704 yield* emitProviderEvent({
1705 type: "node.updated",
1706 driver: OPENCODE_PROVIDER,
1707 node: {
1708 id: nodeId,
1709 threadId: turn.threadId,
1710 runId: turn.runId,
1711 parentNodeId: turn.rootNodeId,
1712 rootNodeId: turn.rootNodeId,
1713 kind: "todo_list",
1714 status: completed ? "completed" : "running",
1715 countsForRun: false,
1716 providerThreadId: state.providerThread.id,
1717 providerTurnId: turn.providerTurnId,
1718 nativeItemRef: providerRef(nativeItemId, "weak"),
1719 runtimeRequestId: null,
1720 checkpointScopeId: null,
1721 startedAt: turn.startedAt,
1722 completedAt: completed ? now : null,
1723 },
1724 });
1725 yield* emitProviderEvent({
1726 type: "plan.updated",
1727 driver: OPENCODE_PROVIDER,
1728 plan: {
1729 id: planId,
1730 threadId: turn.threadId,
1731 runId: turn.runId,
1732 nodeId,
1733 status: completed ? "completed" : "active",
1734 kind: "todo_list",
1735 steps,
1736 },
1737 });
1738 yield* emitProviderEvent({
1739 type: "turn_item.updated",
1740 driver: OPENCODE_PROVIDER,
1741 turnItem: {
1742 id: turnItemId,
1743 threadId: turn.threadId,
1744 runId: turn.runId,
1745 nodeId,
1746 providerThreadId: state.providerThread.id,
1747 providerTurnId: turn.providerTurnId,
1748 nativeItemRef: providerRef(nativeItemId, "weak"),
1749 parentItemId: null,
1750 ordinal: itemOrdinal(turn, nativeItemId),
1751 status: completed ? "completed" : "running",
1752 title: "Todo list",
1753 startedAt: turn.startedAt,
1754 completedAt: completed ? now : null,
1755 updatedAt: now,
1756 type: "todo_list",
1757 planId,
1758 steps,
1759 },
1760 });
1761 });
1762
1763 const requestQuestions = (request: QuestionRequest) =>
1764 request.questions.map((question, index) => ({
1765 id: OpenCodeRuntime.openCodeQuestionId(index, question),
1766 header: question.header.trim() || `Question ${index + 1}`,
1767 question: question.question.trim() || question.header.trim() || `Question ${index + 1}`,
1768 options: question.options.map((option) => ({
1769 label: option.label.trim() || "Option",
1770 description: option.description.trim() || option.label.trim() || "Option",
1771 })),
1772 }));
1773
1774 const runtimeRequestTurnItem = (
1775 pending: PendingOpenCodeRequest,
1776 status: OrchestrationV2TurnItem["status"],
1777 completedAt: DateTime.Utc | null,
1778 updatedAt: DateTime.Utc,
1779 ): OrchestrationV2TurnItem => {
1780 const base = {
1781 id: pending.turnItemId,
1782 threadId: pending.turn.threadId,
1783 runId: pending.turn.runId,
1784 nodeId: pending.nodeId,
1785 providerThreadId: pending.state.providerThread.id,
1786 providerTurnId: pending.turn.providerTurnId,
1787 nativeItemRef: providerRef(pending.nativeRequestId),
1788 parentItemId: null,
1789 ordinal: itemOrdinal(pending.turn, pending.nativeRequestId),
1790 status,
1791 startedAt: pending.createdAt,
1792 completedAt,
1793 updatedAt,
1794 };
1795 if (pending.question !== undefined) {
1796 return {
1797 ...base,
1798 title: "User input",
1799 type: "user_input_request",
1800 requestId: pending.requestId,
1801 questions: requestQuestions(pending.question),
1802 };
1803 }
1804 const permission = pending.permission;
1805 if (permission === undefined) {
1806 throw protocolError(`OpenCode request ${pending.requestId} has no native payload`);
1807 }
1808 return {
1809 ...base,
1810 title: permission.permission,
1811 type: "approval_request",
1812 requestId: pending.requestId,
1813 requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind,
1814 prompt:
1815 permission.patterns.length === 0
1816 ? permission.permission
1817 : permission.patterns.join("\n"),
1818 };
1819 };
1820
1821 const emitRuntimeRequest = Effect.fnUntraced(function* (
1822 owner: OpenCodeRequestOwner,
1823 nativeRequestId: string,
1824 request:
1825 | { readonly type: "permission"; readonly value: PermissionRequest }
1826 | { readonly type: "question"; readonly value: QuestionRequest },
1827 ) {
1828 if (pendingRequestsByNativeId.has(nativeRequestId)) return;
1829 const { state, turn, subagent } = owner;
1830 const now = yield* DateTime.now;
1831 const requestId = yield* idAllocator.allocate.runtimeRequest({
1832 driver: OPENCODE_PROVIDER,
1833 providerTurnId: turn.providerTurnId,
1834 nativeRequestId,
1835 });
1836 const nodeId = idAllocator.derive.approvalNode({ requestId });
1837 const turnItemId = idAllocator.derive.approvalTurnItem({ requestId });
1838 // The tool call belongs to the asking session's turn, not the owner's.
1839 const permissionToolName =
1840 request.type === "permission" && request.value.tool !== undefined
1841 ? threads
1842 .get(request.value.sessionID)
1843 ?.activeTurn?.toolNamesByCallId.get(request.value.tool.callID)
1844 : undefined;
1845 const permissionRequestKind =
1846 request.type === "permission"
1847 ? openCodePermissionRequestKind(request.value.permission, permissionToolName)
1848 : undefined;
1849 const requestKind: OrchestrationV2RuntimeRequest["kind"] =
1850 permissionRequestKind ?? "user_input";
1851 const pending: PendingOpenCodeRequest = {
1852 requestId,
1853 nativeRequestId,
1854 nativeSessionId: request.value.sessionID,
1855 turn,
1856 state,
1857 nodeId,
1858 parentNodeId: subagent?.nodeId ?? turn.rootNodeId,
1859 turnItemId,
1860 requestKind,
1861 createdAt: now,
1862 ...(request.type === "permission"
1863 ? { permission: request.value }
1864 : { question: request.value }),
1865 };
1866 pendingRequests.set(String(requestId), pending);
1867 pendingRequestsByNativeId.set(nativeRequestId, pending);
1868 const runtimeRequest: OrchestrationV2RuntimeRequest = {
1869 id: requestId,
1870 nodeId,
1871 providerTurnId: turn.providerTurnId,
1872 nativeRequestRef: providerRef(nativeRequestId),
1873 kind: requestKind,
1874 status: "pending",
1875 responseCapability: {
1876 type: "live",
1877 providerSessionId: inputProviderSessionId,
1878 },
1879 createdAt: now,
1880 resolvedAt: null,
1881 };
1882 state.runtimeRequests.set(String(requestId), runtimeRequest);
1883 yield* emitProviderEvent({
1884 type: "node.updated",
1885 driver: OPENCODE_PROVIDER,
1886 node: {
1887 id: nodeId,
1888 threadId: turn.threadId,
1889 runId: turn.runId,
1890 parentNodeId: pending.parentNodeId,
1891 rootNodeId: turn.rootNodeId,
1892 kind: request.type === "question" ? "user_input_request" : "approval_request",
1893 status: "waiting",
1894 countsForRun: false,
1895 providerThreadId: state.providerThread.id,
1896 providerTurnId: turn.providerTurnId,
1897 nativeItemRef: providerRef(nativeRequestId),
1898 runtimeRequestId: requestId,
1899 checkpointScopeId: null,
1900 startedAt: now,
1901 completedAt: null,
1902 },
1903 });
1904 yield* emitProviderEvent({
1905 type: "runtime_request.updated",
1906 driver: OPENCODE_PROVIDER,
1907 threadId: turn.threadId,
1908 runtimeRequest,
1909 });
1910 yield* emitProviderEvent({
1911 type: "turn_item.updated",
1912 driver: OPENCODE_PROVIDER,
1913 turnItem: runtimeRequestTurnItem(pending, "waiting", null, now),
1914 });
1915 yield* updateProviderSession("waiting", null);
1916 });
1917
1918 const resolveRuntimeRequest = Effect.fnUntraced(function* (
1919 nativeRequestId: string,
1920 status: "resolved" | "cancelled",
1921 ) {
1922 const pending = pendingRequestsByNativeId.get(nativeRequestId);
1923 if (pending === undefined) return;
1924 const now = yield* DateTime.now;
1925 const current = pending.state.runtimeRequests.get(String(pending.requestId));
1926 if (current !== undefined) {
1927 const resolved: OrchestrationV2RuntimeRequest = {
1928 ...current,
1929 status,
1930 resolvedAt: now,
1931 };
1932 pending.state.runtimeRequests.set(String(pending.requestId), resolved);
1933 yield* emitProviderEvent({
1934 type: "runtime_request.updated",
1935 driver: OPENCODE_PROVIDER,
1936 threadId: pending.turn.threadId,
1937 runtimeRequest: resolved,
1938 });
1939 }
1940 yield* emitProviderEvent({
1941 type: "node.updated",
1942 driver: OPENCODE_PROVIDER,
1943 node: {
1944 id: pending.nodeId,
1945 threadId: pending.turn.threadId,
1946 runId: pending.turn.runId,
1947 parentNodeId: pending.parentNodeId,
1948 rootNodeId: pending.turn.rootNodeId,
1949 kind: pending.question === undefined ? "approval_request" : "user_input_request",
1950 status: status === "resolved" ? "completed" : "cancelled",
1951 countsForRun: false,
1952 providerThreadId: pending.state.providerThread.id,
1953 providerTurnId: pending.turn.providerTurnId,
1954 nativeItemRef: providerRef(nativeRequestId),
1955 runtimeRequestId: pending.requestId,
1956 checkpointScopeId: null,
1957 startedAt: pending.createdAt,
1958 completedAt: now,
1959 },
1960 });
1961 yield* emitProviderEvent({
1962 type: "turn_item.updated",
1963 driver: OPENCODE_PROVIDER,
1964 turnItem: runtimeRequestTurnItem(
1965 pending,
1966 status === "resolved" ? "completed" : "cancelled",
1967 now,
1968 now,
1969 ),
1970 });
1971 pendingRequests.delete(String(pending.requestId));
1972 pendingRequestsByNativeId.delete(nativeRequestId);
1973 const hasOtherPending = Array.from(pendingRequests.values()).some(
1974 (candidate) => candidate.turn.isRoot,
1975 );
1976 if (!hasOtherPending) yield* updateProviderSession("running", null);
1977 });
1978
1979 /** Resolve the thread state a session belongs to: its own, or for a
1980 * child not registered yet, the nearest known ancestor found by walking
1981 * the native parent chain. Registers every hop so later requests from
1982 * the same child resolve without another lookup. */
1983 const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) {
1984 const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId);
1985 if (known !== undefined) return known;
1986 let cursor = sessionId;
1987 const hops: string[] = [];
1988 for (let depth = 0; depth < 5; depth += 1) {
1989 const response = yield* sdkCall("session.get", { sessionID: cursor }, () =>
1990 client.session.get({ sessionID: cursor }),
1991 ).pipe(Effect.option);
1992 const info = Option.getOrUndefined(response)?.data;
1993 const parentId = info?.parentID;
1994 if (parentId === undefined) return undefined;
1995 hops.push(cursor);
1996 const owner = threads.get(parentId) ?? relatedSessionOwners.get(parentId);
1997 if (owner !== undefined) {
1998 for (const hop of hops) relatedSessionOwners.set(hop, owner);
1999 return owner;
2000 }
2001 cursor = parentId;
2002 }
2003 return undefined;
2004 });
2005
2006 /** Every request is asked on the top-level thread and its active turn,
2007 * under the subagent that leads to the asking session, because native
2008 * subagent threads are hidden from the sidebar. A child's request can
2009 * arrive before the task part or session.created event that reveals
2010 * its relation to a thread. The first resolution attempt runs inline
2011 * (the replayable path); if the relation or the owning turn is not
2012 * established yet, a short forked backoff keeps trying instead of
2013 * dropping the request. */
2014 const routeRuntimeRequest = Effect.fnUntraced(function* (
2015 nativeRequestId: string,
2016 sessionId: string,
2017 request:
2018 | { readonly type: "permission"; readonly value: PermissionRequest }
2019 | { readonly type: "question"; readonly value: QuestionRequest },
2020 ) {
2021 if (pendingChildRequestRoutes.has(nativeRequestId)) return;
2022 const attempt = Effect.gen(function* () {
2023 if (
2024 settledNativeRequestIds.has(nativeRequestId) ||
2025 pendingRequestsByNativeId.has(nativeRequestId)
2026 ) {
2027 return true;
2028 }
2029 const state = yield* resolveSessionOwner(sessionId);
2030 const owner = state === undefined ? undefined : topLevelRequestOwner(state);
2031 if (owner === undefined) return false;
2032 yield* emitRuntimeRequest(owner, nativeRequestId, request);
2033 return true;
2034 });
2035 if (yield* attempt) return;
2036 pendingChildRequestRoutes.add(nativeRequestId);
2037 yield* Effect.gen(function* () {
2038 for (let retry = 0; retry < 5; retry += 1) {
2039 yield* Effect.sleep(Duration.millis(Math.min(200 * 2 ** retry, 2_000)));
2040 if (yield* attempt) return;
2041 }
2042 }).pipe(
2043 Effect.ensuring(Effect.sync(() => pendingChildRequestRoutes.delete(nativeRequestId))),
2044 Effect.forkIn(scope),
2045 );
2046 });
2047
2048 const finalizeTurn = Effect.fnUntraced(function* (
2049 state: OpenCodeThreadState,
2050 turn: ActiveOpenCodeTurn,
2051 status: TerminalTurnStatus,
2052 terminal?: {
2053 readonly failure?: OrchestrationV2ProviderFailure;
2054 readonly threadDisposition?: "reusable" | "broken";
2055 },
2056 ) {
2057 if (turn.finalized) return;
2058 if (nativeStreamFailure !== null) {
2059 status = "failed";
2060 terminal = { failure: nativeStreamFailure, threadDisposition: "broken" };
2061 }
2062 turn.finalized = true;
2063 const completedAt = yield* DateTime.now;
2064 for (const part of turn.parts.values()) {
2065 if (part.type === "text" || part.type === "reasoning") {
2066 yield* emitTextPart(state, turn, part, true);
2067 }
2068 }
2069 for (const pending of Array.from(pendingRequests.values())) {
2070 if (
2071 pending.turn.providerTurnId === turn.providerTurnId ||
2072 pending.nativeSessionId === state.nativeSessionId
2073 ) {
2074 yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled");
2075 }
2076 }
2077 yield* emitProviderTurn(state, turn, status, completedAt);
2078 const threadDisposition = terminal?.threadDisposition ?? "reusable";
2079 yield* updateProviderThread(state, {
2080 status: turn.isRoot ? "active" : threadDisposition === "broken" ? "error" : "idle",
2081 nativeConversationHeadRef:
2082 turn.nativeUserMessageId === null
2083 ? state.providerThread.nativeConversationHeadRef
2084 : providerRef(turn.nativeUserMessageId, "weak"),
2085 });
2086 state.activeTurn = null;
2087 if (!turn.isRoot) {
2088 yield* emitProviderEvent({
2089 type: "node.updated",
2090 driver: OPENCODE_PROVIDER,
2091 node: {
2092 id: turn.rootNodeId,
2093 threadId: turn.threadId,
2094 runId: null,
2095 parentNodeId: null,
2096 rootNodeId: turn.rootNodeId,
2097 kind: "root_turn",
2098 status,
2099 countsForRun: false,
2100 providerThreadId: state.providerThread.id,
2101 providerTurnId: turn.providerTurnId,
2102 nativeItemRef: providerRef(state.nativeSessionId),
2103 runtimeRequestId: null,
2104 checkpointScopeId: null,
2105 startedAt: turn.startedAt,
2106 completedAt,
2107 },
2108 });
2109 return;
2110 }
2111 const anotherTurnIsActive = Array.from(threads.values()).some(
2112 (candidate) => candidate.activeTurn?.isRoot === true,
2113 );
2114 yield* updateProviderSession(
2115 anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready",
2116 status === "failed" ? sessionEntity.lastError : null,
2117 );
2118 yield* emitProviderEvent(
2119 status === "failed"
2120 ? {
2121 type: "turn.terminal",
2122 driver: OPENCODE_PROVIDER,
2123 providerThreadId: turn.providerThreadId,
2124 providerTurnId: turn.providerTurnId,
2125 runOrdinal: turn.runOrdinal,
2126 failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`),
2127 status,
2128 failure:
2129 terminal?.failure ??
2130 makeProviderFailure({
2131 message: sessionEntity.lastError ?? undefined,
2132 class: "provider_error",
2133 }),
2134 threadDisposition,
2135 }
2136 : {
2137 type: "turn.terminal",
2138 driver: OPENCODE_PROVIDER,
2139 providerThreadId: turn.providerThreadId,
2140 providerTurnId: turn.providerTurnId,
2141 runOrdinal: turn.runOrdinal,
2142 status,
2143 failure: null,
2144 threadDisposition,
2145 },
2146 );
2147 });
2148
2149 const promptAdmissionIsCurrent = (
2150 state: OpenCodeThreadState,
2151 turn: ActiveOpenCodeTurn,
2152 generation: number,
2153 ) =>
2154 state.activeTurn === turn &&
2155 !turn.finalized &&
2156 !turn.interrupted &&
2157 turn.admissionPending &&
2158 turn.admissionGeneration === generation;
2159
2160 const runPromptAdmissionReconciliation = Effect.fnUntraced(function* (
2161 state: OpenCodeThreadState,
2162 turn: ActiveOpenCodeTurn,
2163 generation: number,
2164 ) {
2165 while (promptAdmissionIsCurrent(state, turn, generation)) {
2166 const status = yield* reconcileOpenCodePromptAdmissionStatus(
2167 turn,
2168 generation,
2169 sdkCall("session.status", { sessionID: state.nativeSessionId, generation }, () =>
2170 client.session.status(),
2171 ).pipe(
2172 Effect.match({
2173 onFailure: () => "unknown" as const,
2174 onSuccess: (response) => {
2175 const statuses = unwrapData("session.status", response);
2176 const sessionStatus = statuses[state.nativeSessionId];
2177 return sessionStatus === undefined || sessionStatus.type === "idle"
2178 ? ("idle" as const)
2179 : ("busy" as const);
2180 },
2181 }),
2182 ),
2183 );
2184 if (
2185 state.activeTurn !== turn ||
2186 status === "stale" ||
2187 turn.finalized ||
2188 turn.interrupted ||
2189 turn.admissionGeneration !== generation
2190 ) {
2191 return;
2192 }
2193 if (status === "idle") {
2194 yield* finalizeTurn(state, turn, "completed");
2195 return;
2196 }
2197 if (status === "busy") return;
2198 yield* Effect.sleep("250 millis");
2199 }
2200 });
2201
2202 const reconcilePromptAdmission = Effect.fnUntraced(function* (
2203 state: OpenCodeThreadState,
2204 turn: ActiveOpenCodeTurn,
2205 ) {
2206 const generation = turn.admissionGeneration;
2207 if (
2208 !promptAdmissionIsCurrent(state, turn, generation) ||
2209 turn.admissionReconciliationGeneration === generation
2210 ) {
2211 return;
2212 }
2213 turn.admissionReconciliationGeneration = generation;
2214 yield* runPromptAdmissionReconciliation(state, turn, generation).pipe(
2215 Effect.ensuring(
2216 Effect.sync(() => {
2217 if (turn.admissionReconciliationGeneration === generation) {
2218 turn.admissionReconciliationGeneration = null;
2219 }
2220 }),
2221 ),
2222 Effect.forkIn(scope),
2223 );
2224 });
2225
2226 const createChildTurn = Effect.fnUntraced(function* (
2227 state: OpenCodeThreadState,
2228 message: Extract<OpenCodeMessage, { role: "user" }>,
2229 ) {
2230 if (state.appThread === null || state.parentSubagent === null) return null;
2231 const now = yield* DateTime.now;
2232 const startedAt = dateTimeFromEpoch(message.time.created, now);
2233 const rootNodeId = idAllocator.derive.nodeFromProviderItem({
2234 driver: OPENCODE_PROVIDER,
2235 nativeItemId: `${state.nativeSessionId}:root:${message.id}`,
2236 });
2237 const providerTurnId = idAllocator.derive.providerTurn({
2238 driver: OPENCODE_PROVIDER,
2239 nativeTurnId: message.id,
2240 });
2241 const providerTurn: OrchestrationV2ProviderTurn = {
2242 id: providerTurnId,
2243 providerThreadId: state.providerThread.id,
2244 nodeId: rootNodeId,
2245 runAttemptId: null,
2246 nativeTurnRef: providerRef(message.id, "weak"),
2247 ordinal: state.nextChildTurnOrdinal++,
2248 status: "running",
2249 startedAt,
2250 completedAt: null,
2251 };
2252 const turn: ActiveOpenCodeTurn = {
2253 isRoot: false,
2254 threadId: state.appThread.id,
2255 runId: null,
2256 rootNodeId,
2257 appThread: state.appThread,
2258 modelSelection: state.appThread.modelSelection,
2259 runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy,
2260 providerTurnId,
2261 providerThreadId: providerTurn.providerThreadId,
2262 providerTurnOrdinal: providerTurn.ordinal,
2263 runOrdinal: state.parentSubagent.parentTurn.runOrdinal,
2264 runAttemptId: null,
2265 startedAt,
2266 itemOrdinals: new Map(),
2267 usage: makeOpenCodeTurnTokenUsageAccumulator(),
2268 parts: new Map(),
2269 partIdsByMessage: new Map(),
2270 toolNamesByCallId: new Map(),
2271 providerTurn,
2272 nextItemOrdinal: 1,
2273 nativeUserMessageId: message.id,
2274 admissionMessageId: null,
2275 interrupted: false,
2276 finalized: false,
2277 planId: null,
2278 admissionGeneration: 0,
2279 admissionReconciliationGeneration: null,
2280 admissionPending: false,
2281 admissionAccepted: true,
2282 admissionMessageObserved: true,
2283 idleDuringAdmission: false,
2284 admissionSettled: Deferred.makeUnsafe<void>(),
2285 admissionAbortController: null,
2286 };
2287 state.activeTurn = turn;
2288 state.providerTurns.set(String(providerTurnId), providerTurn);
2289 yield* emitProviderEvent({
2290 type: "node.updated",
2291 driver: OPENCODE_PROVIDER,
2292 node: {
2293 id: rootNodeId,
2294 threadId: turn.threadId,
2295 runId: null,
2296 parentNodeId: null,
2297 rootNodeId,
2298 kind: "root_turn",
2299 status: "running",
2300 countsForRun: false,
2301 providerThreadId: state.providerThread.id,
2302 providerTurnId,
2303 nativeItemRef: providerRef(message.id, "weak"),
2304 runtimeRequestId: null,
2305 checkpointScopeId: null,
2306 startedAt,
2307 completedAt: null,
2308 },
2309 });
2310 yield* emitProviderTurn(state, turn, "running", null);
2311 return turn;
2312 });
2313
2314 const projectChildUserPart = Effect.fnUntraced(function* (
2315 state: OpenCodeThreadState,
2316 turn: ActiveOpenCodeTurn,
2317 part: Extract<OpenCodePart, { type: "text" }>,
2318 ) {
2319 const now = yield* DateTime.now;
2320 const messageId = idAllocator.derive.messageFromProviderItem({
2321 driver: OPENCODE_PROVIDER,
2322 nativeItemId: part.messageID,
2323 });
2324 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
2325 driver: OPENCODE_PROVIDER,
2326 nativeItemId: part.messageID,
2327 });
2328 const projected: OrchestrationV2ConversationMessage = {
2329 createdBy: "agent",
2330 creationSource: "provider",
2331 id: messageId,
2332 threadId: turn.threadId,
2333 runId: null,
2334 nodeId: turn.rootNodeId,
2335 role: "user",
2336 text: part.text,
2337 attachments: [],
2338 streaming: false,
2339 createdAt: turn.startedAt,
2340 updatedAt: now,
2341 };
2342 state.messages.set(String(messageId), projected);
2343 yield* emitProviderEvent({
2344 type: "message.updated",
2345 driver: OPENCODE_PROVIDER,
2346 message: projected,
2347 });
2348 yield* emitProviderEvent({
2349 type: "turn_item.updated",
2350 driver: OPENCODE_PROVIDER,
2351 turnItem: {
2352 createdBy: "agent",
2353 creationSource: "provider",
2354 id: turnItemId,
2355 threadId: turn.threadId,
2356 runId: null,
2357 nodeId: turn.rootNodeId,
2358 providerThreadId: state.providerThread.id,
2359 providerTurnId: turn.providerTurnId,
2360 nativeItemRef: providerRef(part.messageID),
2361 parentItemId: null,
2362 ordinal: itemOrdinal(turn, part.messageID),
2363 status: "completed",
2364 title: null,
2365 startedAt: projected.createdAt,
2366 completedAt: now,
2367 updatedAt: now,
2368 type: "user_message",
2369 messageId,
2370 inputIntent: "turn_start",
2371 text: part.text,
2372 attachments: [],
2373 },
2374 });
2375 });
2376
2377 const emitCompactionItem = Effect.fn("OpenCodeAdapterV2.emitCompactionItem")(function* (
2378 state: OpenCodeThreadState,
2379 turn: ActiveOpenCodeTurn,
2380 ) {
2381 const nativeItemId = `${turn.providerTurnId}:compaction`;
2382 if (turn.itemOrdinals.has(nativeItemId)) return;
2383 const now = yield* DateTime.now;
2384 yield* emitProviderEvent({
2385 type: "turn_item.updated",
2386 driver: OPENCODE_PROVIDER,
2387 turnItem: {
2388 id: idAllocator.derive.turnItemFromProviderItem({
2389 driver: OPENCODE_PROVIDER,
2390 nativeItemId,
2391 }),
2392 threadId: turn.threadId,
2393 runId: turn.runId,
2394 nodeId: turn.rootNodeId,
2395 providerThreadId: state.providerThread.id,
2396 providerTurnId: turn.providerTurnId,
2397 nativeItemRef: providerRef(nativeItemId, "weak"),
2398 parentItemId: null,
2399 ordinal: itemOrdinal(turn, nativeItemId),
2400 type: "compaction",
2401 driver: OPENCODE_PROVIDER,
2402 status: "completed",
2403 title: "Context compacted",
2404 startedAt: turn.startedAt,
2405 completedAt: now,
2406 updatedAt: now,
2407 },
2408 });
2409 });
2410
2411 const handleMessageUpdated = Effect.fnUntraced(function* (
2412 event: Extract<OpenCodeEvent, { type: "message.updated" }>,
2413 ) {
2414 const state = threads.get(event.properties.sessionID);
2415 if (state === undefined) return;
2416 const message = event.properties.info;
2417 state.messageRoles.set(message.id, message.role);
2418 if (message.role === "assistant") {
2419 const usage = state.activeTurn?.usage;
2420 if (usage === undefined) return;
2421 const prior = usage.assistantOwnershipByMessageId.get(message.id);
2422 const ownership =
2423 prior !== undefined && prior !== "unknown"
2424 ? prior
2425 : !message.parentID
2426 ? "unknown"
2427 : usage.promptMessageIds.has(message.parentID)
2428 ? "owned"
2429 : "other";
2430 usage.assistantOwnershipByMessageId.set(message.id, ownership);
2431 if (ownership !== "unknown") {
2432 if (ownership === "owned") {
2433 for (const step of usage.unresolvedStepsByMessageId.get(message.id)?.values() ??
2434 []) {
2435 accumulateOpenCodeStepUsage(usage, step);
2436 }
2437 }
2438 usage.unresolvedStepsByMessageId.delete(message.id);
2439 }
2440 return;
2441 }
2442 const commandReceipt = commandReceipts.get(message.id);
2443 if (commandReceipt) yield* Deferred.succeed(commandReceipt, undefined);
2444 const isNewUserMessage = !state.userMessageIds.includes(message.id);
2445 if (isNewUserMessage) state.userMessageIds.push(message.id);
2446 let turn = state.activeTurn;
2447 if (turn === null && state.parentSubagent !== null && isNewUserMessage) {
2448 turn = yield* createChildTurn(state, message);
2449 }
2450 const matchesAdmission =
2451 turn !== null &&
2452 (turn.admissionMessageId === null || turn.admissionMessageId === message.id);
2453 if (turn !== null && matchesAdmission) turn.usage.promptMessageIds.add(message.id);
2454 if (turn !== null && matchesAdmission && turn.nativeUserMessageId === null) {
2455 turn.nativeUserMessageId = message.id;
2456 yield* emitProviderTurn(state, turn, "running", null);
2457 }
2458 if (turn !== null && matchesAdmission && turn.admissionPending) {
2459 if (advanceOpenCodePromptAdmission(turn, "user-message") === "reconcile-idle") {
2460 yield* reconcilePromptAdmission(state, turn);
2461 }
2462 }
2463 });
2464
2465 const handlePartUpdated = Effect.fnUntraced(function* (
2466 event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
2467 ) {
2468 const part = event.properties.part;
2469 const state = threads.get(part.sessionID);
2470 const turn = state?.activeTurn;
2471 if (state === undefined || turn === null || turn === undefined || turn.finalized) return;
2472 if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") {
2473 if (!turn.isRoot) yield* projectChildUserPart(state, turn, part);
2474 return;
2475 }
2476 if (part.type === "step-finish") {
2477 const usage = turn.usage;
2478 const ownership = usage.assistantOwnershipByMessageId.get(part.messageID);
2479 if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part);
2480 else if (
2481 ownership === "unknown" ||
2482 (ownership === undefined && !state.messageRoles.has(part.messageID))
2483 ) {
2484 const steps =
2485 usage.unresolvedStepsByMessageId.get(part.messageID) ??
2486 new Map<string, OpenCodeStepUsage>();
2487 steps.set(part.id, { id: part.id, tokens: part.tokens });
2488 usage.unresolvedStepsByMessageId.set(part.messageID, steps);
2489 }
2490 return;
2491 }
2492 if (part.type === "tool") {
2493 // Approval routing needs the tool name, without retaining its input and output.
2494 turn.toolNamesByCallId.set(part.callID, part.tool);
2495 } else {
2496 turn.parts.set(part.id, part);
2497 const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set<string>();
2498 ids.add(part.id);
2499 turn.partIdsByMessage.set(part.messageID, ids);
2500 }
2501 switch (part.type) {
2502 case "text":
2503 case "reasoning":
2504 yield* emitTextPart(state, turn, part);
2505 return;
2506 case "tool":
2507 yield* emitToolPart(state, turn, part);
2508 return;
2509 default:
2510 return;
2511 }
2512 });
2513
2514 const handlePartDelta = Effect.fnUntraced(function* (
2515 event: Extract<OpenCodeEvent, { type: "message.part.delta" }>,
2516 ) {
2517 if (event.properties.field !== "text") return;
2518 const state = threads.get(event.properties.sessionID);
2519 const turn = state?.activeTurn;
2520 const current = turn?.parts.get(event.properties.partID);
2521 if (
2522 state === undefined ||
2523 turn === null ||
2524 turn === undefined ||
2525 current === undefined ||
2526 (current.type !== "text" && current.type !== "reasoning")
2527 ) {
2528 return;
2529 }
2530 const updated = { ...current, text: current.text + event.properties.delta };
2531 turn.parts.set(updated.id, updated);
2532 yield* emitTextPart(state, turn, updated);
2533 });
2534
2535 const handleAssistantCompleted = Effect.fnUntraced(function* (
2536 event: Extract<OpenCodeEvent, { type: "message.updated" }>,
2537 ) {
2538 const message = event.properties.info;
2539 if (message.role !== "assistant" || message.time.completed === undefined) return;
2540 const state = threads.get(message.sessionID);
2541 const turn = state?.activeTurn;
2542 if (state === undefined || turn === null || turn === undefined) return;
2543 // Some OpenCode versions ignore the client-provided message ID. A
2544 // completed assistant message is definitive admission evidence, so
2545 // let the following idle event settle the turn without weakening
2546 // the stale-user-message guard.
2547 advanceOpenCodePromptAdmission(turn, "assistant-completed");
2548 for (const partId of turn.partIdsByMessage.get(message.id) ?? []) {
2549 const part = turn.parts.get(partId);
2550 if (part?.type === "text" || part?.type === "reasoning") {
2551 yield* emitTextPart(state, turn, part, true);
2552 }
2553 }
2554 });
2555
2556 const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) {
2557 yield* logProtocolEvent({
2558 direction: "incoming",
2559 messageKind: "notification",
2560 method: event.type,
2561 payload: event,
2562 });
2563 switch (event.type) {
2564 case "server.connected":
2565 if (hasConnected) {
2566 for (const state of threads.values()) {
2567 if (state.activeTurn !== null) state.activeTurn.usage.complete = false;
2568 }
2569 }
2570 hasConnected = true;
2571 return;
2572 case "message.updated":
2573 yield* handleMessageUpdated(event);
2574 yield* handleAssistantCompleted(event);
2575 return;
2576 case "session.compacted": {
2577 const state = threads.get(event.properties.sessionID);
2578 if (state?.activeTurn !== undefined && state.activeTurn !== null) {
2579 yield* emitCompactionItem(state, state.activeTurn);
2580 }
2581 return;
2582 }
2583 case "message.part.updated":
2584 yield* handlePartUpdated(event);
2585 return;
2586 case "message.part.delta":
2587 yield* handlePartDelta(event);
2588 return;
2589 case "todo.updated": {
2590 const state = threads.get(event.properties.sessionID);
2591 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
2592 yield* emitTodo(state, state.activeTurn, event.properties.todos);
2593 }
2594 return;
2595 }
2596 case "permission.asked":
2597 yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, {
2598 type: "permission",
2599 value: event.properties,
2600 });
2601 return;
2602 case "question.asked":
2603 yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, {
2604 type: "question",
2605 value: event.properties,
2606 });
2607 return;
2608 case "permission.replied":
2609 case "question.replied":
2610 rememberSettledRequest(event.properties.requestID);
2611 yield* resolveRuntimeRequest(event.properties.requestID, "resolved");
2612 return;
2613 case "question.rejected":
2614 rememberSettledRequest(event.properties.requestID);
2615 yield* resolveRuntimeRequest(event.properties.requestID, "cancelled");
2616 return;
2617 case "session.created":
2618 case "session.updated": {
2619 const info = event.properties.info;
2620 if (info.parentID !== undefined && !threads.has(info.id)) {
2621 const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID);
2622 if (owner !== undefined) {
2623 relatedSessionOwners.set(info.id, owner);
2624 }
2625 }
2626 return;
2627 }
2628 case "session.deleted":
2629 relatedSessionOwners.delete(event.properties.info.id);
2630 busySessionIds.delete(event.properties.info.id);
2631 return;
2632 case "session.status": {
2633 const sessionId = event.properties.sessionID;
2634 switch (event.properties.status.type) {
2635 case "busy":
2636 case "retry":
2637 if (threads.has(sessionId) || relatedSessionOwners.has(sessionId)) {
2638 busySessionIds.add(sessionId);
2639 }
2640 break;
2641 case "idle":
2642 busySessionIds.delete(sessionId);
2643 break;
2644 }
2645 const state = threads.get(sessionId);
2646 if (state === undefined) return;
2647 if (event.properties.status.type === "busy") {
2648 yield* updateProviderThread(state, { status: "active" });
2649 return;
2650 }
2651 if (event.properties.status.type === "idle" && state.activeTurn !== null) {
2652 if (state.activeTurn.admissionPending) {
2653 advanceOpenCodePromptAdmission(state.activeTurn, "idle");
2654 return;
2655 }
2656 yield* finalizeTurn(
2657 state,
2658 state.activeTurn,
2659 state.activeTurn.interrupted ? "interrupted" : "completed",
2660 );
2661 }
2662 return;
2663 }
2664 case "session.idle": {
2665 busySessionIds.delete(event.properties.sessionID);
2666 const state = threads.get(event.properties.sessionID);
2667 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
2668 if (state.activeTurn.admissionPending) {
2669 advanceOpenCodePromptAdmission(state.activeTurn, "idle");
2670 return;
2671 }
2672 yield* finalizeTurn(
2673 state,
2674 state.activeTurn,
2675 state.activeTurn.interrupted ? "interrupted" : "completed",
2676 );
2677 }
2678 return;
2679 }
2680 case "session.error": {
2681 const states =
2682 event.properties.sessionID === undefined
2683 ? Array.from(threads.values()).filter((state) => state.activeTurn !== null)
2684 : [threads.get(event.properties.sessionID)].filter(
2685 (state): state is OpenCodeThreadState => state !== undefined,
2686 );
2687 const message = openCodeErrorMessage(event);
2688 if (
2689 !isMessageAbortedError(event) &&
2690 (event.properties.sessionID === undefined ||
2691 states.some((state) => state.parentSubagent === null))
2692 ) {
2693 yield* updateProviderSession("error", message);
2694 }
2695 for (const state of states) {
2696 if (state.activeTurn !== null) {
2697 yield* finalizeTurn(
2698 state,
2699 state.activeTurn,
2700 terminalStatusForError(event, state.activeTurn),
2701 {
2702 failure: makeProviderFailure({
2703 message,
2704 code: event.properties.error?.name ?? null,
2705 class: "provider_error",
2706 }),
2707 threadDisposition:
2708 event.properties.sessionID === undefined ? "broken" : "reusable",
2709 },
2710 );
2711 }
2712 }
2713 return;
2714 }
2715 default:
2716 return;
2717 }
2718 });
2719
2720 const inputProviderSessionId = input.providerSessionId;
2721
2722 const subscription = yield* sdkCall("event.subscribe", {}, () =>
2723 client.event.subscribe(undefined, { signal: abortController.signal }),
2724 );
2725 yield* Scope.addFinalizer(
2726 scope,
2727 Effect.sync(() => abortController.abort()),
2728 );
2729 yield* Stream.fromAsyncIterable(
2730 subscription.stream,
2731 (cause) =>
2732 new OpenCodeRuntime.OpenCodeRuntimeError({
2733 operation: "event.subscribe",
2734 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
2735 cause,
2736 }),
2737 ).pipe(
2738 Stream.runForEach(handleEvent),
2739 Effect.exit,
2740 Effect.flatMap((exit) =>
2741 Effect.gen(function* () {
2742 // No further status can clear a busy session once the stream ends.
2743 busySessionIds.clear();
2744 if (closing || abortController.signal.aborted) return;
2745 const detail = Exit.isSuccess(exit)
2746 ? "OpenCode event stream ended unexpectedly."
2747 : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause));
2748 nativeStreamFailure = makeProviderFailure({
2749 message: detail,
2750 class: "transport_error",
2751 });
2752 yield* updateProviderSession("error", detail);
2753 for (const state of threads.values()) {
2754 if (state.activeTurn !== null)
2755 yield* finalizeTurn(state, state.activeTurn, "failed", {
2756 failure: makeProviderFailure({ message: detail, class: "transport_error" }),
2757 threadDisposition: "broken",
2758 });
2759 }
2760 yield* Queue.end(events);
2761 }),
2762 ),
2763 Effect.forkIn(scope),
2764 );
2765
2766 if (!connection.external && connection.exitCode !== null) {
2767 yield* connection.exitCode.pipe(
2768 Effect.flatMap((code) =>
2769 abortController.signal.aborted
2770 ? Effect.void
2771 : Effect.gen(function* () {
2772 const detail = `OpenCode server exited unexpectedly (${code}).`;
2773 yield* updateProviderSession("error", detail);
2774 for (const state of threads.values()) {
2775 if (state.activeTurn !== null) {
2776 yield* finalizeTurn(state, state.activeTurn, "failed", {
2777 failure: makeProviderFailure({
2778 message: detail,
2779 class: "transport_error",
2780 }),
2781 threadDisposition: "broken",
2782 });
2783 }
2784 }
2785 }),
2786 ),
2787 Effect.forkIn(scope),
2788 );
2789 }
2790
2791 if (connection.external) {
2792 yield* Scope.addFinalizer(
2793 scope,
2794 Effect.suspend(() =>
2795 Effect.forEach(
2796 Array.from(threads.values()).filter((state) => state.parentSubagent === null),
2797 (state) =>
2798 sdkCall("session.abort", { sessionID: state.nativeSessionId }, (signal) =>
2799 client.session.abort({ sessionID: state.nativeSessionId }, { signal }),
2800 ).pipe(
2801 Effect.timeout("1 second"),
2802 Effect.ignore({ log: true }),
2803 Effect.andThen(
2804 abortDescendants(state.nativeSessionId).pipe(
2805 Effect.timeout("1 second"),
2806 Effect.ignore({ log: true }),
2807 ),
2808 ),
2809 ),
2810 { concurrency: 8, discard: true },
2811 ),
2812 ),
2813 );
2814 }
2815
2816 yield* Scope.addFinalizer(
2817 scope,
2818 Effect.sync(() => {
2819 closing = true;
2820 }),
2821 );
2822
2823 const registerThread = (
2824 nativeSession: OpenCodeSession,
2825 providerThread: OrchestrationV2ProviderThread,
2826 appThread: OrchestrationV2AppThread | null,
2827 ): OpenCodeThreadState => {
2828 const existing = threads.get(nativeSession.id);
2829 if (existing !== undefined) {
2830 existing.providerThread = providerThread;
2831 if (appThread !== null) existing.appThread = appThread;
2832 return existing;
2833 }
2834 const state: OpenCodeThreadState = {
2835 nativeSessionId: nativeSession.id,
2836 providerThread,
2837 appThread,
2838 activeTurn: null,
2839 providerTurns: new Map(),
2840 messages: new Map(),
2841 runtimeRequests: new Map(),
2842 messageRoles: new Map(),
2843 userMessageIds: [],
2844 parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null,
2845 nextChildTurnOrdinal: 1,
2846 nextAdmissionGeneration: 1,
2847 };
2848 threads.set(nativeSession.id, state);
2849 return state;
2850 };
2851
2852 const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => {
2853 const text = providerMessageTextWithAttachmentPaths({
2854 text: turnInput.message.text,
2855 attachments: turnInput.message.attachments,
2856 resolveAttachmentPath: host.resolveAttachmentPath,
2857 }).trim();
2858 const files = OpenCodeRuntime.toOpenCodeFileParts({
2859 attachments: turnInput.message.attachments,
2860 resolveAttachmentPath: host.resolveAttachmentPath,
2861 });
2862 if (text.length === 0 && files.length === 0) {
2863 throw protocolError("OpenCode turns require text or at least one valid attachment");
2864 }
2865 return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files];
2866 };
2867
2868 const readSnapshot = Effect.fnUntraced(function* (
2869 providerThread: OrchestrationV2ProviderThread,
2870 ) {
2871 const sessionId = nativeThreadId(providerThread);
2872 const sessionResponse = yield* sdkCall("session.get", { sessionID: sessionId }, () =>
2873 client.session.get({ sessionID: sessionId }),
2874 );
2875 const nativeSession = unwrapData("session.get", sessionResponse);
2876 const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () =>
2877 client.session.messages({ sessionID: sessionId }),
2878 );
2879 const nativeMessages = [];
2880 for (const entry of unwrapData("session.messages", response)) {
2881 // OpenCode retains reverted records in session.messages. Its
2882 // revert marker identifies the first removed message, so stop
2883 // before it or a restart resurrects rolled-back assistant work.
2884 if (entry.info.id === nativeSession.revert?.messageID) break;
2885 nativeMessages.push(entry);
2886 }
2887 const state = threads.get(sessionId);
2888 const snapshotNow = yield* DateTime.now;
2889 const messages: Array<OrchestrationV2ConversationMessage> = nativeMessages.flatMap(
2890 ({ info, parts }) => {
2891 const text = parts
2892 .filter(
2893 (part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text",
2894 )
2895 .filter((part) => part.ignored !== true && part.synthetic !== true)
2896 .map((part) => part.text)
2897 .join("\n");
2898 if (text.length === 0) return [];
2899 const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow);
2900 return [
2901 {
2902 createdBy: info.role === "user" ? "user" : "agent",
2903 creationSource: "provider",
2904 id: idAllocator.derive.messageFromProviderItem({
2905 driver: OPENCODE_PROVIDER,
2906 nativeItemId: info.id,
2907 }),
2908 threadId: providerThread.appThreadId ?? input.threadId,
2909 runId: null,
2910 nodeId: null,
2911 role: info.role,
2912 text,
2913 attachments: [],
2914 streaming: false,
2915 createdAt,
2916 updatedAt:
2917 info.role === "assistant"
2918 ? dateTimeFromEpoch(info.time.completed, createdAt)
2919 : createdAt,
2920 },
2921 ];
2922 },
2923 );
2924 const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id;
2925 return {
2926 providerThread: {
2927 ...providerThread,
2928 providerSessionId: input.providerSessionId,
2929 nativeConversationHeadRef:
2930 lastUser === undefined ? null : providerRef(lastUser, "weak"),
2931 status: "idle" as const,
2932 updatedAt: snapshotNow,
2933 },
2934 providerTurns: state === undefined ? [] : [...state.providerTurns.values()],
2935 messages,
2936 runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()],
2937 providerPayload: nativeMessages,
2938 };
2939 });
2940
2941 // Native commands wait for generation, unlike promptAsync. A user-message
2942 // receipt admits the run while the scoped request continues in the background.
2943 const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* (
2944 state: OpenCodeThreadState,
2945 turn: ActiveOpenCodeTurn,
2946 payload: Parameters<typeof client.session.promptAsync>[0] & {
2947 messageID: string;
2948 sessionID: string;
2949 },
2950 abortController: AbortController,
2951 ) {
2952 const text = payload.parts
2953 ?.filter((part) => part.type === "text")
2954 .map((part) => part.text)
2955 .join("\n")
2956 .trim();
2957 const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/);
2958 const command = match
2959 ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe(
2960 Effect.timeout("10 seconds"),
2961 Effect.orElseSucceed(() => []),
2962 )).find((entry) => entry.name === match[1])
2963 : undefined;
2964 if (!command) {
2965 return yield* sdkCall("session.promptAsync", payload, (signal) =>
2966 client.session.promptAsync(payload, {
2967 signal: AbortSignal.any([signal, abortController.signal]),
2968 }),
2969 ).pipe(Effect.asVoid);
2970 }
2971 const receipt = Deferred.makeUnsafe<void>();
2972 commandReceipts.set(payload.messageID, receipt);
2973 const controllers =
2974 commandControllers.get(payload.sessionID) ?? new Set<AbortController>();
2975 controllers.add(abortController);
2976 commandControllers.set(payload.sessionID, controllers);
2977 const generation = turn.admissionGeneration;
2978 const commandPayload = {
2979 sessionID: payload.sessionID,
2980 messageID: payload.messageID,
2981 command: command.name,
2982 arguments: match?.[2] ?? "",
2983 ...(payload.model
2984 ? { model: `${payload.model.providerID}/${payload.model.modelID}` }
2985 : {}),
2986 ...(payload.agent ? { agent: payload.agent } : {}),
2987 ...(payload.variant ? { variant: payload.variant } : {}),
2988 parts: payload.parts?.filter((part) => part.type === "file") ?? [],
2989 };
2990 const request = yield* sdkCall("session.command", commandPayload, (signal) =>
2991 client.session.command(commandPayload, {
2992 signal: AbortSignal.any([signal, abortController.signal]),
2993 }),
2994 ).pipe(
2995 Effect.asVoid,
2996 Effect.tapError((cause) =>
2997 abortController.signal.aborted ||
2998 turn.finalized ||
2999 turn.admissionGeneration !== generation
3000 ? Effect.void
3001 : finalizeTurn(state, turn, "failed", {
3002 failure: makeProviderFailure({ cause, class: "provider_error" }),
3003 }),
3004 ),
3005 Effect.ensuring(
3006 Effect.sync(() => {
3007 commandReceipts.delete(payload.messageID);
3008 controllers.delete(abortController);
3009 if (controllers.size === 0) commandControllers.delete(payload.sessionID);
3010 }),
3011 ),
3012 Effect.forkIn(scope),
3013 );
3014 yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe(
3015 Effect.timeout("10 seconds"),
3016 Effect.catchTags({
3017 TimeoutError: (cause) => {
3018 const error = new OpenCodeRuntime.OpenCodeRuntimeError({
3019 operation: "session.command",
3020 detail: "OpenCode command admission did not complete within 10 seconds.",
3021 cause,
3022 });
3023 abortController.abort();
3024 return finalizeTurn(state, turn, "failed", {
3025 failure: makeProviderFailure({ cause: error, class: "provider_error" }),
3026 }).pipe(Effect.andThen(Effect.fail(error)));
3027 },
3028 }),
3029 );
3030 });
3031
3032 const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
3033 instanceId: options.instanceId,
3034 driver: OPENCODE_PROVIDER,
3035 providerSessionId: input.providerSessionId,
3036 providerSession: sessionEntity,
3037 events: Stream.fromEffectRepeat(Queue.take(events)),
3038 hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0),
3039 ensureThread: (threadInput) =>
3040 Effect.gen(function* () {
3041 // Only a row that already carries a native session can be
3042 // resumed; a placeholder without one still needs session.create.
3043 if (threadInput.existingProviderThread?.nativeThreadRef != null) {
3044 return yield* runtimeSession.resumeThread({
3045 providerThread: threadInput.existingProviderThread,
3046 });
3047 }
3048 // No title: OpenCode generates one from the first prompt only when
3049 // session.create leaves it unset (SessionPrompt.ensureTitle).
3050 const response = yield* sdkCall(
3051 "session.create",
3052 { permission: openCodePermissionRules(threadInput.runtimePolicy) },
3053 () =>
3054 client.session.create({
3055 permission: openCodePermissionRules(threadInput.runtimePolicy),
3056 }),
3057 );
3058 const nativeSession = unwrapData("session.create", response);
3059 const createdAt = yield* DateTime.now;
3060 const created = makeProviderThread({
3061 idAllocator,
3062 providerInstanceId: options.instanceId,
3063 providerSessionId: input.providerSessionId,
3064 appThreadId: threadInput.threadId,
3065 nativeSession,
3066 now: createdAt,
3067 });
3068 const existing = threadInput.existingProviderThread;
3069 // Bind the new native session to the caller's row when one was
3070 // handed over: a second live row per app thread would make
3071 // `activeProviderThreadId` flap between the two on every update.
3072 const providerThread =
3073 existing === undefined
3074 ? created
3075 : {
3076 ...existing,
3077 providerSessionId: input.providerSessionId,
3078 nativeThreadRef: created.nativeThreadRef,
3079 nativeConversationHeadRef: created.nativeConversationHeadRef,
3080 status: created.status,
3081 updatedAt: created.updatedAt,
3082 };
3083 registerThread(nativeSession, providerThread, null);
3084 return providerThread;
3085 }).pipe(
3086 Effect.mapError(
3087 (cause) =>
3088 new ProviderAdapter.ProviderAdapterEnsureThreadError({
3089 driver: OPENCODE_PROVIDER,
3090 threadId: threadInput.threadId,
3091 cause,
3092 }),
3093 ),
3094 ),
3095 resumeThread: (threadInput) =>
3096 Effect.gen(function* () {
3097 const sessionId = nativeThreadId(threadInput.providerThread);
3098 const response = yield* sdkCall("session.get", { sessionID: sessionId }, () =>
3099 client.session.get({ sessionID: sessionId }),
3100 );
3101 const nativeSession = unwrapData("session.get", response);
3102 const resumedAt = yield* DateTime.now;
3103 const providerThread = {
3104 ...threadInput.providerThread,
3105 providerSessionId: input.providerSessionId,
3106 status: "idle" as const,
3107 updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt),
3108 };
3109 registerThread(nativeSession, providerThread, null);
3110 return providerThread;
3111 }).pipe(
3112 Effect.mapError(
3113 (cause) =>
3114 new ProviderAdapter.ProviderAdapterResumeThreadError({
3115 driver: OPENCODE_PROVIDER,
3116 providerSessionId: input.providerSessionId,
3117 providerThreadId: threadInput.providerThread.id,
3118 cause,
3119 }),
3120 ),
3121 ),
3122 compactThread: (turnInput) =>
3123 runtimeSession.startTurn({
3124 ...turnInput,
3125 message: { ...turnInput.message, text: "/compact" },
3126 }),
3127 startTurn: (turnInput) =>
3128 Effect.gen(function* () {
3129 if (nativeStreamFailure !== null) {
3130 return yield* protocolError(
3131 "OpenCode event stream has ended; reconnect the provider session before starting another turn.",
3132 );
3133 }
3134 const sessionId = nativeThreadId(turnInput.providerThread);
3135 const state = threads.get(sessionId);
3136 if (state === undefined) {
3137 return yield* protocolError(`OpenCode session ${sessionId} is not registered`);
3138 }
3139 if (state.activeTurn !== null) {
3140 return yield* protocolError(
3141 `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`,
3142 );
3143 }
3144 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(
3145 turnInput.modelSelection.model,
3146 );
3147 if (parsedModel === null) {
3148 return yield* protocolError(
3149 `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`,
3150 );
3151 }
3152 const isCompaction =
3153 turnInput.message.text.trim() === "/compact" &&
3154 turnInput.message.attachments.length === 0;
3155 const parts = isCompaction ? [] : resolvePromptParts(turnInput);
3156 const startedAt = yield* DateTime.now;
3157 const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`;
3158 const providerTurnId = idAllocator.derive.providerTurn({
3159 driver: OPENCODE_PROVIDER,
3160 nativeTurnId: syntheticNativeTurnId,
3161 });
3162 const providerTurn: OrchestrationV2ProviderTurn = {
3163 id: providerTurnId,
3164 providerThreadId: turnInput.providerThread.id,
3165 nodeId: turnInput.rootNodeId,
3166 runAttemptId: turnInput.attemptId,
3167 nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"),
3168 ordinal: turnInput.providerTurnOrdinal,
3169 status: "running",
3170 startedAt,
3171 completedAt: null,
3172 };
3173 const admissionMessageId = yield* makeOpenCodeMessageId();
3174 // No Effect may be yielded between this check and installing the
3175 // turn. If the event stream ended while IDs were being prepared,
3176 // registering afterward would leave a running turn that the EOF
3177 // handler had already finished scanning.
3178 if (nativeStreamFailure !== null) {
3179 return yield* protocolError(
3180 "OpenCode event stream has ended; reconnect the provider session before starting another turn.",
3181 );
3182 }
3183 if (state.activeTurn !== null) {
3184 return yield* protocolError(
3185 `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`,
3186 );
3187 }
3188 const turn: ActiveOpenCodeTurn = {
3189 isRoot: true,
3190 threadId: turnInput.threadId,
3191 runId: turnInput.runId,
3192 rootNodeId: turnInput.rootNodeId,
3193 appThread: turnInput.appThread,
3194 modelSelection: turnInput.modelSelection,
3195 runtimePolicy: turnInput.runtimePolicy,
3196 providerTurnId,
3197 providerThreadId: turnInput.providerThread.id,
3198 providerTurnOrdinal: turnInput.providerTurnOrdinal,
3199 runOrdinal: turnInput.runOrdinal,
3200 runAttemptId: turnInput.attemptId,
3201 startedAt,
3202 itemOrdinals: new Map(),
3203 usage: makeOpenCodeTurnTokenUsageAccumulator(),
3204 parts: new Map(),
3205 partIdsByMessage: new Map(),
3206 toolNamesByCallId: new Map(),
3207 providerTurn,
3208 nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1,
3209 nativeUserMessageId: null,
3210 admissionMessageId,
3211 interrupted: false,
3212 finalized: false,
3213 planId: null,
3214 admissionGeneration: state.nextAdmissionGeneration++,
3215 admissionReconciliationGeneration: null,
3216 admissionPending: !isCompaction,
3217 admissionAccepted: isCompaction,
3218 admissionMessageObserved: false,
3219 idleDuringAdmission: false,
3220 admissionSettled: Deferred.makeUnsafe<void>(),
3221 admissionAbortController: new AbortController(),
3222 };
3223 if (turn.admissionMessageId !== null)
3224 turn.usage.promptMessageIds.add(turn.admissionMessageId);
3225 const admissionSettled = turn.admissionSettled;
3226 const admissionAbortController = turn.admissionAbortController;
3227 state.appThread = turnInput.appThread;
3228 state.activeTurn = turn;
3229 state.providerTurns.set(String(providerTurnId), providerTurn);
3230 yield* emitProviderTurn(state, turn, "running", null);
3231 yield* updateProviderThread(state, {
3232 status: "active",
3233 firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal,
3234 lastRunOrdinal: turnInput.runOrdinal,
3235 });
3236 yield* updateProviderSession("running", null);
3237 if (isCompaction) {
3238 yield* sdkCall(
3239 "session.summarize",
3240 { sessionID: sessionId, ...parsedModel, auto: false },
3241 (signal) =>
3242 client.session.summarize(
3243 { sessionID: sessionId, ...parsedModel, auto: false },
3244 { signal: AbortSignal.any([signal, admissionAbortController!.signal]) },
3245 ),
3246 ).pipe(
3247 Effect.tap(() =>
3248 turn.interrupted || turn.finalized || nativeStreamFailure !== null
3249 ? Effect.void
3250 : emitCompactionItem(state, turn),
3251 ),
3252 Effect.tap(() =>
3253 finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "completed"),
3254 ),
3255 Effect.tapError((cause) =>
3256 finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "failed", {
3257 failure: makeProviderFailure({ cause, class: "provider_error" }),
3258 }),
3259 ),
3260 Effect.ensuring(Deferred.succeed(admissionSettled, undefined)),
3261 );
3262 return;
3263 }
3264 const systemPrompt = [
3265 orchestrationSystemPrompt,
3266 buildRuntimeInstructions({
3267 harness: "OpenCode",
3268 model: turnInput.modelSelection.model,
3269 }),
3270 ]
3271 .filter(Boolean)
3272 .join("\n\n");
3273 const agent =
3274 getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ??
3275 (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined);
3276 const variant = getModelSelectionStringOptionValue(
3277 turnInput.modelSelection,
3278 "variant",
3279 );
3280 yield* submitPrompt(
3281 state,
3282 turn,
3283 {
3284 sessionID: sessionId,
3285 messageID: turn.admissionMessageId!,
3286 model: parsedModel,
3287 ...(agent === undefined ? {} : { agent }),
3288 ...(variant === undefined ? {} : { variant }),
3289 system: systemPrompt,
3290 parts,
3291 },
3292 admissionAbortController!,
3293 ).pipe(
3294 Effect.tapError((cause) =>
3295 admissionAbortController!.signal.aborted
3296 ? Effect.void
3297 : finalizeTurn(state, turn, "failed", {
3298 failure: makeProviderFailure({ cause, class: "provider_error" }),
3299 }),
3300 ),
3301 Effect.catch((cause) =>
3302 admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause),
3303 ),
3304 Effect.ensuring(
3305 Effect.all([
3306 Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore),
3307 Effect.sync(() => {
3308 if (turn.admissionAbortController === admissionAbortController) {
3309 turn.admissionAbortController = null;
3310 }
3311 }),
3312 ]).pipe(Effect.asVoid),
3313 ),
3314 );
3315 if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) {
3316 const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted");
3317 if (admissionAction === "reconcile-idle") {
3318 yield* reconcilePromptAdmission(state, turn);
3319 }
3320 }
3321 }).pipe(
3322 Effect.mapError(
3323 (cause) =>
3324 new ProviderAdapter.ProviderAdapterTurnStartError({
3325 driver: OPENCODE_PROVIDER,
3326 threadId: turnInput.threadId,
3327 providerThreadId: turnInput.providerThread.id,
3328 runId: turnInput.runId,
3329 cause,
3330 }),
3331 ),
3332 ),
3333 steerTurn: (steerInput) =>
3334 Effect.gen(function* () {
3335 const sessionId = nativeThreadId(steerInput.providerThread);
3336 const state = threads.get(sessionId);
3337 const turn = state?.activeTurn;
3338 if (
3339 state === undefined ||
3340 turn === undefined ||
3341 turn === null ||
3342 turn.providerTurnId !== steerInput.providerTurnId ||
3343 turn.interrupted
3344 ) {
3345 return yield* protocolError(
3346 `OpenCode turn ${steerInput.providerTurnId} is not active`,
3347 );
3348 }
3349 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model);
3350 if (parsedModel === null) {
3351 return yield* protocolError(
3352 `OpenCode model '${turn.modelSelection.model}' must use provider/model format`,
3353 );
3354 }
3355 const text = providerMessageTextWithAttachmentPaths({
3356 text: steerInput.message.text,
3357 attachments: steerInput.message.attachments,
3358 resolveAttachmentPath: host.resolveAttachmentPath,
3359 }).trim();
3360 const files = OpenCodeRuntime.toOpenCodeFileParts({
3361 attachments: steerInput.message.attachments,
3362 resolveAttachmentPath: host.resolveAttachmentPath,
3363 });
3364 if (text.length === 0 && files.length === 0) {
3365 return yield* protocolError("OpenCode steering requires text or an attachment");
3366 }
3367 const parts = [
3368 ...(text.length === 0 ? [] : [{ type: "text" as const, text }]),
3369 ...files,
3370 ];
3371 turn.admissionGeneration = state.nextAdmissionGeneration++;
3372 turn.admissionMessageId = yield* makeOpenCodeMessageId();
3373 turn.admissionPending = true;
3374 turn.admissionAccepted = false;
3375 turn.admissionMessageObserved = false;
3376 turn.idleDuringAdmission = false;
3377 turn.admissionSettled = Deferred.makeUnsafe<void>();
3378 turn.admissionAbortController = new AbortController();
3379 if (turn.admissionMessageId !== null)
3380 turn.usage.promptMessageIds.add(turn.admissionMessageId);
3381 const admissionSettled = turn.admissionSettled;
3382 const admissionAbortController = turn.admissionAbortController;
3383 yield* submitPrompt(
3384 state,
3385 turn,
3386 {
3387 sessionID: sessionId,
3388 messageID: turn.admissionMessageId,
3389 model: parsedModel,
3390 parts,
3391 },
3392 admissionAbortController!,
3393 ).pipe(
3394 Effect.ensuring(
3395 Effect.all([
3396 Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore),
3397 Effect.sync(() => {
3398 if (turn.admissionAbortController === admissionAbortController) {
3399 turn.admissionAbortController = null;
3400 }
3401 }),
3402 ]).pipe(Effect.asVoid),
3403 ),
3404 );
3405 if (state.activeTurn === turn && !turn.finalized) {
3406 const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted");
3407 if (admissionAction === "reconcile-idle") {
3408 yield* reconcilePromptAdmission(state, turn);
3409 }
3410 }
3411 }).pipe(
3412 Effect.mapError(
3413 (cause) =>
3414 new ProviderAdapter.ProviderAdapterSteerRunError({
3415 driver: OPENCODE_PROVIDER,
3416 providerThreadId: steerInput.providerThread.id,
3417 providerTurnId: steerInput.providerTurnId,
3418 cause,
3419 }),
3420 ),
3421 ),
3422 interruptTurn: (interruptInput) =>
3423 Effect.gen(function* () {
3424 const sessionId = nativeThreadId(interruptInput.providerThread);
3425 const state = threads.get(sessionId);
3426 const turn = state?.activeTurn;
3427 // Stop on a settled turn: a background task child can outlive
3428 // it (busySessionIds), and aborting the descendants ends it.
3429 if (
3430 (turn === undefined || turn === null) &&
3431 interruptInput.requestRuntimeRestart === true
3432 ) {
3433 for (const controller of commandControllers.get(sessionId) ?? [])
3434 controller.abort();
3435 yield* abortDescendants(sessionId);
3436 return;
3437 }
3438 if (
3439 turn === undefined ||
3440 turn === null ||
3441 turn.providerTurnId !== interruptInput.providerTurnId
3442 ) {
3443 return yield* protocolError(
3444 `OpenCode turn ${interruptInput.providerTurnId} is not active`,
3445 );
3446 }
3447 turn.interrupted = true;
3448 for (const controller of commandControllers.get(sessionId) ?? []) controller.abort();
3449 const admissionWasPending = turn.admissionPending;
3450 cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++);
3451 turn.admissionAbortController?.abort();
3452 if (admissionWasPending) {
3453 // Settle the cancelled request before sending the session abort.
3454 // A timed-out local request must not prevent the definitive
3455 // server-side abort below from running.
3456 yield* Deferred.await(turn.admissionSettled).pipe(
3457 Effect.timeout("10 seconds"),
3458 Effect.ignore,
3459 );
3460 }
3461 yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) =>
3462 client.session.abort({ sessionID: sessionId }, { signal }),
3463 ).pipe(
3464 Effect.timeout("10 seconds"),
3465 // The turn can settle while the abort is in flight, and
3466 // aborting an already-idle session fails. That stop still
3467 // succeeded; only surface failures for a turn that is
3468 // genuinely still running.
3469 Effect.catch((cause) =>
3470 turn.finalized
3471 ? Effect.void
3472 : Effect.suspend(() => {
3473 turn.interrupted = false;
3474 return Effect.fail(cause);
3475 }),
3476 ),
3477 );
3478 // Root abort does not stop child sessions. Report incomplete cleanup
3479 // even if the root has already emitted its terminal event.
3480 yield* abortDescendants(sessionId);
3481 }).pipe(
3482 Effect.mapError(
3483 (cause) =>
3484 new ProviderAdapter.ProviderAdapterInterruptError({
3485 driver: OPENCODE_PROVIDER,
3486 providerThreadId: interruptInput.providerThread.id,
3487 providerTurnId: interruptInput.providerTurnId,
3488 cause,
3489 }),
3490 ),
3491 ),
3492 respondToRuntimeRequest: (requestInput) =>
3493 Effect.gen(function* () {
3494 const pending = pendingRequests.get(String(requestInput.requestId));
3495 if (pending === undefined) {
3496 return yield* protocolError(
3497 `No pending OpenCode request ${requestInput.requestId}`,
3498 );
3499 }
3500 if (pending.question !== undefined) {
3501 if (requestInput.answers === undefined) {
3502 return yield* protocolError(
3503 `OpenCode question request ${requestInput.requestId} requires answers`,
3504 );
3505 }
3506 const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers(
3507 pending.question,
3508 requestInput.answers,
3509 );
3510 yield* sdkCall(
3511 "question.reply",
3512 { requestID: pending.nativeRequestId, answers },
3513 (signal) =>
3514 client.question.reply(
3515 {
3516 requestID: pending.nativeRequestId,
3517 answers,
3518 },
3519 { signal },
3520 ),
3521 ).pipe(Effect.timeout("10 seconds"));
3522 return;
3523 }
3524 if (requestInput.decision === undefined) {
3525 return yield* protocolError(
3526 `OpenCode approval request ${requestInput.requestId} requires a decision`,
3527 );
3528 }
3529 const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision);
3530 yield* sdkCall(
3531 "permission.reply",
3532 { requestID: pending.nativeRequestId, reply },
3533 (signal) =>
3534 client.permission.reply(
3535 {
3536 requestID: pending.nativeRequestId,
3537 reply,
3538 },
3539 { signal },
3540 ),
3541 ).pipe(Effect.timeout("10 seconds"));
3542 }).pipe(
3543 Effect.mapError(
3544 (cause) =>
3545 new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
3546 driver: OPENCODE_PROVIDER,
3547 requestId: requestInput.requestId,
3548 cause,
3549 }),
3550 ),
3551 ),
3552 readThreadSnapshot: (snapshotInput) =>
3553 readSnapshot(snapshotInput.providerThread).pipe(
3554 Effect.mapError(
3555 (cause) =>
3556 new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
3557 driver: OPENCODE_PROVIDER,
3558 providerThreadId: snapshotInput.providerThread.id,
3559 cause,
3560 }),
3561 ),
3562 ),
3563 rollbackThread: (rollbackInput) =>
3564 Effect.gen(function* () {
3565 const sessionId = nativeThreadId(rollbackInput.providerThread);
3566 const state = threads.get(sessionId);
3567 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
3568 return yield* protocolError(
3569 `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`,
3570 );
3571 }
3572 const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () =>
3573 client.session.messages({ sessionID: sessionId }),
3574 );
3575 const messages = unwrapData("session.messages", response);
3576 let boundaryMessageId: string | undefined;
3577 if (rollbackInput.target.type === "thread_start") {
3578 boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id;
3579 } else {
3580 boundaryMessageId = openCodeBoundaryAfterProviderTurn(
3581 rollbackInput.providerThreadTurns,
3582 rollbackInput.target.providerTurn.id,
3583 );
3584 }
3585 let retainedThread = rollbackInput.providerThread;
3586 if (boundaryMessageId !== undefined) {
3587 const boundaryIndex = messages.findIndex(
3588 ({ info }) => info.id === boundaryMessageId,
3589 );
3590 if (boundaryIndex < 0)
3591 return yield* protocolError(
3592 "The OpenCode rewind boundary is no longer available.",
3593 );
3594 const fork = unwrapData(
3595 "session.fork",
3596 yield* sdkCall(
3597 "session.fork",
3598 { sessionID: sessionId, messageID: boundaryMessageId },
3599 () =>
3600 client.session.fork({ sessionID: sessionId, messageID: boundaryMessageId }),
3601 ),
3602 );
3603 const retained = unwrapData(
3604 "session.messages",
3605 yield* sdkCall("session.messages", { sessionID: fork.id }, () =>
3606 client.session.messages({ sessionID: fork.id }),
3607 ),
3608 );
3609 if (retained.length !== boundaryIndex)
3610 return yield* protocolError(
3611 "OpenCode did not preserve the requested rewind boundary.",
3612 );
3613 yield* sdkCall("session.update", { sessionID: fork.id }, () =>
3614 client.session.update({
3615 sessionID: fork.id,
3616 permission: openCodePermissionRules(input.runtimePolicy),
3617 }),
3618 );
3619 retainedThread = {
3620 ...rollbackInput.providerThread,
3621 nativeThreadRef: providerRef(fork.id),
3622 };
3623 registerThread(fork, retainedThread, state?.appThread ?? null);
3624 }
3625 const snapshot = yield* readSnapshot(retainedThread);
3626 return {
3627 ...snapshot,
3628 providerThread: {
3629 ...snapshot.providerThread,
3630 nativeConversationHeadRef:
3631 rollbackInput.target.type === "provider_turn"
3632 ? rollbackInput.target.providerTurn.nativeTurnRef
3633 : null,
3634 },
3635 };
3636 }).pipe(
3637 Effect.mapError(
3638 (cause) =>
3639 new ProviderAdapter.ProviderAdapterRollbackThreadError({
3640 driver: OPENCODE_PROVIDER,
3641 providerThreadId: rollbackInput.providerThread.id,
3642 checkpointId: rollbackInput.target.checkpointId,
3643 cause,
3644 }),
3645 ),
3646 ),
3647 forkThread: (forkInput) =>
3648 Effect.gen(function* () {
3649 const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread);
3650 const sourceState = threads.get(sourceSessionId);
3651 if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) {
3652 return yield* protocolError(
3653 `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`,
3654 );
3655 }
3656 let boundaryMessageId: string | undefined;
3657 if (forkInput.providerTurnId !== undefined) {
3658 const sourceTurns = forkInput.sourceProviderTurns ?? [];
3659 const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId);
3660 if (selected === undefined) {
3661 return yield* protocolError(
3662 `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`,
3663 );
3664 }
3665 boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id);
3666 }
3667 const response = yield* sdkCall(
3668 "session.fork",
3669 {
3670 sessionID: sourceSessionId,
3671 ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }),
3672 },
3673 () =>
3674 client.session.fork({
3675 sessionID: sourceSessionId,
3676 ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }),
3677 }),
3678 );
3679 const nativeSession = unwrapData("session.fork", response);
3680 const forkedAt = yield* DateTime.now;
3681 const providerThread = makeProviderThread({
3682 idAllocator,
3683 providerInstanceId: options.instanceId,
3684 providerSessionId: input.providerSessionId,
3685 appThreadId: forkInput.targetThreadId,
3686 ...(forkInput.ownerNodeId === undefined
3687 ? {}
3688 : { ownerNodeId: forkInput.ownerNodeId }),
3689 nativeSession,
3690 forkedFrom: {
3691 providerThreadId: forkInput.sourceProviderThread.id,
3692 ...(forkInput.providerTurnId === undefined
3693 ? {}
3694 : { providerTurnId: forkInput.providerTurnId }),
3695 },
3696 now: forkedAt,
3697 });
3698 registerThread(nativeSession, providerThread, null);
3699 return providerThread;
3700 }).pipe(
3701 Effect.mapError(
3702 (cause) =>
3703 new ProviderAdapter.ProviderAdapterForkThreadError({
3704 driver: OPENCODE_PROVIDER,
3705 providerThreadId: forkInput.sourceProviderThread.id,
3706 cause,
3707 }),
3708 ),
3709 ),
3710 };
3711
3712 return runtimeSession;
3713 },
3714 (effect, input) =>
3715 effect.pipe(
3716 Effect.mapError(
3717 (cause) =>
3718 new ProviderAdapter.ProviderAdapterOpenSessionError({
3719 driver: OPENCODE_PROVIDER,
3720 providerSessionId: input.providerSessionId,
3721 cause,
3722 }),
3723 ),
3724 ),
3725 ),
3726 });
3727}
3728
3729export type OpenCodeAdapterV2DriverEnv =
3730 | OpenCodeRuntime.OpenCodeRuntime
3731 | IdAllocator.IdAllocatorV2
3732 | ProviderEventLoggers.ProviderEventLoggers
3733 | ProviderHost.ProviderHost;
3734
3735export const OpenCodeAdapterV2Driver: ProviderAdapterDriver<
3736 OpenCodeSettings,
3737 OpenCodeAdapterV2DriverEnv
3738> = {
3739 driverKind: OPENCODE_PROVIDER,
3740 configSchema: OpenCodeSettings,
3741 defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS,
3742 create: Effect.fn("OpenCodeAdapterV2Driver.create")(
3743 function* (input: ProviderAdapterDriverCreateInput<OpenCodeSettings>) {
3744 const hostEnvironment = yield* HostProcessEnvironment;
3745 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
3746 const idAllocator = yield* IdAllocator.IdAllocatorV2;
3747 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
3748 const host = yield* ProviderHost.ProviderHost;
3749 return makeOpenCodeAdapterV2({
3750 instanceId: input.instanceId,
3751 settings: { ...input.config, enabled: input.enabled },
3752 environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
3753 runtime: openCodeRuntime,
3754 idAllocator,
3755 host,
3756 ...(providerEventLoggers.native === undefined
3757 ? {}
3758 : { nativeEventLogger: providerEventLoggers.native }),
3759 });
3760 },
3761 (effect, input) =>
3762 effect.pipe(
3763 Effect.mapError(
3764 (cause) =>
3765 new ProviderAdapterDriverCreateError({
3766 driver: OPENCODE_PROVIDER,
3767 instanceId: input.instanceId,
3768 detail: "Failed to create OpenCode v2 adapter.",
3769 cause,
3770 }),
3771 ),
3772 ),
3773 ),
3774};
3775
3776const layer: Layer.Layer<ProviderAdapter.ProviderAdapterV2, never, OpenCodeAdapterV2DriverEnv> =
3777 Layer.effect(
3778 ProviderAdapter.ProviderAdapterV2,
3779 Effect.gen(function* () {
3780 const hostEnvironment = yield* HostProcessEnvironment;
3781 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
3782 const idAllocator = yield* IdAllocator.IdAllocatorV2;
3783 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
3784 const host = yield* ProviderHost.ProviderHost;
3785 return makeOpenCodeAdapterV2({
3786 instanceId: OPENCODE_DEFAULT_INSTANCE_ID,
3787 settings: DEFAULT_OPENCODE_SETTINGS,
3788 environment: hostEnvironment,
3789 runtime: openCodeRuntime,
3790 idAllocator,
3791 host,
3792 ...(providerEventLoggers.native === undefined
3793 ? {}
3794 : { nativeEventLogger: providerEventLoggers.native }),
3795 });
3796 }),
3797 );
packages/provider-opencode/src/server/driver.test.ts created+274
...@@ -0,0 +1,274 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { assert, it } from "@effect/vitest";
8import { ProviderInstanceId } from "@t3tools/contracts";
9import type { OpenCodeSettings } from "../settings.ts";
10import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as Layer from "effect/Layer";
14import * as TestClock from "effect/testing/TestClock";
15import { HttpClient } from "effect/http";
16import { ChildProcessSpawner } from "effect/process";
17
18import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
19import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
20import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver";
21import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
22import {
23 OPENCODE_2_RESPONSES,
24 OPENCODE_2_WORKSPACE_RESPONSES,
25 replayOpenCodeServer,
26} from "./probeResponses.fixture.ts";
27import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts";
28import { layerTestProviderHost } from "@t3tools/provider-testing/host";
29
30const serverStarts: Array<string> = [];
31const reachedServer = (operation: string) =>
32 Effect.sync(() => serverStarts.push(operation)).pipe(
33 Effect.andThen(
34 Effect.fail(
35 new OpenCodeRuntime.OpenCodeRuntimeError({
36 operation,
37 detail: "reached a 1.x server path",
38 }),
39 ),
40 ),
41 );
42// Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused.
43const openCode2Runtime = {
44 runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }),
45 startOpenCodeServerProcess: () => reachedServer("start"),
46 connectToOpenCodeServer: () => reachedServer("connect"),
47} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape;
48
49const layer = Layer.mergeAll(
50 IdAllocator.layer,
51 layerTestProviderHost(),
52 Layer.succeed(
53 ProviderEventLoggers.ProviderEventLoggers,
54 ProviderEventLoggers.NoOpProviderEventLoggers,
55 ),
56 Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime),
57).pipe(Layer.provideMerge(NodeServices.layer));
58
59const create = (config: Partial<OpenCodeSettings>, http: HttpClient.HttpClient) =>
60 OpenCodeDriver.create({
61 instanceId: ProviderInstanceId.make("opencode-test"),
62 displayName: undefined,
63 environment: [],
64 enabled: true,
65 config: { ...OpenCodeDriver.defaultConfig(), ...config },
66 }).pipe(Effect.provideService(HttpClient.HttpClient, http));
67
68const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP"));
69
70it.layer(layer)("OpenCodeDriver runtime selection", (it) => {
71 it.effect("never starts a 1.x server for an OpenCode 2 instance", () =>
72 Effect.gen(function* () {
73 serverStarts.length = 0;
74 const instance = yield* create({}, noHttp);
75 // Text generation goes to the instance's 2.x server, which this test refuses to start.
76 yield* Effect.flip(
77 instance.textGeneration.generateThreadTitle({
78 cwd: process.cwd(),
79 message: "hello",
80 modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" },
81 }),
82 );
83 assert.deepStrictEqual(serverStarts, ["start"]);
84 }).pipe(Effect.scoped),
85 );
86
87 it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () =>
88 Effect.gen(function* () {
89 serverStarts.length = 0;
90 const requested: Array<string> = [];
91 const server = replayOpenCodeServer(
92 { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES },
93 "secret",
94 requested,
95 );
96 const instance = yield* create(
97 { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" },
98 server,
99 );
100
101 const workspace = yield* instance.snapshotForCwd!("/work");
102 assert.includeMembers(
103 workspace.skills.map((skill) => skill.name),
104 ["plum", "opencode"],
105 );
106 assert.deepStrictEqual(
107 workspace.slashCommands.map((command) => command.name),
108 ["compact", "init", "review", "bee"],
109 );
110 assert.include(requested, "/api/skill");
111 assert.deepStrictEqual(serverStarts, []);
112 }).pipe(Effect.scoped),
113 );
114
115 it.effect("answers capability reads without waiting on an unreachable server", () =>
116 Effect.gen(function* () {
117 const hang = HttpClient.make(() => Effect.never);
118 const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang);
119
120 // No probe has succeeded yet, and the server never answers: the 1.x default applies.
121 const capabilities = yield* instance.orchestrationAdapter
122 .getCapabilities()
123 .pipe(Effect.forkChild);
124 yield* Effect.yieldNow;
125 assert.isDefined(capabilities.pollUnsafe());
126 yield* Fiber.join(capabilities);
127 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
128 );
129});
130
131// npm links `<prefix>/bin/opencode` into the package that installed it:
132// `opencode-ai` for 1.x and `@opencode/cli` (whose bin is `bin/opencode.exe`) for 2.x.
133const npmGlobalInstall = (prefix: string, packagePath: ReadonlyArray<string>, bin: string) => {
134 const target = NodePath.join(prefix, "lib", "node_modules", ...packagePath, "bin", bin);
135 NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true });
136 NodeFS.writeFileSync(target, "#!/bin/sh\n");
137 NodeFS.chmodSync(target, 0o755);
138 const link = NodePath.join(prefix, "bin", "opencode");
139 NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true });
140 NodeFS.symlinkSync(target, link);
141 return link;
142};
143const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) =>
144 ProviderMaintenance.resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation), {
145 binaryPath,
146 env: { PATH: "" },
147 }).pipe(
148 Effect.provideService(
149 ChildProcessSpawner.ChildProcessSpawner,
150 ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")),
151 ),
152 Effect.provide(NodeServices.layer),
153 );
154
155it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
156 "updates an npm-global OpenCode 2 install as @opencode/cli and a 1.x one as opencode-ai",
157 () =>
158 Effect.gen(function* () {
159 const root = NodeFS.realpathSync(
160 NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-update-")),
161 );
162 const v2Prefix = NodePath.join(root, "v2");
163 const v1Prefix = NodePath.join(root, "v1");
164 const v2 = yield* resolveUpdate(
165 "v2",
166 npmGlobalInstall(v2Prefix, ["@opencode", "cli"], "opencode.exe"),
167 );
168 assert.deepStrictEqual(v2.update?.args, [
169 "install",
170 "-g",
171 "--prefix",
172 v2Prefix,
173 "--allow-scripts=@opencode/cli",
174 "@opencode/cli@latest",
175 ]);
176 const v1 = yield* resolveUpdate(
177 "v1",
178 npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"),
179 );
180 assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest");
181 // A 1.x install is never offered 2.x's package, or the reverse.
182 assert.isNull(
183 (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update,
184 );
185 assert.isNull(
186 (yield* resolveUpdate("v1", NodePath.join(v2Prefix, "bin", "opencode"))).update,
187 );
188 }),
189);
190
191// The update the driver offers follows the version the binary reports now.
192const versionOutput: { current: string | undefined } = { current: undefined };
193const versionProbes: Array<string> = [];
194const changingRuntime = {
195 runOpenCodeCommand: ({ binaryPath }: { readonly binaryPath: string }) =>
196 Effect.sync(() => versionProbes.push(binaryPath)).pipe(
197 Effect.andThen(
198 versionOutput.current === undefined
199 ? Effect.fail(
200 new OpenCodeRuntime.OpenCodeRuntimeError({
201 operation: "version",
202 detail: "no version",
203 }),
204 )
205 : Effect.succeed({ stdout: versionOutput.current, stderr: "", code: 0 }),
206 ),
207 ),
208 startOpenCodeServerProcess: () => reachedServer("start"),
209 connectToOpenCodeServer: () => reachedServer("connect"),
210} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape;
211const layerUpdate = Layer.mergeAll(
212 IdAllocator.layer,
213 layerTestProviderHost(),
214 Layer.succeed(
215 ProviderEventLoggers.ProviderEventLoggers,
216 ProviderEventLoggers.NoOpProviderEventLoggers,
217 ),
218 Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime),
219).pipe(Layer.provideMerge(NodeServices.layer));
220
221it.layer(layerUpdate)("OpenCodeDriver updates", (it) => {
222 it.effect("never runs the binary for a disabled instance's update check", () =>
223 Effect.gen(function* () {
224 versionProbes.length = 0;
225 versionOutput.current = "opencode v2.0.18\n";
226 const instance = yield* OpenCodeDriver.create({
227 instanceId: ProviderInstanceId.make("opencode-disabled"),
228 displayName: undefined,
229 environment: [],
230 enabled: false,
231 config: { ...OpenCodeDriver.defaultConfig(), binaryPath: "opencode" },
232 }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp));
233 const maintenance = yield* instance.snapshot.resolveMaintenance();
234 yield* instance.snapshot.resolveMaintenance({ fresh: true });
235 assert.isNull(maintenance.update);
236 assert.deepStrictEqual(versionProbes, []);
237 }).pipe(Effect.scoped),
238 );
239
240 it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
241 "offers no package update for an unknown version and follows a changed one on a fresh read",
242 () =>
243 Effect.gen(function* () {
244 const root = NodeFS.realpathSync(
245 NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-driver-update-")),
246 );
247 const binaryPath = npmGlobalInstall(NodePath.join(root, "v1"), ["opencode-ai"], "opencode");
248 const instance = yield* OpenCodeDriver.create({
249 instanceId: ProviderInstanceId.make("opencode-update"),
250 displayName: undefined,
251 environment: [],
252 enabled: true,
253 config: { ...OpenCodeDriver.defaultConfig(), binaryPath },
254 }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp));
255
256 // The version could not be read: which package owns the binary is unknown.
257 versionOutput.current = undefined;
258 const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true });
259 assert.isNull(unknown.update);
260 assert.isNull(unknown.packageName);
261
262 versionOutput.current = "1.18.32\n";
263 const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true });
264 assert.strictEqual(v1.packageName, "opencode-ai");
265
266 // The same path now reports 2.x (reinstalled in place): a fresh read re-probes
267 // and never offers 1.x's package for it.
268 versionOutput.current = "opencode v2.0.18\n";
269 const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true });
270 assert.strictEqual(v2.packageName, "@opencode/cli");
271 assert.isNull(v2.update);
272 }).pipe(Effect.scoped),
273 );
274});
packages/provider-opencode/src/server/driver.ts created+540
...@@ -0,0 +1,540 @@
1/**
2 * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime.
3 *
4 * Mirrors the Codex / Claude drivers: a plain value whose `create()`
5 * bundles `snapshot` / `adapter` / `textGeneration` closures over the
6 * per-instance `OpenCodeSettings`.
7 *
8 * Two instances with different `serverUrl`s therefore talk to independent
9 * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation
10 * shares spin up their own scoped child processes, and those child
11 * processes are released when the registry scope closes.
12 *
13 * @module provider/Drivers/OpenCodeDriver
14 */
15import { ProviderDriverKind } from "@t3tools/contracts";
16import { OpenCodeSettings } from "../settings.ts";
17import * as Crypto from "effect/Crypto";
18import * as Deferred from "effect/Deferred";
19import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";
21import * as FileSystem from "effect/FileSystem";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24import * as Stream from "effect/Stream";
25import { HttpClient } from "effect/http";
26import { ChildProcessSpawner } from "effect/process";
27
28import * as OpenCode2TextGeneration from "./v2/textGeneration.ts";
29import { makeOpenCodeTextGeneration } from "./textGeneration.ts";
30import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
31import * as OpenCodeAdapterV2 from "./adapter.ts";
32import * as OpenCode2AdapterV2 from "./v2/adapter.ts";
33import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
34import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
35import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
36import { readOpenCodeGoUsageLimits } from "./usageLimits.ts";
37import {
38 checkOpenCodeProviderStatus,
39 loadOpenCode2Workspace,
40 makeOpenCode2ModelLoader,
41 makePendingOpenCodeProvider,
42 openCode2CommandsToServerProviderSlashCommands,
43 openCode2SkillsToServerProviderSkills,
44 openCodeSkillsToServerProviderSkills,
45 openCodeCommandsToServerProviderSlashCommands,
46} from "./status.ts";
47import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
48import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
49import {
50 makeOpenCodeRuntimeProbe,
51 probeOpenCodeRuntime,
52 type ProbedOpenCode,
53} from "./versionProbe.ts";
54import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
55import * as OpenCode2Client from "./v2/OpenCode2Client.ts";
56import * as OpenCode2Server from "./v2/OpenCode2Server.ts";
57import {
58 defaultProviderContinuationIdentity,
59 type ProviderDriver,
60 type ProviderInstance,
61} from "@t3tools/provider-core/server/driver";
62import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
63import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
64import {
65 enrichProviderSnapshotWithVersionAdvisory,
66 makeCachedProviderMaintenanceResolution,
67 makeManualOnlyProviderMaintenanceCapabilities,
68 makePackageManagedProviderMaintenanceResolver,
69 normalizeCommandPath,
70 resolveProviderMaintenanceCapabilitiesEffect,
71} from "@t3tools/provider-core/server/maintenanceResolver";
72import {
73 haveProviderSnapshotSettingsChanged,
74 makeProviderSnapshotSettingsSource,
75 type ProviderSnapshotSettings,
76} from "@t3tools/provider-core/server/snapshotSettings";
77const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
78
79const DRIVER_KIND = ProviderDriverKind.make("opencode");
80
81function isOpenCodeNativeCommandPath(commandPath: string): boolean {
82 const normalized = normalizeCommandPath(commandPath);
83 return (
84 normalized.endsWith("/.opencode/bin/opencode") ||
85 normalized.endsWith("/.opencode/bin/opencode.exe")
86 );
87}
88
89/**
90 * OpenCode 1.x ships as `opencode-ai` and 2.x as `@opencode/cli`. An install is
91 * only ever updated within its own package: T3 never moves a 1.x install onto
92 * 2.x or back, since 2.x converts the shared database in place.
93 */
94export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) =>
95 makePackageManagedProviderMaintenanceResolver({
96 provider: DRIVER_KIND,
97 npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai",
98 nativeUpdate: {
99 args: ["upgrade"],
100 isCommandPath: isOpenCodeNativeCommandPath,
101 },
102 });
103
104type OpenCodeRuntimeProbe = Effect.Success<
105 ReturnType<typeof makeOpenCodeRuntimeProbe<OpenCodeRuntime.OpenCodeRuntimeError>>
106>;
107
108/** Runs `v2` for a 2.x instance and `v1` otherwise; a failed probe keeps 1.x, whose checks report it. */
109function byOpenCodeRuntime<A, E, R, PE>(
110 probed: Effect.Effect<ProbedOpenCode | undefined, PE>,
111 paths: { readonly v1: Effect.Effect<A, E, R>; readonly v2: Effect.Effect<A, E, R> },
112): Effect.Effect<A, E, R> {
113 return probed.pipe(
114 Effect.orElseSucceed(() => undefined),
115 Effect.flatMap((result) => (result?.generation === "v2" ? paths.v2 : paths.v1)),
116 );
117}
118
119/**
120 * Routes each adapter call to the runtime the instance's probe detected. Capability and selection
121 * reads are hot, so they use the last successful probe (1.x before one lands) and never wait on a
122 * slow server. Opening a session waits for a probe, so each server is spoken to in its own protocol.
123 */
124function selectOpenCodeRuntimeAdapter(input: {
125 readonly probe: OpenCodeRuntimeProbe;
126 readonly v1: ProviderAdapter.ProviderAdapterV2Shape;
127 readonly v2: ProviderAdapter.ProviderAdapterV2Shape;
128}): ProviderAdapter.ProviderAdapterV2Shape {
129 const pick = <PE>(probed: Effect.Effect<ProbedOpenCode | undefined, PE>) =>
130 byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) });
131 const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined));
132 return {
133 instanceId: input.v1.instanceId,
134 driver: DRIVER_KIND,
135 getCapabilities: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()),
136 planSelectionTransition: (transition) =>
137 Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)),
138 openSession: (session) =>
139 Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)),
140 };
141}
142
143/** Text generation runs on the server the instance's probe detected, each in its own protocol. */
144function selectOpenCodeRuntimeTextGeneration(
145 probe: OpenCodeRuntimeProbe,
146 v1: ProviderTextGeneration,
147 v2: ProviderTextGeneration,
148): ProviderTextGeneration {
149 return {
150 generateCommitMessage: (input) =>
151 byOpenCodeRuntime(probe.get, {
152 v1: v1.generateCommitMessage(input),
153 v2: v2.generateCommitMessage(input),
154 }),
155 generatePrContent: (input) =>
156 byOpenCodeRuntime(probe.get, {
157 v1: v1.generatePrContent(input),
158 v2: v2.generatePrContent(input),
159 }),
160 generateBranchName: (input) =>
161 byOpenCodeRuntime(probe.get, {
162 v1: v1.generateBranchName(input),
163 v2: v2.generateBranchName(input),
164 }),
165 generateThreadTitle: (input) =>
166 byOpenCodeRuntime(probe.get, {
167 v1: v1.generateThreadTitle(input),
168 v2: v2.generateThreadTitle(input),
169 }),
170 };
171}
172
173export type OpenCodeDriverEnv =
174 | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv
175 | ProviderHost.ProviderHost
176 | ChildProcessSpawner.ChildProcessSpawner
177 | Crypto.Crypto
178 | FileSystem.FileSystem
179 | HttpClient.HttpClient
180 | OpenCodeRuntime.OpenCodeRuntime
181 | Path.Path;
182
183export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> = {
184 driverKind: DRIVER_KIND,
185 metadata: {
186 displayName: "OpenCode",
187 supportsMultipleInstances: true,
188 },
189 configSchema: OpenCodeSettings,
190 defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}),
191 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
192 Effect.gen(function* () {
193 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
194 const fileSystem = yield* FileSystem.FileSystem;
195 const pathService = yield* Path.Path;
196 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
197 const httpClient = yield* HttpClient.HttpClient;
198 const crypto = yield* Crypto.Crypto;
199 const host = yield* ProviderHost.ProviderHost;
200 const processEnv = mergeProviderInstanceEnvironment(environment);
201 const continuationIdentity = defaultProviderContinuationIdentity({
202 driverKind: DRIVER_KIND,
203 instanceId,
204 });
205 const stampIdentity = withInstanceIdentity({
206 instanceId,
207 driverKind: DRIVER_KIND,
208 displayName,
209 accentColor,
210 continuationGroupKey: continuationIdentity.continuationKey,
211 });
212 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;
213 const runtimeProbe = yield* makeOpenCodeRuntimeProbe(
214 probeOpenCodeRuntime(effectiveConfig, processEnv).pipe(
215 Effect.provideService(HttpClient.HttpClient, httpClient),
216 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
217 ),
218 );
219 // Updates follow the installed package, which the version probe names. An
220 // unknown version offers no package update, since a guess could move a
221 // 2.x install onto 1.x's package or the reverse. A disabled instance never
222 // runs its binary, so it has no version and offers no update.
223 const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({
224 provider: DRIVER_KIND,
225 packageName: null,
226 });
227 const maintenanceFor = (probed: typeof runtimeProbe.get) =>
228 probed.pipe(
229 Effect.map((result) => result.generation),
230 Effect.option,
231 Effect.flatMap((generation) =>
232 Option.isNone(generation)
233 ? Effect.succeed(noUpdate)
234 : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), {
235 binaryPath: effectiveConfig.binaryPath,
236 env: processEnv,
237 }),
238 ),
239 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
240 Effect.provideService(FileSystem.FileSystem, fileSystem),
241 Effect.provideService(Path.Path, pathService),
242 );
243 const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution(
244 maintenanceFor(runtimeProbe.get),
245 );
246 // A fresh read (an update about to run, or a manual refresh) re-probes, so
247 // a binary replaced by the other major version gets its own package.
248 const resolveMaintenance = (options?: { readonly fresh?: boolean }) =>
249 !effectiveConfig.enabled
250 ? Effect.succeed(noUpdate)
251 : options?.fresh === true
252 ? runtimeProbe.refresh.pipe(
253 Effect.ignore,
254 Effect.andThen(cachedMaintenance({ fresh: true })),
255 )
256 : cachedMaintenance();
257 const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({
258 instanceId,
259 displayName,
260 accentColor,
261 environment,
262 enabled,
263 config,
264 }).pipe(
265 Effect.mapError(
266 (cause) =>
267 new ProviderDriverError({
268 driver: DRIVER_KIND,
269 instanceId,
270 detail: "Failed to build OpenCode orchestration adapter.",
271 cause,
272 }),
273 ),
274 );
275 // One OpenCode 2 server per instance, spawned on first use or reached at `serverUrl`.
276 const openCode2Server = yield* OpenCode2Server.make({
277 binaryPath: effectiveConfig.binaryPath,
278 serverUrl: effectiveConfig.serverUrl,
279 serverPassword: effectiveConfig.serverPassword,
280 directory: host.paths.cwd,
281 environment: processEnv,
282 }).pipe(
283 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
284 Effect.provideService(
285 OpenCode2Client.OpenCode2Client,
286 yield* OpenCode2Client.make.pipe(
287 Effect.provideService(HttpClient.HttpClient, httpClient),
288 ),
289 ),
290 );
291 const orchestrationAdapter = selectOpenCodeRuntimeAdapter({
292 probe: runtimeProbe,
293 v1: openCodeV1Adapter,
294 v2: yield* OpenCode2AdapterV2.make(instanceId).pipe(
295 Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
296 ),
297 });
298 const loadOpenCode2Models = yield* makeOpenCode2ModelLoader(
299 openCode2Server.withConnection((connection) =>
300 connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe(
301 Effect.map((models) => models.data),
302 Effect.mapError(
303 (cause) =>
304 new OpenCodeRuntime.OpenCodeRuntimeError({
305 operation: "model.list",
306 detail: "The OpenCode server could not list its models.",
307 cause,
308 }),
309 ),
310 ),
311 ),
312 );
313 // A 2.x server lists skills and commands per directory, so one server
314 // answers every workspace. Its event stream says when a directory it had
315 // not served yet finished scanning.
316 const listOpenCode2Workspace = (cwd: string) =>
317 openCode2Server.withConnection(({ client, events }) =>
318 Effect.gen(function* () {
319 const location = { directory: cwd };
320 const scanned = yield* Deferred.make<void>();
321 const pending = new Set(["command.updated", "skill.updated"]);
322 const stream = yield* events.pipe(Effect.option);
323 if (stream._tag === "Some") {
324 yield* stream.value.pipe(
325 Stream.runForEach((event) =>
326 "location" in event &&
327 event.location?.directory === cwd &&
328 pending.delete(event.type) &&
329 pending.size === 0
330 ? Deferred.succeed(scanned, undefined)
331 : Effect.void,
332 ),
333 Effect.ignore,
334 Effect.forkScoped,
335 );
336 }
337 return yield* loadOpenCode2Workspace(
338 Effect.all(
339 {
340 skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)),
341 commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)),
342 },
343 { concurrency: "unbounded" },
344 ),
345 Deferred.await(scanned),
346 );
347 }).pipe(Effect.scoped),
348 );
349 const serverOwner = yield* OpenCodeServerOwner.make({
350 binaryPath: effectiveConfig.binaryPath,
351 directory: host.paths.cwd,
352 ...(effectiveConfig.serverPassword
353 ? { serverPassword: effectiveConfig.serverPassword }
354 : {}),
355 environment: processEnv,
356 });
357 const textGeneration = selectOpenCodeRuntimeTextGeneration(
358 runtimeProbe,
359 yield* makeOpenCodeTextGeneration(effectiveConfig).pipe(
360 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
361 ),
362 yield* OpenCode2TextGeneration.make().pipe(
363 Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
364 ),
365 );
366
367 const checkProvider = Effect.all(
368 {
369 provider: checkOpenCodeProviderStatus(
370 effectiveConfig,
371 host.paths.cwd,
372 runtimeProbe.refresh,
373 loadOpenCode2Models,
374 ),
375 usageLimits: readOpenCodeGoUsageLimits({
376 enabled: effectiveConfig.enabled,
377 serverUrl: effectiveConfig.serverUrl,
378 environment: processEnv,
379 }),
380 },
381 { concurrency: "unbounded" },
382 ).pipe(
383 Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })),
384 Effect.map(stampIdentity),
385 Effect.provideService(FileSystem.FileSystem, fileSystem),
386 Effect.provideService(Path.Path, pathService),
387 Effect.provideService(HttpClient.HttpClient, httpClient),
388 Effect.provideService(Crypto.Crypto, crypto),
389 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
390 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
391 );
392 // NOTE: the local branch intentionally uses the shared SDK server
393 // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes
394 // its full JSON inventory to stdout, but the Bun-compiled binary does
395 // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the
396 // piped output arrives truncated and unparseable — which degrades to an
397 // empty skill list and poisons the workspace snapshot the `$` picker
398 // reads. The SDK `app.skills` endpoint honors the per-request directory
399 // and returns complete results regardless of size.
400 const loadWorkspaceInventory = (
401 client: Parameters<typeof OpenCodeRuntime.loadOpenCodeCommands>[0],
402 ) =>
403 Effect.all(
404 {
405 skills: openCodeRuntime.loadOpenCodeSkills(client),
406 commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe(
407 Effect.timeout("10 seconds"),
408 Effect.orElseSucceed(() => []),
409 ),
410 },
411 { concurrency: "unbounded" },
412 );
413 const loadWorkspaceForCwd = (cwd: string) =>
414 effectiveConfig.serverUrl.trim().length > 0
415 ? Effect.scoped(
416 Effect.gen(function* () {
417 const server = yield* openCodeRuntime.connectToOpenCodeServer({
418 binaryPath: effectiveConfig.binaryPath,
419 directory: cwd,
420 serverUrl: effectiveConfig.serverUrl,
421 ...(effectiveConfig.serverPassword
422 ? { serverPassword: effectiveConfig.serverPassword }
423 : {}),
424 environment: processEnv,
425 });
426 const client = openCodeRuntime.createOpenCodeSdkClient({
427 baseUrl: server.url,
428 directory: cwd,
429 ...(effectiveConfig.serverPassword
430 ? { serverPassword: effectiveConfig.serverPassword }
431 : {}),
432 });
433 return yield* loadWorkspaceInventory(client);
434 }),
435 )
436 : serverOwner.withServer((server) =>
437 loadWorkspaceInventory(
438 openCodeRuntime.createOpenCodeSdkClient({
439 baseUrl: server.url,
440 directory: cwd,
441 ...(server.serverPassword !== undefined
442 ? { serverPassword: server.serverPassword }
443 : {}),
444 }),
445 ),
446 );
447
448 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
449 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
450 {
451 resolveMaintenance,
452 getSettings: snapshotSettings.getSettings,
453 streamSettings: snapshotSettings.streamSettings,
454 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
455 checkProviderOnSettingsChange: () => false,
456 refreshOnInterval: false,
457 initialSnapshot: (settings) =>
458 makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
459 checkProvider,
460 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
461 resolveMaintenance().pipe(
462 Effect.flatMap((maintenanceCapabilities) =>
463 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
464 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
465 }),
466 ),
467 Effect.provideService(HttpClient.HttpClient, httpClient),
468 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
469 ),
470 },
471 ).pipe(
472 Effect.mapError(
473 (cause) =>
474 new ProviderDriverError({
475 driver: DRIVER_KIND,
476 instanceId,
477 detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`,
478 cause,
479 }),
480 ),
481 );
482
483 return {
484 instanceId,
485 driverKind: DRIVER_KIND,
486 continuationIdentity,
487 displayName,
488 accentColor,
489 enabled,
490 snapshot,
491 snapshotForCwd: (cwd) =>
492 !effectiveConfig.enabled
493 ? snapshot.getSnapshot
494 : byOpenCodeRuntime(runtimeProbe.get, {
495 v2: Effect.all([
496 snapshot.getSnapshot,
497 listOpenCode2Workspace(cwd).pipe(
498 Effect.timeout("20 seconds"),
499 Effect.mapError(
500 (cause) =>
501 new ProviderDriverError({
502 driver: DRIVER_KIND,
503 instanceId,
504 detail: `Failed to list OpenCode commands and skills for '${cwd}'`,
505 cause,
506 }),
507 ),
508 ),
509 ]).pipe(
510 Effect.map(([machineSnapshot, { skills, commands }]) => ({
511 ...machineSnapshot,
512 skills: openCode2SkillsToServerProviderSkills(skills),
513 slashCommands: openCode2CommandsToServerProviderSlashCommands(commands),
514 })),
515 ),
516 v1: Effect.all([
517 snapshot.getSnapshot,
518 loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")),
519 ]).pipe(
520 Effect.map(([machineSnapshot, { skills, commands }]) => ({
521 ...machineSnapshot,
522 skills: openCodeSkillsToServerProviderSkills(skills),
523 slashCommands: openCodeCommandsToServerProviderSlashCommands(commands),
524 })),
525 Effect.mapError(
526 (cause) =>
527 new ProviderDriverError({
528 driver: DRIVER_KIND,
529 instanceId,
530 detail: `Failed to probe OpenCode commands and skills for '${cwd}'`,
531 cause,
532 }),
533 ),
534 ),
535 }),
536 orchestrationAdapter,
537 textGeneration,
538 } satisfies ProviderInstance;
539 }),
540};
packages/provider-opencode/src/server/probeResponses.fixture.ts created+93
...@@ -0,0 +1,93 @@
1/**
2 * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an
3 * `HttpClient` so the version probe runs against real bytes.
4 */
5import * as Effect from "effect/Effect";
6import { HttpClient, HttpClientResponse } from "effect/http";
7
8interface RecordedResponse {
9 readonly status: number;
10 readonly contentType?: string;
11 readonly body: string;
12}
13
14// Both versions serve their web UI's index page for unknown paths. Bodies are cut after the
15// first lines; the classifier rejects them on content type alone.
16const SPA_HTML =
17 '<!doctype html>\n<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">\n';
18
19export const OPENCODE_2_RESPONSES = {
20 "/api/info": {
21 status: 200,
22 contentType: "application/json",
23 body: '{"version":"2.0.18","pid":2393556,"urls":["http://127.0.0.1:48771"],"paths":{"tmp":"/tmp/opencode"}}',
24 },
25 "/global/health": { status: 200, contentType: "text/html", body: SPA_HTML },
26 unauthorized: {
27 status: 401,
28 contentType: "application/json",
29 body: '{"_tag":"UnauthorizedError","message":"Authentication required"}',
30 },
31} satisfies Record<string, RecordedResponse>;
32
33/**
34 * A 2.0.18 server's command and skill lists for a directory with one command
35 * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on
36 * 2026-09-29. Skill contents are cut; machine-wide skills are dropped.
37 */
38export const OPENCODE_2_WORKSPACE_RESPONSES = {
39 "/api/command": {
40 status: 200,
41 contentType: "application/json",
42 body: '{"location":{"directory":"/work"},"data":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"},{"name":"bee","description":"B command"}]}',
43 },
44 "/api/skill": {
45 status: 200,
46 contentType: "application/json",
47 body: '{"location":{"directory":"/work"},"data":[{"id":"opencode","name":"OpenCode","description":"Use this skill for any question about OpenCode itself.","path":"/builtin/opencode.md","content":"# OpenCode"},{"id":"report","name":"Report","description":"Use when the user wants to report an opencode issue or bug.","path":"/builtin/report.md","content":"<!-- Built-in skill. -->"},{"id":"plum","name":"plum","description":"plum skill","path":"/work/.opencode/skills/plum/SKILL.md","content":"Say PLUM."}]}',
48 },
49} satisfies Record<string, RecordedResponse>;
50
51export const OPENCODE_1_RESPONSES = {
52 "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML },
53 "/global/health": {
54 status: 200,
55 contentType: "application/json",
56 body: '{"healthy":true,"version":"1.18.32"}',
57 },
58 // 1.x also answers a wrong password with an empty 401 on every path.
59 unauthorized: { status: 401, body: "" },
60} satisfies Record<string, RecordedResponse>;
61
62/** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */
63export function replayOpenCodeServer(
64 responses:
65 | typeof OPENCODE_1_RESPONSES
66 | typeof OPENCODE_2_RESPONSES
67 | (typeof OPENCODE_2_RESPONSES & typeof OPENCODE_2_WORKSPACE_RESPONSES),
68 password: string,
69 requestedPaths: Array<string> = [],
70) {
71 const answers: Record<string, RecordedResponse | undefined> = responses;
72 // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`).
73 const expected = password
74 ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`
75 : undefined;
76 return HttpClient.make((request) => {
77 const path = new URL(request.url).pathname;
78 requestedPaths.push(path);
79 const recorded: RecordedResponse =
80 request.headers.authorization === expected
81 ? (answers[path] ?? responses.unauthorized)
82 : responses.unauthorized;
83 return Effect.succeed(
84 HttpClientResponse.fromWeb(
85 request,
86 new Response(recorded.body, {
87 status: recorded.status,
88 headers: recorded.contentType ? { "content-type": recorded.contentType } : {},
89 }),
90 ),
91 );
92 });
93}
packages/provider-opencode/src/server/status.test.ts created+871
...@@ -0,0 +1,871 @@
1import * as NodeAssert from "node:assert/strict";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";
11import * as TestClock from "effect/testing/TestClock";
12import { HttpClient, HttpClientResponse } from "effect/http";
13import { beforeEach } from "vite-plus/test";
14
15import { OpenCodeSettings } from "../settings.ts";
16import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
17import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
18import {
19 checkOpenCodeProviderStatus,
20 loadOpenCode2Workspace,
21 makeOpenCode2ModelLoader,
22 type OpenCode2Model,
23 type OpenCode2Workspace,
24 openCode2CommandsToServerProviderSlashCommands,
25 openCode2SkillsToServerProviderSkills,
26 openCodeCommandsToServerProviderSlashCommands,
27} from "./status.ts";
28import { readOpenCodeGoUsageLimits } from "./usageLimits.ts";
29import { probeOpenCodeRuntime } from "./versionProbe.ts";
30import {
31 OPENCODE_1_RESPONSES,
32 OPENCODE_2_RESPONSES,
33 replayOpenCodeServer,
34} from "./probeResponses.fixture.ts";
35const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
36
37const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
38
39it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () =>
40 Effect.gen(function* () {
41 const resetsAt = "2026-09-17T12:00:00.000Z";
42 const limits = yield* readOpenCodeGoUsageLimits({
43 enabled: true,
44 serverUrl: "",
45 environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" },
46 }).pipe(
47 Effect.provideService(
48 FileSystem.FileSystem,
49 FileSystem.makeNoop({
50 readFileString: (path) => {
51 NodeAssert.equal(path, "/instance/data/opencode/auth.json");
52 return Effect.succeed(
53 JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }),
54 );
55 },
56 }),
57 ),
58 Effect.provideService(
59 HttpClient.HttpClient,
60 HttpClient.make((request) => {
61 NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage");
62 NodeAssert.equal(request.headers.authorization, "Bearer instance-key");
63 return Effect.succeed(
64 HttpClientResponse.fromWeb(
65 request,
66 Response.json({
67 usage: {
68 rolling: { percent: 0, resetsAt },
69 weekly: { percent: 10, resetsAt },
70 monthly: { percent: 125, resetsAt },
71 },
72 }),
73 ),
74 );
75 }),
76 ),
77 Effect.provide(NodeServices.layer),
78 );
79 NodeAssert.equal(limits.unavailable, undefined);
80 NodeAssert.equal(
81 limits.credentialFingerprint,
82 // SHA-256 of "opencode-go\0instance-key".
83 "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37",
84 );
85 NodeAssert.deepEqual(
86 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
87 kind,
88 usedPercent,
89 reset,
90 })),
91 [
92 { kind: "session", usedPercent: 0, reset: resetsAt },
93 { kind: "weekly", usedPercent: 10, reset: resetsAt },
94 { kind: "monthly", usedPercent: 100, reset: resetsAt },
95 ],
96 );
97 }),
98);
99
100it.effect("does not read local credentials for external or disabled OpenCode instances", () =>
101 Effect.gen(function* () {
102 for (const settings of [
103 { enabled: true, serverUrl: "https://remote.example" },
104 { enabled: false, serverUrl: "" },
105 ]) {
106 const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe(
107 Effect.provideService(
108 FileSystem.FileSystem,
109 FileSystem.makeNoop({
110 readFileString: () => Effect.die("unexpected credential read"),
111 }),
112 ),
113 Effect.provideService(
114 HttpClient.HttpClient,
115 HttpClient.make(() => Effect.die("unexpected usage request")),
116 ),
117 Effect.provide(NodeServices.layer),
118 );
119 NodeAssert.equal(limits.unavailable?.reason, "unsupported");
120 }
121 }),
122);
123
124it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () =>
125 Effect.gen(function* () {
126 for (const [status, reason] of [
127 [403, "unsupported"],
128 [401, "probeFailed"],
129 [200, "probeFailed"],
130 ] as const) {
131 const limits = yield* readOpenCodeGoUsageLimits({
132 enabled: true,
133 serverUrl: "",
134 environment: {
135 OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}',
136 },
137 }).pipe(
138 Effect.provideService(
139 FileSystem.FileSystem,
140 FileSystem.makeNoop({
141 readFileString: () => Effect.die("inline credentials must bypass disk"),
142 }),
143 ),
144 Effect.provideService(
145 HttpClient.HttpClient,
146 HttpClient.make((request) =>
147 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))),
148 ),
149 ),
150 Effect.provide(NodeServices.layer),
151 );
152 NodeAssert.equal(limits.unavailable?.reason, reason);
153 NodeAssert.equal(limits.credentialFingerprint, undefined);
154 NodeAssert.deepEqual(limits.windows, []);
155 }
156 }),
157);
158
159/**
160 * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag
161 * are deleted. The snapshot-producing logic they wrapped now lives in the
162 * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which
163 * drivers call directly when building their per-instance snapshot
164 * `ServerProviderShape`. Tests mirror that shape: build a settings payload,
165 * invoke the check, assert on the returned snapshot.
166 */
167
168const runtimeMock = {
169 state: {
170 runVersionError: null as Error | null,
171 runVersionPending: false,
172 versionStdout: DEFAULT_VERSION_STDOUT,
173 inventoryError: null as Error | null,
174 connectionError: null as Error | null,
175 inventoryCwd: null as string | null,
176 closeCalls: 0,
177 sdkClientInputs: [] as Array<{
178 baseUrl: string;
179 directory: string;
180 serverPassword?: string;
181 }>,
182 inventory: {
183 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
184 agents: [] as unknown[],
185 skills: [] as unknown[],
186 } as unknown,
187 },
188 reset() {
189 this.state.runVersionError = null;
190 this.state.runVersionPending = false;
191 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
192 this.state.inventoryError = null;
193 this.state.connectionError = null;
194 this.state.inventoryCwd = null;
195 this.state.closeCalls = 0;
196 this.state.sdkClientInputs.length = 0;
197 this.state.inventory = {
198 providerList: { connected: [], all: [] as unknown[], default: {} },
199 agents: [] as unknown[],
200 skills: [] as unknown[],
201 };
202 },
203};
204
205const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
206 startOpenCodeServerProcess: ({ serverPassword, environment }) =>
207 Effect.gen(function* () {
208 yield* Effect.addFinalizer(() =>
209 Effect.sync(() => {
210 runtimeMock.state.closeCalls += 1;
211 }),
212 );
213 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
214 external: false,
215 ...(serverPassword !== undefined ? { serverPassword } : {}),
216 ...(environment !== undefined ? { environment } : {}),
217 });
218 return {
219 url: "http://127.0.0.1:4301",
220 ...(effectiveServerPassword !== undefined
221 ? { serverPassword: effectiveServerPassword }
222 : {}),
223 version: "1.14.19",
224 isRunning: Effect.succeed(true),
225 exitCode: Effect.never,
226 };
227 }),
228 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
229 Effect.gen(function* () {
230 if (runtimeMock.state.connectionError) {
231 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
232 operation: "global.health",
233 detail: runtimeMock.state.connectionError.message,
234 cause: runtimeMock.state.connectionError,
235 });
236 }
237 if (!serverUrl) {
238 yield* Effect.addFinalizer(() =>
239 Effect.sync(() => {
240 runtimeMock.state.closeCalls += 1;
241 }),
242 );
243 }
244 return {
245 url: serverUrl ?? "http://127.0.0.1:4301",
246 ...(serverPassword ? { serverPassword } : {}),
247 version: "1.14.19",
248 exitCode: null,
249 external: Boolean(serverUrl),
250 };
251 }),
252 runOpenCodeCommand: () =>
253 runtimeMock.state.runVersionPending
254 ? Effect.never
255 : runtimeMock.state.runVersionError
256 ? Effect.fail(
257 new OpenCodeRuntime.OpenCodeRuntimeError({
258 operation: "runOpenCodeCommand",
259 detail: runtimeMock.state.runVersionError.message,
260 cause: runtimeMock.state.runVersionError,
261 }),
262 )
263 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
264 createOpenCodeSdkClient: (input) => {
265 runtimeMock.state.sdkClientInputs.push(input);
266 return {} as unknown as ReturnType<
267 OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]
268 >;
269 },
270 loadOpenCodeInventory: () =>
271 runtimeMock.state.inventoryError
272 ? Effect.fail(
273 new OpenCodeRuntime.OpenCodeRuntimeError({
274 operation: "loadOpenCodeInventory",
275 detail: runtimeMock.state.inventoryError.message,
276 cause: runtimeMock.state.inventoryError,
277 }),
278 )
279 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory),
280 loadInventoryFromCli: ({ cwd }) => {
281 runtimeMock.state.inventoryCwd = cwd;
282 return runtimeMock.state.inventoryError
283 ? Effect.fail(
284 new OpenCodeRuntime.OpenCodeRuntimeError({
285 operation: "loadInventoryFromCli",
286 detail: runtimeMock.state.inventoryError.message,
287 cause: runtimeMock.state.inventoryError,
288 }),
289 )
290 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory);
291 },
292 loadOpenCodeSkills: () => Effect.succeed([]),
293 loadSkillsFromCli: () => Effect.succeed([]),
294};
295
296beforeEach(() => {
297 runtimeMock.reset();
298});
299
300it("keeps native and MCP commands while preserving compaction and separate skills", () => {
301 NodeAssert.deepEqual(
302 openCodeCommandsToServerProviderSlashCommands([
303 { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] },
304 { name: "review", source: "command", hints: [] },
305 { name: "compact", source: "command", hints: [] },
306 { name: "skill", source: "skill", hints: [] },
307 { name: "mcp:search", source: "mcp", hints: ["query"] },
308 ]).slice(1),
309 [
310 { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } },
311 { name: "mcp:search", input: { hint: "query" } },
312 ],
313 );
314});
315
316const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(
317 Layer.provideMerge(NodeServices.layer),
318);
319
320const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings =>
321 decodeOpenCodeSettings({
322 enabled: true,
323 binaryPath: "opencode",
324 serverUrl: "",
325 serverPassword: "",
326 customModels: [],
327 ...overrides,
328 });
329
330const checkProvider = Effect.fn("checkProvider")(function* (
331 settings: OpenCodeSettings,
332 cwd = process.cwd(),
333 environment?: NodeJS.ProcessEnv,
334 server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword),
335 openCode2Models: Effect.Effect<
336 ReadonlyArray<OpenCode2Model>,
337 OpenCodeRuntime.OpenCodeRuntimeError
338 > = Effect.succeed([]),
339) {
340 return yield* Effect.scoped(
341 Effect.gen(function* () {
342 const serverOwner = yield* OpenCodeServerOwner.make({
343 binaryPath: settings.binaryPath,
344 directory: cwd,
345 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
346 ...(environment ? { environment } : {}),
347 });
348 const probe = probeOpenCodeRuntime(settings, environment).pipe(
349 Effect.provideService(HttpClient.HttpClient, server),
350 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble),
351 );
352 return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe(
353 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
354 );
355 }),
356 );
357});
358
359it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
360 it.effect("shows a codex-style missing binary message", () =>
361 Effect.gen(function* () {
362 runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
363 const snapshot = yield* checkProvider(makeOpenCodeSettings());
364
365 NodeAssert.equal(snapshot.status, "error");
366 NodeAssert.equal(snapshot.installed, false);
367 NodeAssert.equal(
368 snapshot.message,
369 "OpenCode CLI (`opencode`) is not installed or not on PATH.",
370 );
371 }),
372 );
373
374 it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () =>
375 Effect.gen(function* () {
376 runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise");
377 const snapshot = yield* checkProvider(makeOpenCodeSettings());
378
379 NodeAssert.equal(snapshot.status, "error");
380 NodeAssert.equal(snapshot.installed, true);
381 NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");
382 }),
383 );
384
385 it.effect("times out a hanging local CLI version probe", () =>
386 Effect.gen(function* () {
387 runtimeMock.state.runVersionPending = true;
388 const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild);
389
390 yield* Effect.yieldNow;
391 yield* TestClock.adjust("4 seconds");
392 const snapshot = yield* Fiber.join(probeFiber);
393
394 NodeAssert.equal(snapshot.status, "error");
395 NodeAssert.equal(snapshot.installed, true);
396 NodeAssert.equal(
397 snapshot.message,
398 "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
399 );
400 }).pipe(Effect.provide(TestClock.layer())),
401 );
402
403 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
404 Effect.gen(function* () {
405 runtimeMock.state.inventory = {
406 providerList: {
407 connected: ["openai"],
408 all: [
409 {
410 id: "openai",
411 name: "OpenAI",
412 models: {
413 "gpt-5.4": {
414 id: "gpt-5.4",
415 name: "GPT-5.4",
416 variants: {
417 none: {},
418 low: {},
419 medium: {},
420 high: {},
421 xhigh: {},
422 },
423 },
424 },
425 },
426 ],
427 default: {},
428 },
429 agents: [
430 { name: "build", hidden: false, mode: "primary" },
431 { name: "plan", hidden: false, mode: "primary" },
432 ],
433 };
434
435 const snapshot = yield* checkProvider(makeOpenCodeSettings());
436 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
437
438 NodeAssert.ok(model);
439 const variantDescriptor = model.capabilities?.optionDescriptors?.find(
440 (descriptor) => descriptor.id === "variant" && descriptor.type === "select",
441 );
442 NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select");
443 NodeAssert.equal(
444 variantDescriptor.options.find((option) => option.isDefault === true)?.id,
445 "medium",
446 );
447 const agentDescriptor = model.capabilities?.optionDescriptors?.find(
448 (descriptor) => descriptor.id === "agent" && descriptor.type === "select",
449 );
450 NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select");
451 NodeAssert.equal(
452 agentDescriptor.options.find((option) => option.isDefault === true)?.id,
453 "build",
454 );
455 }),
456 );
457
458 it.effect("includes OpenCode skills in the provider snapshot", () =>
459 Effect.gen(function* () {
460 runtimeMock.state.inventory = {
461 providerList: {
462 connected: ["openai"],
463 all: [
464 {
465 id: "openai",
466 name: "OpenAI",
467 models: {
468 "gpt-5.4": {
469 id: "gpt-5.4",
470 name: "GPT-5.4",
471 variants: {},
472 },
473 },
474 },
475 ],
476 default: {},
477 },
478 agents: [],
479 skills: [
480 {
481 name: "openclaw-review",
482 description: "Review OpenClaw workflow changes.",
483 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
484 },
485 {
486 name: "openclaw-triage",
487 description: "Triage OpenClaw routing issues.",
488 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
489 },
490 {
491 name: "missing-location",
492 description: "This incomplete SDK row should be skipped.",
493 location: "",
494 },
495 ],
496 };
497
498 const snapshot = yield* checkProvider(makeOpenCodeSettings());
499
500 NodeAssert.deepEqual(
501 snapshot.skills.map((skill) => ({
502 name: skill.name,
503 path: skill.path,
504 enabled: skill.enabled,
505 shortDescription: skill.shortDescription,
506 })),
507 [
508 {
509 name: "openclaw-review",
510 path: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
511 enabled: true,
512 shortDescription: "Review OpenClaw workflow changes.",
513 },
514 {
515 name: "openclaw-triage",
516 path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
517 enabled: true,
518 shortDescription: "Triage OpenClaw routing issues.",
519 },
520 ],
521 );
522 }),
523 );
524
525 it.effect("loads local inventory from a scoped OpenCode server", () =>
526 Effect.gen(function* () {
527 yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" }));
528
529 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
530 {
531 baseUrl: "http://127.0.0.1:4301",
532 directory: process.cwd(),
533 serverPassword: "secret-password",
534 },
535 ]);
536 NodeAssert.equal(runtimeMock.state.closeCalls, 1);
537 NodeAssert.equal(runtimeMock.state.inventoryCwd, null);
538 }),
539 );
540
541 it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () =>
542 Effect.gen(function* () {
543 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
544 const snapshot = yield* checkProvider(
545 makeOpenCodeSettings(),
546 process.cwd(),
547 undefined,
548 undefined,
549 Effect.succeed([
550 { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] },
551 {
552 providerID: "opencode",
553 id: "space-bunny-free",
554 name: "Space Bunny Free",
555 variants: [{ id: "low" }, { id: "medium" }, { id: "high" }],
556 },
557 ]),
558 );
559
560 NodeAssert.equal(snapshot.status, "ready");
561 NodeAssert.equal(snapshot.version, "2.0.18");
562 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
563 "approval-required",
564 "auto-accept-edits",
565 "auto",
566 "full-access",
567 ]);
568 // Plan mode runs as OpenCode's plan agent, and every session can compact.
569 NodeAssert.equal(snapshot.showInteractionModeToggle, true);
570 NodeAssert.deepEqual(
571 snapshot.slashCommands.map((command) => command.name),
572 ["compact"],
573 );
574 NodeAssert.deepEqual(
575 snapshot.models.map((model) => model.slug),
576 ["opencode/big-pickle", "opencode/space-bunny-free"],
577 );
578 const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free")
579 ?.capabilities?.optionDescriptors?.[0];
580 NodeAssert.deepEqual(
581 variant?.type === "select" ? variant.options.map((option) => option.id) : [],
582 ["low", "medium", "high"],
583 );
584 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
585 }),
586 );
587
588 it.effect("reports a failed OpenCode 2 model list without the server's response", () =>
589 Effect.gen(function* () {
590 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
591 const snapshot = yield* checkProvider(
592 makeOpenCodeSettings(),
593 process.cwd(),
594 undefined,
595 undefined,
596 Effect.fail(
597 new OpenCodeRuntime.OpenCodeRuntimeError({
598 operation: "model.list",
599 detail: 'status=500 body={"token":"leaked-response-body"}',
600 }),
601 ),
602 );
603
604 NodeAssert.equal(snapshot.status, "error");
605 NodeAssert.equal(snapshot.message, "OpenCode could not load its model list.");
606 // A failed catalog read still leaves the session able to compact.
607 NodeAssert.deepEqual(
608 snapshot.slashCommands.map((command) => command.name),
609 ["compact"],
610 );
611 }),
612 );
613
614 it.effect("uses an environment-only password for local inventory", () =>
615 Effect.gen(function* () {
616 yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
617 OPENCODE_SERVER_PASSWORD: "environment-password",
618 });
619
620 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
621 {
622 baseUrl: "http://127.0.0.1:4301",
623 directory: process.cwd(),
624 serverPassword: "environment-password",
625 },
626 ]);
627 }),
628 );
629
630 it.effect("uses the settings password when local environment auth differs", () =>
631 Effect.gen(function* () {
632 yield* checkProvider(
633 makeOpenCodeSettings({ serverPassword: "settings-password" }),
634 process.cwd(),
635 { OPENCODE_SERVER_PASSWORD: "environment-password" },
636 );
637
638 NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password");
639 }),
640 );
641
642 it.effect("reports local model inventory failures without treating them as empty", () =>
643 Effect.gen(function* () {
644 runtimeMock.state.inventoryError = new Error("opencode models failed");
645 const snapshot = yield* checkProvider(makeOpenCodeSettings());
646
647 NodeAssert.equal(snapshot.status, "error");
648 NodeAssert.equal(snapshot.installed, true);
649 NodeAssert.equal(snapshot.models.length, 0);
650 NodeAssert.equal(
651 snapshot.message,
652 "Failed to load OpenCode provider inventory: opencode models failed",
653 );
654 }),
655 );
656});
657
658it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => {
659 it.effect("does not send a local environment password to a configured server", () =>
660 Effect.gen(function* () {
661 const snapshot = yield* checkProvider(
662 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
663 process.cwd(),
664 { OPENCODE_SERVER_PASSWORD: "local-secret" },
665 );
666
667 NodeAssert.equal(snapshot.version, "1.14.19");
668 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
669 {
670 baseUrl: "http://127.0.0.1:9999",
671 directory: process.cwd(),
672 },
673 ]);
674 }),
675 );
676
677 it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () =>
678 Effect.gen(function* () {
679 const settings = makeOpenCodeSettings({
680 serverUrl: "http://127.0.0.1:9999",
681 serverPassword: "secret-password",
682 });
683 const snapshot = yield* checkProvider(
684 settings,
685 process.cwd(),
686 undefined,
687 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
688 );
689
690 NodeAssert.equal(snapshot.status, "warning");
691 NodeAssert.equal(snapshot.version, "2.0.18");
692 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
693 "approval-required",
694 "auto-accept-edits",
695 "auto",
696 "full-access",
697 ]);
698 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
699 }),
700 );
701
702 it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () =>
703 Effect.gen(function* () {
704 const snapshot = yield* checkProvider(
705 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }),
706 process.cwd(),
707 undefined,
708 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
709 );
710
711 NodeAssert.equal(snapshot.status, "error");
712 NodeAssert.equal(
713 snapshot.message,
714 "OpenCode server rejected authentication. Check the server URL and password.",
715 );
716 }),
717 );
718
719 it.effect("rejects an unsupported server before loading inventory", () =>
720 Effect.gen(function* () {
721 runtimeMock.state.connectionError = new Error(
722 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
723 );
724 const snapshot = yield* checkProvider(
725 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
726 );
727
728 NodeAssert.equal(snapshot.status, "error");
729 NodeAssert.equal(snapshot.models.length, 0);
730 NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/);
731 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
732 }),
733 );
734
735 it.effect("surfaces a friendly auth error for configured servers", () =>
736 Effect.gen(function* () {
737 runtimeMock.state.connectionError = new Error("401 Unauthorized");
738 const snapshot = yield* checkProvider(
739 makeOpenCodeSettings({
740 serverUrl: "http://127.0.0.1:9999",
741 serverPassword: "secret-password",
742 }),
743 );
744
745 NodeAssert.equal(snapshot.status, "error");
746 NodeAssert.equal(snapshot.installed, true);
747 NodeAssert.equal(
748 snapshot.message,
749 "OpenCode server rejected authentication. Check the server URL and password.",
750 );
751 }),
752 );
753
754 it.effect("surfaces a friendly connection error for configured servers", () =>
755 Effect.gen(function* () {
756 runtimeMock.state.connectionError = new Error(
757 "fetch failed: connect ECONNREFUSED 127.0.0.1:9999",
758 );
759 const snapshot = yield* checkProvider(
760 makeOpenCodeSettings({
761 serverUrl: "http://127.0.0.1:9999",
762 serverPassword: "secret-password",
763 }),
764 );
765
766 NodeAssert.equal(snapshot.status, "error");
767 NodeAssert.equal(snapshot.installed, true);
768 NodeAssert.equal(
769 snapshot.message,
770 "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.",
771 );
772 }),
773 );
774});
775
776const bigPickle: OpenCode2Model = {
777 providerID: "opencode",
778 id: "big-pickle",
779 name: "Big Pickle",
780 variants: [],
781};
782
783it.effect("waits for a fresh OpenCode 2 server to list its models", () =>
784 Effect.gen(function* () {
785 // A fresh server lists nothing until its catalog loads.
786 const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]];
787 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle]));
788 const fiber = yield* load.pipe(Effect.forkChild);
789 yield* TestClock.adjust("1 second");
790 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
791 }).pipe(Effect.provide(TestClock.layer())),
792);
793
794it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () =>
795 Effect.gen(function* () {
796 let listed: ReadonlyArray<OpenCode2Model> = [bigPickle];
797 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed));
798 NodeAssert.deepEqual(yield* load, [bigPickle]);
799 listed = [];
800 const fiber = yield* load.pipe(Effect.forkChild);
801 yield* TestClock.adjust("6 seconds");
802 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
803 }).pipe(Effect.provide(TestClock.layer())),
804);
805
806// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29).
807const builtinCommands = [
808 { name: "init", description: "guided AGENTS.md setup" },
809 { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" },
810];
811const builtinSkills = [
812 { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" },
813 { id: "report", name: "Report", path: "/builtin/report.md" },
814];
815const scanned: OpenCode2Workspace = {
816 commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }],
817 skills: [
818 ...builtinSkills,
819 {
820 id: "greet",
821 name: "greet",
822 path: "/work/.opencode/skills/greet/SKILL.md",
823 description: "Greets the user with the secret word MANGO.",
824 },
825 ],
826};
827
828it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () =>
829 Effect.gen(function* () {
830 // A directory the server has not served yet lists no commands until its scan ends.
831 const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned];
832 const scanEnded = yield* Deferred.make<void>();
833 const load = yield* loadOpenCode2Workspace(
834 Effect.sync(() => reads.shift()!),
835 Deferred.await(scanEnded),
836 ).pipe(Effect.forkChild);
837 yield* Effect.yieldNow;
838 NodeAssert.equal(reads.length, 1);
839 yield* Deferred.succeed(scanEnded, undefined);
840 NodeAssert.deepEqual(yield* Fiber.join(load), scanned);
841 }),
842);
843
844it.effect("takes a served OpenCode 2 directory's first listing as is", () =>
845 Effect.gen(function* () {
846 const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never);
847 NodeAssert.deepEqual(workspace, scanned);
848 }),
849);
850
851it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => {
852 NodeAssert.deepEqual(
853 openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [
854 skill.name,
855 skill.displayName,
856 skill.path,
857 ]),
858 [
859 ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"],
860 ["opencode", "OpenCode", "/builtin/opencode.md"],
861 ["report", "Report", "/builtin/report.md"],
862 ],
863 );
864 NodeAssert.deepEqual(
865 openCode2CommandsToServerProviderSlashCommands([
866 ...scanned.commands,
867 { name: "compact", description: "a workspace command named like T3's own" },
868 ]).map((command) => command.name),
869 ["compact", "init", "review", "hello"],
870 );
871});
packages/provider-opencode/src/server/status.ts created+747
...@@ -0,0 +1,747 @@
1import {
2 type ModelCapabilities,
3 type ServerProviderModel,
4 type ServerProviderSkill,
5 type ServerProviderSlashCommand,
6} from "@t3tools/contracts";
7import type { OpenCodeSettings } from "../settings.ts";
8import * as Cause from "effect/Cause";
9import * as Data from "effect/Data";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14import { createModelCapabilities } from "@t3tools/shared/model";
15import { compareSemverVersions } from "@t3tools/shared/semver";
16import {
17 buildServerProvider,
18 COMPACT_SLASH_COMMAND,
19 nonEmptyTrimmed,
20 providerModelsFromSettings,
21 type ServerProviderDraft,
22} from "@t3tools/provider-core/server/snapshotProbe";
23import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
24import type { ProbedOpenCode } from "./versionProbe.ts";
25import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
26import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
27
28const OPENCODE_PRESENTATION = {
29 displayName: "OpenCode",
30 showInteractionModeToggle: false,
31} as const;
32
33class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
34 readonly cause?: unknown;
35 readonly detail: string;
36}> {}
37
38function normalizeProbeMessage(message: string): string | undefined {
39 const trimmed = message.trim();
40 if (trimmed.length === 0) {
41 return undefined;
42 }
43 if (
44 trimmed === "An error occurred in Effect.tryPromise" ||
45 trimmed === "An error occurred in Effect.try"
46 ) {
47 return undefined;
48 }
49 return trimmed;
50}
51
52function normalizedErrorMessage(cause: unknown): string | undefined {
53 if (cause instanceof OpenCodeProbeError) {
54 return normalizeProbeMessage(cause.detail);
55 }
56
57 if (!(cause instanceof Error)) {
58 return undefined;
59 }
60
61 return normalizeProbeMessage(cause.message);
62}
63
64function formatOpenCodeProbeError(input: {
65 readonly cause: unknown;
66 readonly isExternalServer: boolean;
67 readonly phase: "version" | "inventory";
68 readonly serverUrl: string;
69}): { readonly installed: boolean; readonly message: string } {
70 const detail = normalizedErrorMessage(input.cause);
71 const lower = detail?.toLowerCase() ?? "";
72
73 if (input.isExternalServer) {
74 if (
75 lower.includes("401") ||
76 lower.includes("403") ||
77 lower.includes("unauthorized") ||
78 lower.includes("forbidden")
79 ) {
80 return {
81 installed: true,
82 message: "OpenCode server rejected authentication. Check the server URL and password.",
83 };
84 }
85
86 if (
87 lower.includes("econnrefused") ||
88 lower.includes("enotfound") ||
89 lower.includes("fetch failed") ||
90 lower.includes("networkerror") ||
91 lower.includes("timed out") ||
92 lower.includes("timeout") ||
93 lower.includes("socket hang up")
94 ) {
95 return {
96 installed: true,
97 message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`,
98 };
99 }
100
101 return {
102 installed: true,
103 message: detail ?? "Failed to connect to the configured OpenCode server.",
104 };
105 }
106
107 if (lower.includes("enoent") || lower.includes("notfound")) {
108 return {
109 installed: false,
110 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
111 };
112 }
113
114 if (lower.includes("quarantine")) {
115 return {
116 installed: true,
117 message:
118 "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.",
119 };
120 }
121
122 if (lower.includes("invalid code signature") || lower.includes("corrupted")) {
123 return {
124 installed: true,
125 message:
126 "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.",
127 };
128 }
129
130 const failureLabel =
131 input.phase === "inventory"
132 ? "Failed to load OpenCode provider inventory"
133 : "Failed to execute OpenCode CLI health check";
134 return {
135 installed: true,
136 message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`,
137 };
138}
139
140function titleCaseSlug(value: string): string {
141 const segments: Array<string> = [];
142 for (const segment of value.split(/[-_/]+/)) {
143 if (segment.length > 0) {
144 segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
145 }
146 }
147 return segments.join(" ");
148}
149
150function inferDefaultVariant(
151 providerID: string,
152 variants: ReadonlyArray<string>,
153): string | undefined {
154 if (variants.length === 1) {
155 return variants[0];
156 }
157 if (providerID === "anthropic" || providerID.startsWith("google")) {
158 return variants.includes("high") ? "high" : undefined;
159 }
160 if (providerID === "openai" || providerID === "opencode") {
161 return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined;
162 }
163 return undefined;
164}
165
166function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined {
167 return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined;
168}
169
170const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
171 optionDescriptors: [
172 {
173 id: "variant",
174 label: "Reasoning",
175 type: "select",
176 options: [
177 { id: "low", label: "Low" },
178 { id: "medium", label: "Medium", isDefault: true },
179 { id: "high", label: "High" },
180 { id: "xhigh", label: "Extra High" },
181 ],
182 currentValue: "medium",
183 },
184 {
185 id: "agent",
186 label: "Agent",
187 type: "select",
188 options: [
189 { id: "build", label: "Build", isDefault: true },
190 { id: "plan", label: "Plan" },
191 ],
192 currentValue: "build",
193 },
194 ],
195});
196
197function openCodeCapabilitiesForModel(input: {
198 readonly providerID: string;
199 readonly model: ProviderListResponse["all"][number]["models"][string];
200 readonly agents: ReadonlyArray<Agent>;
201}): ModelCapabilities {
202 const rawVariantValues = Object.keys(input.model.variants ?? {});
203 // When a model advertises no variants, synthesize the standard reasoning
204 // levels so the composer still offers a Reasoning selector (mirrors the
205 // Codex/Grok experience where reasoning is always configurable). The set
206 // covers the common OpenCode variant spectrum; `inferDefaultVariant`
207 // picks the provider-appropriate default (e.g. medium for openai/opencode).
208 const variantValues =
209 rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"];
210 const defaultVariant = inferDefaultVariant(input.providerID, variantValues);
211 const variantOptions = variantValues.map((value) =>
212 defaultVariant === value
213 ? { id: value, label: titleCaseSlug(value), isDefault: true as const }
214 : { id: value, label: titleCaseSlug(value) },
215 );
216 const primaryAgents = input.agents.filter(
217 (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
218 );
219 const defaultAgent = inferDefaultAgent(primaryAgents);
220 const agentOptions = primaryAgents.map((agent) =>
221 defaultAgent === agent.name
222 ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const }
223 : { id: agent.name, label: titleCaseSlug(agent.name) },
224 );
225 return createModelCapabilities({
226 optionDescriptors: [
227 ...(variantOptions.length > 0
228 ? [
229 {
230 id: "variant",
231 label: "Reasoning",
232 type: "select" as const,
233 options: variantOptions,
234 ...(defaultVariant ? { currentValue: defaultVariant } : {}),
235 },
236 ]
237 : []),
238 ...(agentOptions.length > 0
239 ? [
240 {
241 id: "agent",
242 label: "Agent",
243 type: "select" as const,
244 options: agentOptions,
245 ...(defaultAgent ? { currentValue: defaultAgent } : {}),
246 },
247 ]
248 : []),
249 ],
250 });
251}
252
253function flattenOpenCodeModels(
254 input: OpenCodeRuntime.OpenCodeInventory,
255): ReadonlyArray<ServerProviderModel> {
256 const connected = new Set(input.providerList.connected);
257 const models: Array<ServerProviderModel> = [];
258
259 for (const provider of input.providerList.all) {
260 if (!connected.has(provider.id)) {
261 continue;
262 }
263
264 for (const model of Object.values(provider.models)) {
265 const name = nonEmptyTrimmed(model.name);
266 if (!name) {
267 continue;
268 }
269
270 const subProvider = nonEmptyTrimmed(provider.name);
271 models.push({
272 slug: `${provider.id}/${model.id}`,
273 name,
274 ...(subProvider ? { subProvider } : {}),
275 isCustom: false,
276 capabilities: openCodeCapabilitiesForModel({
277 providerID: provider.id,
278 model,
279 agents: input.agents,
280 }),
281 });
282 }
283 }
284
285 return models.toSorted((left, right) => left.name.localeCompare(right.name));
286}
287
288function trimOptional(value: string | null | undefined): string | undefined {
289 const trimmed = value?.trim();
290 return trimmed && trimmed.length > 0 ? trimmed : undefined;
291}
292
293export function openCodeSkillsToServerProviderSkills(
294 input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined,
295): ReadonlyArray<ServerProviderSkill> {
296 const skills: ServerProviderSkill[] = [];
297 for (const skill of input ?? []) {
298 const name = trimOptional(skill.name);
299 const path = trimOptional(skill.location);
300 if (!name || !path) {
301 continue;
302 }
303
304 const description = trimOptional(skill.description);
305 skills.push({
306 name,
307 path,
308 enabled: true,
309 ...(description ? { description, shortDescription: description } : {}),
310 });
311 }
312
313 return skills.toSorted((left, right) => left.name.localeCompare(right.name));
314}
315
316export function openCodeCommandsToServerProviderSlashCommands(
317 input: OpenCodeRuntime.OpenCodeInventory["commands"],
318): ReadonlyArray<ServerProviderSlashCommand> {
319 const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
320 const names = new Set([COMPACT_SLASH_COMMAND.name]);
321 for (const command of input ?? []) {
322 const name = trimOptional(command.name);
323 if (!name || names.has(name) || command.source === "skill") continue;
324 names.add(name);
325 const description = trimOptional(command.description);
326 const hint = trimOptional(command.hints.join(" "));
327 commands.push({
328 name,
329 ...(description ? { description } : {}),
330 ...(hint ? { input: { hint } } : {}),
331 });
332 }
333 return commands;
334}
335
336export const makePendingOpenCodeProvider = (
337 openCodeSettings: OpenCodeSettings,
338): Effect.Effect<ServerProviderDraft> =>
339 Effect.gen(function* () {
340 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
341 const models = providerModelsFromSettings(
342 [],
343 openCodeSettings.customModels,
344 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
345 );
346
347 if (!openCodeSettings.enabled) {
348 return buildServerProvider({
349 presentation: OPENCODE_PRESENTATION,
350 enabled: false,
351 checkedAt,
352 models,
353 probe: {
354 installed: false,
355 version: null,
356 status: "warning",
357 auth: { status: "unknown" },
358 message:
359 openCodeSettings.serverUrl.trim().length > 0
360 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
361 : "OpenCode is disabled in T3 Code settings.",
362 },
363 });
364 }
365
366 return buildServerProvider({
367 presentation: OPENCODE_PRESENTATION,
368 enabled: true,
369 checkedAt,
370 models,
371 probe: {
372 installed: false,
373 version: null,
374 status: "warning",
375 auth: { status: "unknown" },
376 message: "OpenCode provider status has not been checked in this session yet.",
377 },
378 });
379 });
380
381/** One model an OpenCode 2 server lists, as the status check reads it. */
382export interface OpenCode2Model {
383 readonly providerID: string;
384 readonly id: string;
385 readonly name: string;
386 readonly variants: ReadonlyArray<{ readonly id: string }>;
387}
388
389/**
390 * A server loads its catalog lazily and lists nothing for its first few
391 * hundred milliseconds, and a local instance starts a fresh server for each
392 * status check. So an empty list is read again for a while, and the last
393 * non-empty list stands in if the catalog still has not loaded.
394 */
395export const makeOpenCode2ModelLoader = <E>(
396 list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>,
397) =>
398 Effect.sync(() => {
399 let lastLoaded: ReadonlyArray<OpenCode2Model> = [];
400 return list.pipe(
401 Effect.repeat({
402 until: (models) => models.length > 0,
403 schedule: Schedule.spaced("250 millis"),
404 }),
405 Effect.timeoutOption("5 seconds"),
406 Effect.map((loaded) => {
407 if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value;
408 return lastLoaded;
409 }),
410 );
411 });
412
413/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */
414export interface OpenCode2Workspace {
415 readonly skills: ReadonlyArray<{
416 readonly id: string;
417 readonly name: string;
418 readonly path: string;
419 readonly description?: string | undefined;
420 }>;
421 readonly commands: ReadonlyArray<{
422 readonly name: string;
423 readonly description?: string | undefined;
424 }>;
425}
426
427/**
428 * A server scans a directory it has not served yet in the background: its
429 * first read lists no commands at all (not even the built-ins), and
430 * `command.updated` plus `skill.updated` for the directory end the scan. A
431 * directory it already serves lists everything at once. `scanned` must be
432 * listening before `read` runs, since the read is what starts the scan.
433 */
434export const loadOpenCode2Workspace = <E>(
435 read: Effect.Effect<OpenCode2Workspace, E>,
436 scanned: Effect.Effect<void>,
437) =>
438 Effect.gen(function* () {
439 const first = yield* read;
440 if (first.commands.length > 0) return first;
441 yield* scanned.pipe(Effect.timeoutOption("10 seconds"));
442 return yield* read;
443 });
444
445export function openCode2SkillsToServerProviderSkills(
446 skills: OpenCode2Workspace["skills"],
447): ReadonlyArray<ServerProviderSkill> {
448 return skills
449 .map((skill) => {
450 const description = trimOptional(skill.description);
451 return {
452 name: skill.id,
453 path: skill.path,
454 enabled: true,
455 ...(skill.name === skill.id ? {} : { displayName: skill.name }),
456 ...(description ? { description, shortDescription: description } : {}),
457 };
458 })
459 .toSorted((left, right) => left.name.localeCompare(right.name));
460}
461
462export function openCode2CommandsToServerProviderSlashCommands(
463 commands: OpenCode2Workspace["commands"],
464): ReadonlyArray<ServerProviderSlashCommand> {
465 const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
466 const names = new Set([COMPACT_SLASH_COMMAND.name]);
467 for (const command of commands) {
468 const name = trimOptional(command.name);
469 if (!name || names.has(name)) continue;
470 names.add(name);
471 const description = trimOptional(command.description);
472 slashCommands.push({ name, ...(description ? { description } : {}) });
473 }
474 return slashCommands;
475}
476
477/**
478 * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised:
479 * OpenCode has no reviewer that approves routine actions. Plan mode is its
480 * `plan` agent, so the composer's mode toggle drives it.
481 */
482const OPENCODE_2_PRESENTATION = {
483 ...OPENCODE_PRESENTATION,
484 showInteractionModeToggle: true,
485 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"],
486} as const;
487
488function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities {
489 const variants = model.variants.map((variant) => variant.id);
490 const defaultVariant = inferDefaultVariant(model.providerID, variants);
491 return createModelCapabilities({
492 optionDescriptors:
493 variants.length === 0
494 ? []
495 : [
496 {
497 id: "variant",
498 label: "Reasoning",
499 type: "select",
500 options: variants.map((id) => ({
501 id,
502 label: titleCaseSlug(id),
503 ...(id === defaultVariant ? { isDefault: true } : {}),
504 })),
505 ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }),
506 },
507 ],
508 });
509}
510
511const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* (
512 settings: OpenCodeSettings,
513 version: string,
514 checkedAt: string,
515 loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>,
516) {
517 const result = yield* Effect.exit(loadModels);
518 const probe = (status: "ready" | "warning" | "error", message: string) => ({
519 installed: true,
520 version,
521 status,
522 auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) },
523 message,
524 });
525 if (result._tag === "Failure") {
526 // The detail can carry a response body; it stays in the log, not the status.
527 yield* Effect.logWarning("OpenCode 2 model list failed", result.cause);
528 return buildServerProvider({
529 presentation: OPENCODE_2_PRESENTATION,
530 enabled: true,
531 checkedAt,
532 models: providerModelsFromSettings(
533 [],
534 settings.customModels,
535 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
536 ),
537 // Compaction does not depend on the model catalog.
538 slashCommands: [COMPACT_SLASH_COMMAND],
539 probe: probe("error", "OpenCode could not load its model list."),
540 });
541 }
542 const models = providerModelsFromSettings(
543 result.value
544 .map((model) => ({
545 slug: `${model.providerID}/${model.id}`,
546 name: model.name,
547 isCustom: false,
548 capabilities: openCode2ModelCapabilities(model),
549 }))
550 .toSorted((left, right) => left.name.localeCompare(right.name)),
551 settings.customModels,
552 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
553 );
554 return buildServerProvider({
555 presentation: OPENCODE_2_PRESENTATION,
556 enabled: true,
557 checkedAt,
558 models,
559 // Every session can compact; a workspace snapshot adds that directory's commands.
560 slashCommands: [COMPACT_SLASH_COMMAND],
561 probe:
562 result.value.length > 0
563 ? probe(
564 "ready",
565 `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`,
566 )
567 : probe("warning", "OpenCode 2 is running, but it did not list any models yet."),
568 });
569});
570
571/**
572 * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary,
573 * the version endpoints for a configured server. The status check refreshes it, so an in-place
574 * upgrade re-routes the instance.
575 */
576export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (
577 openCodeSettings: OpenCodeSettings,
578 cwd: string,
579 probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>,
580 loadOpenCode2Models: Effect.Effect<
581 ReadonlyArray<OpenCode2Model>,
582 OpenCodeRuntime.OpenCodeRuntimeError
583 >,
584): Effect.fn.Return<
585 ServerProviderDraft,
586 never,
587 OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner
588> {
589 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
590 const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
591 const checkedAt = DateTime.formatIso(yield* DateTime.now);
592 const customModels = openCodeSettings.customModels;
593 const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
594
595 const fallback = (
596 cause: unknown,
597 version: string | null = null,
598 phase: "version" | "inventory" = "version",
599 ) => {
600 const failure = formatOpenCodeProbeError({
601 cause,
602 isExternalServer,
603 phase,
604 serverUrl: openCodeSettings.serverUrl,
605 });
606 return buildServerProvider({
607 presentation: OPENCODE_PRESENTATION,
608 enabled: openCodeSettings.enabled,
609 checkedAt,
610 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
611 probe: {
612 installed: failure.installed,
613 version,
614 status: "error",
615 auth: { status: "unknown" },
616 message: failure.message,
617 },
618 });
619 };
620
621 if (!openCodeSettings.enabled) {
622 return buildServerProvider({
623 presentation: OPENCODE_PRESENTATION,
624 enabled: false,
625 checkedAt,
626 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
627 probe: {
628 installed: false,
629 version: null,
630 status: "warning",
631 auth: { status: "unknown" },
632 message: isExternalServer
633 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
634 : "OpenCode is disabled in T3 Code settings.",
635 },
636 });
637 }
638
639 const probedExit = yield* Effect.exit(
640 probeRuntime.pipe(
641 Effect.mapError(
642 (cause) =>
643 new OpenCodeProbeError({
644 cause,
645 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
646 }),
647 ),
648 ),
649 );
650 if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause));
651 const probed = probedExit.value;
652 if (probed.generation === "v2") {
653 return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models);
654 }
655 let version: string | null = probed.version;
656 if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) {
657 return buildServerProvider({
658 presentation: OPENCODE_PRESENTATION,
659 enabled: openCodeSettings.enabled,
660 checkedAt,
661 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
662 probe: {
663 installed: true,
664 version: probed.version,
665 status: "error",
666 auth: { status: "unknown" },
667 message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
668 },
669 });
670 }
671
672 const loadInventory = (server: {
673 readonly url: string;
674 readonly serverPassword?: string;
675 readonly version: string;
676 }) =>
677 openCodeRuntime
678 .loadOpenCodeInventory(
679 openCodeRuntime.createOpenCodeSdkClient({
680 baseUrl: server.url,
681 directory: cwd,
682 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
683 }),
684 )
685 .pipe(Effect.map((inventory) => ({ inventory, version: server.version })));
686 const inventoryEffect = isExternalServer
687 ? openCodeRuntime
688 .connectToOpenCodeServer({
689 binaryPath: openCodeSettings.binaryPath,
690 directory: cwd,
691 serverUrl: openCodeSettings.serverUrl,
692 ...(openCodeSettings.serverPassword
693 ? { serverPassword: openCodeSettings.serverPassword }
694 : {}),
695 })
696 .pipe(Effect.flatMap(loadInventory), Effect.scoped)
697 : serverOwner.withServer(loadInventory);
698 const inventoryExit = yield* Effect.exit(
699 inventoryEffect.pipe(
700 Effect.mapError(
701 (cause) =>
702 new OpenCodeProbeError({
703 cause,
704 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
705 }),
706 ),
707 ),
708 );
709 if (inventoryExit._tag === "Failure") {
710 return fallback(Cause.squash(inventoryExit.cause), version, "inventory");
711 }
712
713 version = inventoryExit.value.version;
714
715 const models = providerModelsFromSettings(
716 flattenOpenCodeModels(inventoryExit.value.inventory),
717 customModels,
718 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
719 );
720 const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills);
721 const connectedCount = inventoryExit.value.inventory.providerList.connected.length;
722 return buildServerProvider({
723 presentation: OPENCODE_PRESENTATION,
724 enabled: true,
725 checkedAt,
726 models,
727 skills,
728 slashCommands: openCodeCommandsToServerProviderSlashCommands(
729 inventoryExit.value.inventory.commands,
730 ),
731 probe: {
732 installed: true,
733 version,
734 status: connectedCount > 0 ? "ready" : "warning",
735 auth: {
736 status: connectedCount > 0 ? "authenticated" : "unknown",
737 type: "opencode",
738 },
739 message:
740 connectedCount > 0
741 ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.`
742 : isExternalServer
743 ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers."
744 : "OpenCode is available, but it did not report any connected upstream providers.",
745 },
746 });
747});
packages/provider-opencode/src/server/textGeneration.test.ts created+609
...@@ -0,0 +1,609 @@
1import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
2import { OpenCodeSettings } from "../settings.ts";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";
10import * as NetService from "@t3tools/shared/Net";
11import { beforeEach, expect } from "vite-plus/test";
12
13import { layerTestProviderHost } from "@t3tools/provider-testing/host";
14import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
15import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
16import * as OpenCodeTextGeneration from "./textGeneration.ts";
17import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
18
19const runtimeMock = {
20 state: {
21 startCalls: [] as string[],
22 promptUrls: [] as string[],
23 promptParts: [] as ReadonlyArray<unknown>[],
24 authHeaders: [] as Array<string | null>,
25 closeCalls: [] as string[],
26 sessionCreateCalls: 0,
27 connectionError: undefined as Error | undefined,
28 sessionCreateError: undefined as unknown,
29 sessionResult: undefined as { data?: { id: string } } | undefined,
30 promptRequestError: undefined as unknown,
31 promptResult: undefined as
32 | { data?: { info?: { error?: unknown }; parts?: Array<unknown> } }
33 | undefined,
34 },
35 reset() {
36 this.state.startCalls.length = 0;
37 this.state.promptUrls.length = 0;
38 this.state.promptParts.length = 0;
39 this.state.authHeaders.length = 0;
40 this.state.closeCalls.length = 0;
41 this.state.sessionCreateCalls = 0;
42 this.state.connectionError = undefined;
43 this.state.sessionCreateError = undefined;
44 this.state.sessionResult = undefined;
45 this.state.promptRequestError = undefined;
46 this.state.promptResult = undefined;
47 },
48};
49
50const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
51 startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) =>
52 Effect.gen(function* () {
53 const index = runtimeMock.state.startCalls.length + 1;
54 const url = `http://127.0.0.1:${4_300 + index}`;
55 runtimeMock.state.startCalls.push(binaryPath);
56 // The production runtime binds server lifetime to the caller's scope.
57 // Mirror that here so the closeCalls probe observes scope close.
58 yield* Effect.addFinalizer(() =>
59 Effect.sync(() => {
60 runtimeMock.state.closeCalls.push(url);
61 }),
62 );
63 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
64 external: false,
65 ...(serverPassword !== undefined ? { serverPassword } : {}),
66 ...(environment !== undefined ? { environment } : {}),
67 });
68 return {
69 url,
70 ...(effectiveServerPassword !== undefined
71 ? { serverPassword: effectiveServerPassword }
72 : {}),
73 version: "1.14.19",
74 isRunning: Effect.succeed(true),
75 exitCode: Effect.never,
76 };
77 }),
78 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
79 runtimeMock.state.connectionError
80 ? Effect.fail(
81 new OpenCodeRuntime.OpenCodeRuntimeError({
82 operation: "global.health",
83 detail: runtimeMock.state.connectionError.message,
84 cause: runtimeMock.state.connectionError,
85 }),
86 )
87 : Effect.succeed({
88 url: serverUrl ?? "http://127.0.0.1:4301",
89 ...(serverPassword ? { serverPassword } : {}),
90 version: "1.14.19",
91 exitCode: null,
92 external: Boolean(serverUrl),
93 }),
94 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
95 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
96 ({
97 session: {
98 create: async () => {
99 runtimeMock.state.sessionCreateCalls += 1;
100 if (runtimeMock.state.sessionCreateError !== undefined) {
101 throw runtimeMock.state.sessionCreateError;
102 }
103 return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };
104 },
105 prompt: async (input: { readonly parts: ReadonlyArray<unknown> }) => {
106 runtimeMock.state.promptUrls.push(baseUrl);
107 runtimeMock.state.promptParts.push(input.parts);
108 runtimeMock.state.authHeaders.push(
109 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
110 );
111 if (runtimeMock.state.promptRequestError !== undefined) {
112 throw runtimeMock.state.promptRequestError;
113 }
114 return (
115 runtimeMock.state.promptResult ?? {
116 data: {
117 parts: [
118 {
119 type: "text",
120 text: JSON.stringify({
121 subject: "Improve OpenCode reuse",
122 body: "Reuse one server for the full action.",
123 }),
124 },
125 ],
126 },
127 }
128 );
129 },
130 },
131 }) as unknown as ReturnType<OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
132 loadOpenCodeInventory: () =>
133 Effect.fail(
134 new OpenCodeRuntime.OpenCodeRuntimeError({
135 operation: "loadOpenCodeInventory",
136 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
137 cause: null,
138 }),
139 ),
140 loadOpenCodeSkills: () => Effect.succeed([]),
141 loadInventoryFromCli: () =>
142 Effect.fail(
143 new OpenCodeRuntime.OpenCodeRuntimeError({
144 operation: "loadInventoryFromCli",
145 detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test",
146 cause: null,
147 }),
148 ),
149 loadSkillsFromCli: () => Effect.succeed([]),
150};
151
152const DEFAULT_TEST_MODEL_SELECTION = {
153 instanceId: ProviderInstanceId.make("opencode"),
154 model: "openai/gpt-5",
155};
156const DEFAULT_COMMIT_MESSAGE_INPUT = {
157 cwd: process.cwd(),
158 branch: "feature/opencode-reuse",
159 stagedSummary: "M README.md",
160 stagedPatch: "diff --git a/README.md b/README.md",
161 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
162};
163
164const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
165
166const layerOpenCodeTextGenerationTest = Layer.succeed(
167 OpenCodeRuntime.OpenCodeRuntime,
168 OpenCodeRuntimeTestDouble,
169).pipe(
170 Layer.provideMerge(layerTestProviderHost()),
171 Layer.provideMerge(NetService.layer),
172 Layer.provideMerge(NodeServices.layer),
173);
174
175const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed(
176 OpenCodeRuntime.OpenCodeRuntime,
177 OpenCodeRuntimeTestDouble,
178).pipe(
179 Layer.provideMerge(layerTestProviderHost()),
180 Layer.provideMerge(NetService.layer),
181 Layer.provideMerge(NodeServices.layer),
182);
183
184const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
185 binaryPath: "fake-opencode",
186});
187const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
188 binaryPath: "fake-opencode",
189 serverPassword: "secret-password",
190});
191const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
192 binaryPath: "fake-opencode",
193 serverUrl: "http://127.0.0.1:9999",
194 serverPassword: "secret-password",
195});
196const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
197 binaryPath: "fake-opencode",
198 serverUrl: "http://127.0.0.1:9999",
199});
200
201function withOpenCodeTextGeneration<A, E, R>(
202 settings: OpenCodeSettings,
203 effectFn: (textGeneration: ProviderTextGeneration) => Effect.Effect<A, E, R>,
204 environment?: NodeJS.ProcessEnv,
205) {
206 return Effect.gen(function* () {
207 const serverOwner = yield* OpenCodeServerOwner.make({
208 binaryPath: settings.binaryPath,
209 directory: process.cwd(),
210 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
211 ...(environment ? { environment } : {}),
212 });
213 const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe(
214 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
215 );
216 return yield* effectFn(textGeneration);
217 }).pipe(Effect.scoped);
218}
219
220beforeEach(() => {
221 runtimeMock.reset();
222});
223
224const advanceIdleClock = Effect.gen(function* () {
225 yield* Effect.yieldNow;
226 yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1));
227 yield* Effect.yieldNow;
228});
229
230it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => {
231 it.effect("excludes generic files from thread title generation", () =>
232 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
233 Effect.gen(function* () {
234 runtimeMock.state.promptResult = {
235 data: {
236 parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }],
237 },
238 };
239
240 yield* textGeneration.generateThreadTitle({
241 cwd: process.cwd(),
242 message: "Review these attachments.",
243 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
244 attachments: [
245 {
246 type: "image",
247 id: "thread-image-attachment",
248 name: "screenshot.png",
249 mimeType: "image/png",
250 sizeBytes: 3,
251 },
252 {
253 type: "file",
254 id: "thread-report-attachment-pdf",
255 name: "report.pdf",
256 mimeType: "application/pdf",
257 sizeBytes: 42,
258 },
259 ],
260 });
261
262 expect(runtimeMock.state.promptParts[0]).toEqual([
263 expect.objectContaining({ type: "text" }),
264 expect.objectContaining({ type: "file", filename: "screenshot.png" }),
265 ]);
266 }),
267 ),
268 );
269
270 it.effect("passes configured authentication to a locally spawned server", () =>
271 withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) =>
272 Effect.gen(function* () {
273 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
274
275 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
276 expect(runtimeMock.state.authHeaders).toEqual([
277 `Basic ${btoa("opencode:secret-password")}`,
278 ]);
279 }),
280 ),
281 );
282
283 it.effect("uses an environment-only password for a locally spawned server", () =>
284 withOpenCodeTextGeneration(
285 DEFAULT_OPENCODE_SETTINGS,
286 (textGeneration) =>
287 Effect.gen(function* () {
288 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
289
290 expect(runtimeMock.state.authHeaders).toEqual([
291 `Basic ${btoa("opencode:environment-password")}`,
292 ]);
293 }),
294 { OPENCODE_SERVER_PASSWORD: "environment-password" },
295 ),
296 );
297
298 it.effect("uses settings auth when the local environment password differs", () =>
299 withOpenCodeTextGeneration(
300 LOCAL_AUTH_OPENCODE_SETTINGS,
301 (textGeneration) =>
302 Effect.gen(function* () {
303 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
304
305 expect(runtimeMock.state.authHeaders).toEqual([
306 `Basic ${btoa("opencode:secret-password")}`,
307 ]);
308 }),
309 { OPENCODE_SERVER_PASSWORD: "environment-password" },
310 ),
311 );
312
313 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
314 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
315 Effect.gen(function* () {
316 yield* textGeneration.generateCommitMessage({
317 cwd: process.cwd(),
318 branch: "feature/opencode-reuse",
319 stagedSummary: "M README.md",
320 stagedPatch: "diff --git a/README.md b/README.md",
321 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
322 });
323 yield* textGeneration.generateCommitMessage({
324 cwd: process.cwd(),
325 branch: "feature/opencode-reuse",
326 stagedSummary: "M README.md",
327 stagedPatch: "diff --git a/README.md b/README.md",
328 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
329 });
330
331 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
332 expect(runtimeMock.state.promptUrls).toEqual([
333 "http://127.0.0.1:4301",
334 "http://127.0.0.1:4301",
335 ]);
336 expect(runtimeMock.state.closeCalls).toEqual([]);
337
338 yield* advanceIdleClock;
339
340 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
341 }),
342 ).pipe(Effect.provide(TestClock.layer())),
343 );
344
345 it.effect("starts a new server after the warm server idles out", () =>
346 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
347 Effect.gen(function* () {
348 yield* textGeneration.generateCommitMessage({
349 cwd: process.cwd(),
350 branch: "feature/opencode-reuse",
351 stagedSummary: "M README.md",
352 stagedPatch: "diff --git a/README.md b/README.md",
353 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
354 });
355
356 yield* advanceIdleClock;
357
358 yield* textGeneration.generateCommitMessage({
359 cwd: process.cwd(),
360 branch: "feature/opencode-reuse",
361 stagedSummary: "M README.md",
362 stagedPatch: "diff --git a/README.md b/README.md",
363 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
364 });
365
366 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
367 expect(runtimeMock.state.promptUrls).toEqual([
368 "http://127.0.0.1:4301",
369 "http://127.0.0.1:4302",
370 ]);
371 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
372 }),
373 ).pipe(Effect.provide(TestClock.layer())),
374 );
375
376 it.effect("preserves the SDK cause when session creation fails", () =>
377 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
378 Effect.gen(function* () {
379 const sdkCause = new Error("session endpoint unavailable");
380 runtimeMock.state.sessionCreateError = sdkCause;
381
382 const error = yield* textGeneration
383 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
384 .pipe(Effect.flip);
385
386 expect(error).toBeInstanceOf(TextGenerationError);
387 expect(error.message).toContain("OpenCode session.create request failed.");
388 expect(error.cause).toMatchObject({
389 _tag: "OpenCodeTextGenerationSessionRequestError",
390 operation: "generateCommitMessage",
391 cwd: process.cwd(),
392 cause: sdkCause,
393 });
394 expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
395 }),
396 ),
397 );
398
399 it.effect("reports a missing session payload without manufacturing a cause", () =>
400 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
401 Effect.gen(function* () {
402 runtimeMock.state.sessionResult = {};
403
404 const error = yield* textGeneration
405 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
406 .pipe(Effect.flip);
407
408 expect(error.message).toContain("OpenCode session.create returned no session payload.");
409 expect(error.cause).toMatchObject({
410 _tag: "OpenCodeTextGenerationSessionPayloadError",
411 operation: "generateCommitMessage",
412 cwd: process.cwd(),
413 });
414 expect(error.cause).not.toHaveProperty("cause");
415 }),
416 ),
417 );
418
419 it.effect("preserves the SDK cause and request context when prompting fails", () =>
420 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
421 Effect.gen(function* () {
422 const sdkCause = new Error("prompt endpoint unavailable");
423 runtimeMock.state.promptRequestError = sdkCause;
424
425 const error = yield* textGeneration
426 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
427 .pipe(Effect.flip);
428
429 expect(error.message).toContain("OpenCode session.prompt request failed.");
430 expect(error.cause).toMatchObject({
431 _tag: "OpenCodeTextGenerationPromptRequestError",
432 operation: "generateCommitMessage",
433 cwd: process.cwd(),
434 sessionId: "http://127.0.0.1:4301/session",
435 providerId: "openai",
436 modelId: "gpt-5",
437 cause: sdkCause,
438 });
439 expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
440 }),
441 ),
442 );
443
444 it.effect("returns a typed empty-output error for malformed and blank response parts", () =>
445 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
446 Effect.gen(function* () {
447 runtimeMock.state.promptResult = {
448 data: {
449 parts: [null, { type: "tool" }, { type: "text", text: " " }],
450 },
451 };
452
453 const error = yield* textGeneration
454 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
455 .pipe(Effect.flip);
456
457 expect(error.message).toContain("OpenCode returned empty output.");
458 expect(error.cause).toMatchObject({
459 _tag: "OpenCodeTextGenerationEmptyOutputError",
460 operation: "generateCommitMessage",
461 cwd: process.cwd(),
462 sessionId: "http://127.0.0.1:4301/session",
463 providerId: "openai",
464 modelId: "gpt-5",
465 responsePartCount: 3,
466 textPartCount: 1,
467 });
468 expect(error.cause).not.toHaveProperty("cause");
469 }),
470 ),
471 );
472
473 it.effect("parses JSON returned as plain text output", () =>
474 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
475 Effect.gen(function* () {
476 runtimeMock.state.promptResult = {
477 data: {
478 parts: [
479 {
480 type: "text",
481 text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}',
482 },
483 ],
484 },
485 };
486
487 const result = yield* textGeneration.generateCommitMessage({
488 cwd: process.cwd(),
489 branch: "feature/opencode-reuse",
490 stagedSummary: "M README.md",
491 stagedPatch: "diff --git a/README.md b/README.md",
492 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
493 });
494
495 expect(result).toEqual({
496 subject: "Tighten OpenCode parsing",
497 body: "Handle JSON text output locally.",
498 });
499 }),
500 ),
501 );
502
503 it.effect("surfaces the upstream OpenCode structured-output error message", () =>
504 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
505 Effect.gen(function* () {
506 runtimeMock.state.promptResult = {
507 data: {
508 info: {
509 error: {
510 name: "StructuredOutputError",
511 data: {
512 message: "Model did not produce structured output",
513 retries: 2,
514 },
515 },
516 },
517 },
518 };
519
520 const error = yield* textGeneration
521 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
522 .pipe(Effect.flip);
523
524 expect(error.message).toContain("Model did not produce structured output");
525 expect(error.cause).toMatchObject({
526 _tag: "OpenCodeTextGenerationPromptResponseError",
527 operation: "generateCommitMessage",
528 cwd: process.cwd(),
529 sessionId: "http://127.0.0.1:4301/session",
530 providerId: "openai",
531 modelId: "gpt-5",
532 providerErrorName: "StructuredOutputError",
533 providerMessage: "Model did not produce structured output",
534 });
535 expect(error.cause).not.toHaveProperty("cause");
536 }),
537 ),
538 );
539});
540
541it.layer(layerOpenCodeTextGenerationExistingServerTest)(
542 "OpenCodeTextGeneration with configured server URL",
543 (it) => {
544 it.effect("does not send a local environment password to a configured server", () =>
545 withOpenCodeTextGeneration(
546 EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS,
547 (textGeneration) =>
548 Effect.gen(function* () {
549 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
550 expect(runtimeMock.state.authHeaders).toEqual([null]);
551 }),
552 { OPENCODE_SERVER_PASSWORD: "local-secret" },
553 ),
554 );
555
556 it.effect("does not create a session when the server version is unsupported", () =>
557 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
558 Effect.gen(function* () {
559 runtimeMock.state.connectionError = new Error(
560 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
561 );
562
563 const error = yield* textGeneration
564 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
565 .pipe(Effect.flip);
566
567 expect(error).toBeInstanceOf(TextGenerationError);
568 expect(error.message).toContain("v1.14.18 is too old");
569 expect(runtimeMock.state.sessionCreateCalls).toBe(0);
570 }),
571 ),
572 );
573
574 it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
575 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
576 Effect.gen(function* () {
577 yield* textGeneration.generateCommitMessage({
578 cwd: process.cwd(),
579 branch: "feature/opencode-reuse",
580 stagedSummary: "M README.md",
581 stagedPatch: "diff --git a/README.md b/README.md",
582 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
583 });
584 yield* textGeneration.generateCommitMessage({
585 cwd: process.cwd(),
586 branch: "feature/opencode-reuse",
587 stagedSummary: "M README.md",
588 stagedPatch: "diff --git a/README.md b/README.md",
589 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
590 });
591
592 expect(runtimeMock.state.startCalls).toEqual([]);
593 expect(runtimeMock.state.promptUrls).toEqual([
594 "http://127.0.0.1:9999",
595 "http://127.0.0.1:9999",
596 ]);
597 expect(runtimeMock.state.authHeaders).toEqual([
598 `Basic ${btoa("opencode:secret-password")}`,
599 `Basic ${btoa("opencode:secret-password")}`,
600 ]);
601
602 yield* advanceIdleClock;
603
604 expect(runtimeMock.state.closeCalls).toEqual([]);
605 }),
606 ).pipe(Effect.provide(TestClock.layer())),
607 );
608 },
609);
packages/provider-opencode/src/server/textGeneration.ts created+324
...@@ -0,0 +1,324 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3
4import { NonNegativeInt, TextGenerationError } from "@t3tools/contracts";
5import type { OpenCodeSettings } from "../settings.ts";
6import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
7
8import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
9import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
10import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
11import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
12
13const OpenCodeTextGenerationOperation = Schema.Literals([
14 "generateCommitMessage",
15 "generatePrContent",
16 "generateBranchName",
17 "generateThreadTitle",
18]);
19
20const openCodeTextGenerationErrorContext = {
21 operation: OpenCodeTextGenerationOperation,
22 cwd: Schema.String,
23};
24
25export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedError<OpenCodeTextGenerationSessionRequestError>()(
26 "OpenCodeTextGenerationSessionRequestError",
27 {
28 ...openCodeTextGenerationErrorContext,
29 cause: Schema.Defect(),
30 },
31) {
32 override get message(): string {
33 return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`;
34 }
35}
36
37export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedError<OpenCodeTextGenerationSessionPayloadError>()(
38 "OpenCodeTextGenerationSessionPayloadError",
39 openCodeTextGenerationErrorContext,
40) {
41 override get message(): string {
42 return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`;
43 }
44}
45
46const openCodePromptErrorContext = {
47 ...openCodeTextGenerationErrorContext,
48 sessionId: Schema.String,
49 providerId: Schema.String,
50 modelId: Schema.String,
51};
52
53export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedError<OpenCodeTextGenerationPromptRequestError>()(
54 "OpenCodeTextGenerationPromptRequestError",
55 {
56 ...openCodePromptErrorContext,
57 cause: Schema.Defect(),
58 },
59) {
60 override get message(): string {
61 return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`;
62 }
63}
64
65export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedError<OpenCodeTextGenerationPromptResponseError>()(
66 "OpenCodeTextGenerationPromptResponseError",
67 {
68 ...openCodePromptErrorContext,
69 providerErrorName: Schema.optional(Schema.String),
70 providerMessage: Schema.String,
71 },
72) {
73 override get message(): string {
74 const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : "";
75 return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`;
76 }
77}
78
79export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError<OpenCodeTextGenerationEmptyOutputError>()(
80 "OpenCodeTextGenerationEmptyOutputError",
81 {
82 ...openCodePromptErrorContext,
83 responsePartCount: NonNegativeInt,
84 textPartCount: NonNegativeInt,
85 },
86) {
87 override get message(): string {
88 return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`;
89 }
90}
91
92interface OpenCodePromptFailure {
93 readonly name?: string;
94 readonly message: string;
95}
96
97interface OpenCodeTextPart {
98 readonly type: "text";
99 readonly text: string;
100}
101
102function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null {
103 if (!error || typeof error !== "object") {
104 return null;
105 }
106
107 const name =
108 "name" in error && typeof error.name === "string" && error.name.trim().length > 0
109 ? error.name.trim()
110 : undefined;
111 const message =
112 "data" in error &&
113 error.data &&
114 typeof error.data === "object" &&
115 "message" in error.data &&
116 typeof error.data.message === "string"
117 ? error.data.message.trim()
118 : "";
119 if (message.length > 0) {
120 return {
121 ...(name ? { name } : {}),
122 message,
123 };
124 }
125
126 if (name) {
127 return { name, message: name };
128 }
129
130 return null;
131}
132
133function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart {
134 return (
135 part !== null &&
136 typeof part === "object" &&
137 "type" in part &&
138 part.type === "text" &&
139 "text" in part &&
140 typeof part.text === "string"
141 );
142}
143
144function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string {
145 return (parts ?? [])
146 .filter(isOpenCodeTextPart)
147 .map((part) => part.text)
148 .join("")
149 .trim();
150}
151
152export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
153 openCodeSettings: OpenCodeSettings,
154) {
155 const host = yield* ProviderHost.ProviderHost;
156 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
157 const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
158
159 const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(
160 input: TextGenerationOperations.Request<S>,
161 ) {
162 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model);
163 if (!parsedModel) {
164 return yield* new TextGenerationError({
165 operation: input.operation,
166 detail: "OpenCode model selection must use the 'provider/model' format.",
167 });
168 }
169
170 const fileParts = OpenCodeRuntime.toOpenCodeFileParts({
171 attachments: input.attachments?.filter((attachment) => attachment.type === "image"),
172 resolveAttachmentPath: host.resolveAttachmentPath,
173 });
174
175 const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")(
176 function* (
177 server: Pick<
178 OpenCodeRuntime.OpenCodeServerConnection,
179 "url" | "serverPassword" | "version"
180 >,
181 ) {
182 const client = openCodeRuntime.createOpenCodeSdkClient({
183 baseUrl: server.url,
184 directory: input.cwd,
185 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
186 });
187 const session = yield* Effect.tryPromise({
188 try: () =>
189 client.session.create({
190 title: `T3 Code ${input.operation}`,
191 permission: [{ permission: "*", pattern: "*", action: "deny" }],
192 }),
193 catch: (cause) =>
194 new OpenCodeTextGenerationSessionRequestError({
195 operation: input.operation,
196 cwd: input.cwd,
197 cause,
198 }),
199 });
200 if (!session.data) {
201 return yield* new OpenCodeTextGenerationSessionPayloadError({
202 operation: input.operation,
203 cwd: input.cwd,
204 });
205 }
206 const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent");
207 const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant");
208 const promptContext = {
209 operation: input.operation,
210 cwd: input.cwd,
211 sessionId: session.data.id,
212 providerId: parsedModel.providerID,
213 modelId: parsedModel.modelID,
214 };
215
216 const result = yield* Effect.tryPromise({
217 try: () =>
218 client.session.prompt({
219 sessionID: session.data.id,
220 model: parsedModel,
221 ...(selectedAgent ? { agent: selectedAgent } : {}),
222 ...(selectedVariant ? { variant: selectedVariant } : {}),
223 parts: [{ type: "text", text: input.prompt }, ...fileParts],
224 }),
225 catch: (cause) =>
226 new OpenCodeTextGenerationPromptRequestError({
227 ...promptContext,
228 cause,
229 }),
230 });
231 const promptFailure = getOpenCodePromptFailure(result.data?.info?.error);
232 if (promptFailure) {
233 return yield* new OpenCodeTextGenerationPromptResponseError({
234 ...promptContext,
235 ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}),
236 providerMessage: promptFailure.message,
237 });
238 }
239 const responseParts = result.data?.parts ?? [];
240 const rawText = getOpenCodeTextResponse(responseParts);
241 if (rawText.length === 0) {
242 return yield* new OpenCodeTextGenerationEmptyOutputError({
243 ...promptContext,
244 responsePartCount: responseParts.length,
245 textPartCount: responseParts.filter(isOpenCodeTextPart).length,
246 });
247 }
248 return rawText;
249 },
250 Effect.catchTags({
251 OpenCodeTextGenerationSessionRequestError: (cause) =>
252 Effect.fail(
253 new TextGenerationError({
254 operation: cause.operation,
255 detail: "OpenCode session.create request failed.",
256 cause,
257 }),
258 ),
259 OpenCodeTextGenerationSessionPayloadError: (cause) =>
260 Effect.fail(
261 new TextGenerationError({
262 operation: cause.operation,
263 detail: "OpenCode session.create returned no session payload.",
264 cause,
265 }),
266 ),
267 OpenCodeTextGenerationPromptRequestError: (cause) =>
268 Effect.fail(
269 new TextGenerationError({
270 operation: cause.operation,
271 detail: "OpenCode session.prompt request failed.",
272 cause,
273 }),
274 ),
275 OpenCodeTextGenerationPromptResponseError: (cause) =>
276 Effect.fail(
277 new TextGenerationError({
278 operation: cause.operation,
279 detail: cause.providerMessage,
280 cause,
281 }),
282 ),
283 OpenCodeTextGenerationEmptyOutputError: (cause) =>
284 Effect.fail(
285 new TextGenerationError({
286 operation: cause.operation,
287 detail: "OpenCode returned empty output.",
288 cause,
289 }),
290 ),
291 }),
292 );
293
294 const serverOutput =
295 openCodeSettings.serverUrl.length > 0
296 ? openCodeRuntime
297 .connectToOpenCodeServer({
298 binaryPath: openCodeSettings.binaryPath,
299 directory: input.cwd,
300 serverUrl: openCodeSettings.serverUrl,
301 ...(openCodeSettings.serverPassword
302 ? { serverPassword: openCodeSettings.serverPassword }
303 : {}),
304 })
305 .pipe(Effect.flatMap(runAgainstServer), Effect.scoped)
306 : serverOwner.withServer(runAgainstServer);
307 const rawOutput = yield* serverOutput.pipe(
308 Effect.catchTags({
309 OpenCodeRuntimeError: (cause) =>
310 Effect.fail(
311 new TextGenerationError({
312 operation: input.operation,
313 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
314 cause,
315 }),
316 ),
317 }),
318 );
319
320 return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput);
321 });
322
323 return TextGenerationOperations.fromRunner("OpenCodeTextGeneration", runOpenCodeJson);
324});
packages/provider-opencode/src/server/toolItems.ts created+170
...@@ -0,0 +1,170 @@
1/**
2 * Turn-item bodies for OpenCode tool calls, shared by the 1.x and 2.x runtimes.
3 * Both report a tool as a name, a JSON input, text output, and free-form
4 * metadata; only how they deliver those differs.
5 */
6import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
7import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
8
9// Search results stay on the timeline wire, so keep their text a preview.
10const SEARCH_PREVIEW_MAX_CHARS = 8_000;
11
12type ToolItemBase = Omit<
13 Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>,
14 "type" | "toolName" | "input" | "output"
15>;
16
17export function openCodeToolProjectionKind(
18 toolName: string,
19): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" {
20 const normalized = toolName.toLowerCase();
21 if (normalized === "todowrite") {
22 return "dynamic_tool";
23 }
24 if (normalized.includes("bash") || normalized.includes("shell")) {
25 return "command_execution";
26 }
27 if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) {
28 return "file_change";
29 }
30 if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") {
31 return "web_search";
32 }
33 if (normalized === "read") {
34 return "dynamic_tool";
35 }
36 if (
37 normalized.includes("glob") ||
38 normalized.includes("grep") ||
39 normalized.includes("search") ||
40 normalized.includes("lsp")
41 ) {
42 return "file_search";
43 }
44 return "dynamic_tool";
45}
46
47function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined {
48 if (typeof input !== "object" || input === null) return undefined;
49 for (const key of keys) {
50 const value = (input as Record<string, unknown>)[key];
51 if (typeof value === "string" && value.trim().length > 0) return value;
52 }
53 return undefined;
54}
55
56function recordNumber(input: unknown, ...keys: ReadonlyArray<string>): number | undefined {
57 if (typeof input !== "object" || input === null) return undefined;
58 for (const key of keys) {
59 const value = (input as Record<string, unknown>)[key];
60 if (typeof value === "number" && Number.isFinite(value)) return value;
61 }
62 return undefined;
63}
64
65function stableJson(value: unknown): string {
66 if (typeof value === "string") return value;
67 try {
68 return JSON.stringify(value, null, 2);
69 } catch {
70 return String(value);
71 }
72}
73
74/**
75 * The turn item for one tool call. `completedMetadata` is the tool's metadata
76 * once it succeeded: exit codes and diffs are only trusted from a finished call.
77 */
78export function openCodeToolTurnItem(
79 base: ToolItemBase,
80 tool: {
81 readonly name: string;
82 readonly input: Record<string, unknown>;
83 readonly output: string | undefined;
84 readonly completedMetadata: unknown;
85 },
86): OrchestrationV2TurnItem {
87 const { input, output } = tool;
88 switch (openCodeToolProjectionKind(tool.name)) {
89 case "command_execution": {
90 const exitCode = recordNumber(tool.completedMetadata, "exit", "exitCode");
91 return {
92 ...base,
93 type: "command_execution",
94 input: recordString(input, "command", "cmd") ?? stableJson(input),
95 ...(output === undefined ? {} : { output }),
96 ...(exitCode === undefined ? {} : { exitCode }),
97 };
98 }
99 case "file_change": {
100 const oldStr = recordString(input, "oldString", "oldText");
101 const newStr = recordString(input, "newString", "content", "newText");
102 // A failed edit has no diff; keep its error where the diff would be.
103 const diffStr =
104 recordString(tool.completedMetadata, "diff", "patch") ??
105 (base.status === "failed" && output?.trim() ? output : undefined);
106 return {
107 ...base,
108 type: "file_change",
109 fileName: recordString(input, "filePath", "path", "file") ?? tool.name,
110 ...(oldStr === undefined ? {} : { oldStr }),
111 ...(newStr === undefined ? {} : { newStr }),
112 ...(diffStr === undefined ? {} : { diffStr }),
113 };
114 }
115 case "file_search": {
116 const pattern = recordString(input, "pattern", "query", "path", "filePath");
117 // OpenCode reports matches as plain text, so keep it as one result row
118 // under the searched path, like the ACP search projection.
119 const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim();
120 return {
121 ...base,
122 title:
123 formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ??
124 base.title,
125 type: "file_search",
126 ...(pattern === undefined ? {} : { pattern }),
127 ...(!output?.trim() || searchRoot === undefined
128 ? {}
129 : {
130 results: [
131 { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) },
132 ],
133 }),
134 };
135 }
136 case "web_search": {
137 const pattern = recordString(input, "query", "url", "pattern");
138 const url = recordString(input, "url")?.trim();
139 return {
140 ...base,
141 type: "web_search",
142 ...(pattern === undefined ? {} : { patterns: [pattern] }),
143 ...(!output?.trim()
144 ? {}
145 : {
146 results: [
147 {
148 ...(url === undefined ? {} : { url }),
149 snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS),
150 },
151 ],
152 }),
153 };
154 }
155 case "dynamic_tool": {
156 const readPath = recordString(input, "filePath", "path", "file");
157 return {
158 ...base,
159 title:
160 tool.name.toLowerCase() === "read" && readPath !== undefined
161 ? formatReadToolLabel(readPath)
162 : base.title,
163 type: "dynamic_tool",
164 toolName: tool.name,
165 input,
166 ...(output === undefined ? {} : { output }),
167 };
168 }
169 }
170}
packages/provider-opencode/src/server/usageLimits.ts created+121
...@@ -0,0 +1,121 @@
1import * as NodeOS from "node:os";
2
3import type { ServerProviderUsageWindow } from "@t3tools/contracts";
4import * as Crypto from "effect/Crypto";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as Hex from "effect/encoding/Hex";
12import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
13
14import {
15 clampPercent,
16 makeUnavailableUsageLimits,
17 makeUsageLimits,
18} from "@t3tools/provider-core/server/usageLimits";
19
20const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) });
21const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String });
22const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile));
23const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth);
24const UsageWindow = Schema.Struct({
25 percent: Schema.Finite,
26 resetsAt: Schema.DateTimeUtcFromString,
27});
28const UsageResponse = Schema.Struct({
29 usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }),
30});
31
32/** External OpenCode servers own their credentials; never read the host's account for them. */
33export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: {
34 readonly enabled: boolean;
35 readonly serverUrl: string;
36 readonly environment: NodeJS.ProcessEnv;
37}) {
38 const checkedAt = DateTime.formatIso(yield* DateTime.now);
39 const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
40 if (!input.enabled || input.serverUrl.trim()) return unsupported;
41
42 return yield* Effect.gen(function* () {
43 const fs = yield* FileSystem.FileSystem;
44 const path = yield* Path.Path;
45 const env = input.environment;
46 const dataHome =
47 env.XDG_DATA_HOME ||
48 path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share");
49 const authPath = path.join(dataHome, "opencode", "auth.json");
50 const contents =
51 env.OPENCODE_AUTH_CONTENT ||
52 (yield* fs.readFileString(authPath).pipe(
53 Effect.catchTags({
54 PlatformError: (error) =>
55 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
56 }),
57 ));
58 const auth = yield* decodeAuthFile(contents);
59 const apiAuth = decodeApiAuth(auth["opencode-go"]);
60 // OpenCode overlays stored API credentials after environment credentials.
61 const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim();
62 if (!apiKey) return unsupported;
63
64 const client = yield* HttpClient.HttpClient;
65 const response = yield* client.execute(
66 HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe(
67 HttpClientRequest.bearerToken(apiKey),
68 ),
69 );
70 // A valid Zen key can exist without a Go subscription.
71 if (response.status === 403) return unsupported;
72 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
73 Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)),
74 );
75 const windows: ServerProviderUsageWindow[] = [
76 {
77 id: "go_rolling",
78 kind: "session",
79 label: "Go · Session",
80 windowDurationMins: 5 * 60,
81 usedPercent: clampPercent(body.usage.rolling.percent),
82 resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt),
83 },
84 {
85 id: "go_weekly",
86 kind: "weekly",
87 label: "Go · Weekly",
88 windowDurationMins: 7 * 24 * 60,
89 usedPercent: clampPercent(body.usage.weekly.percent),
90 resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt),
91 },
92 {
93 id: "go_monthly",
94 kind: "monthly",
95 label: "Go · Monthly",
96 usedPercent: clampPercent(body.usage.monthly.percent),
97 resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt),
98 },
99 ];
100 const crypto = yield* Crypto.Crypto;
101 // Go's usage response has no account ID. An unkeyed hash matches across
102 // environments without a shared secret. It permits offline guesses, but
103 // Go keys are randomly generated.
104 const credentialFingerprint = yield* crypto
105 .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`))
106 .pipe(Effect.map(Hex.encode), Effect.orDie);
107 return {
108 ...makeUsageLimits({ checkedAt, windows }),
109 credentialFingerprint,
110 };
111 }).pipe(
112 Effect.timeout("5 seconds"),
113 Effect.orElseSucceed(() =>
114 makeUnavailableUsageLimits({
115 checkedAt,
116 reason: "probeFailed",
117 message: "OpenCode Go could not read usage.",
118 }),
119 ),
120 );
121});
packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts created+151
...@@ -0,0 +1,151 @@
1/**
2 * Live contract check for `@opencode/client` against a real OpenCode 2 server.
3 * It re-proves that OpenCode's schemas decode under the workspace Effect
4 * version, which the pnpm overrides force. Run it after bumping either side:
5 *
6 * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Client.live.test.ts
7 *
8 * The turn uses the free `opencode/big-pickle` model.
9 */
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect";
12import { assert, it } from "@effect/vitest";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Base64Url from "effect/encoding/Base64Url";
16import * as Fiber from "effect/Fiber";
17import * as FileSystem from "effect/FileSystem";
18import * as Filter from "effect/Filter";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as Stream from "effect/Stream";
23import { FetchHttpClient } from "effect/http";
24import { ChildProcess, ChildProcessSpawner } from "effect/process";
25import { describe } from "vite-plus/test";
26
27import * as OpenCode2Client from "./OpenCode2Client.ts";
28
29const binaryPath = process.env.OPENCODE2_BIN;
30
31const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (binary: string) {
32 const fs = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;
34 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
35 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-live-" });
36 const directory = path.join(root, "work");
37 yield* fs.makeDirectory(directory);
38 // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8.
39 const crypto = yield* Crypto.Crypto;
40 const password = `${Base64Url.encode(yield* crypto.randomBytes(32))}-pässwörd€`;
41 const child = yield* spawner.spawn(
42 ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], {
43 cwd: directory,
44 env: {
45 PATH: process.env.PATH,
46 HOME: root,
47 XDG_CONFIG_HOME: path.join(root, "config"),
48 XDG_DATA_HOME: path.join(root, "data"),
49 XDG_STATE_HOME: path.join(root, "state"),
50 XDG_CACHE_HOME: path.join(root, "cache"),
51 OPENCODE_PASSWORD: password,
52 },
53 }),
54 );
55 // Stop by the PID captured at spawn, before the temp directory is removed.
56 yield* Effect.addFinalizer(() =>
57 Effect.sync(() => {
58 try {
59 process.kill(Number(child.pid), "SIGTERM");
60 } catch {
61 // Already exited.
62 }
63 }).pipe(Effect.andThen(child.exitCode), Effect.timeout("10 seconds"), Effect.ignore),
64 );
65 const baseUrl = yield* child.stdout.pipe(
66 Stream.decodeText(),
67 Stream.splitLines,
68 Stream.filterMap(
69 Filter.fromPredicateOption((line: string) =>
70 Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]),
71 ),
72 ),
73 Stream.runHead,
74 Effect.flatMap(Effect.fromOption),
75 Effect.timeout("30 seconds"),
76 );
77 return { baseUrl, password, directory, pid: Number(child.pid) };
78});
79
80describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => {
81 it.live(
82 "reads server info, runs one turn, and pages the history",
83 () =>
84 Effect.gen(function* () {
85 const server = yield* startServer(binaryPath!);
86 const opencode = yield* OpenCode2Client.OpenCode2Client;
87 const { client, events } = yield* opencode.connect({
88 baseUrl: server.baseUrl,
89 password: server.password,
90 });
91
92 const info = yield* client.server.info();
93 assert.strictEqual(info.version, "2.0.23");
94 assert.strictEqual(info.pid, server.pid);
95
96 const session = yield* client.session.create({
97 title: "t3 client live check",
98 location: Location.PublicRef.make({ directory: AbsolutePath.make(server.directory) }),
99 model: Model.Ref.make({
100 providerID: Provider.ID.make("opencode"),
101 id: Model.ID.make("big-pickle"),
102 }),
103 });
104 const finished = yield* (yield* events).pipe(
105 Stream.filter(
106 (event) =>
107 event.type !== "unreadable.execution.ended" &&
108 event.type !== "unreadable.execution.started" &&
109 (event.data as { readonly sessionID?: string }).sessionID === session.id &&
110 event.type.startsWith("session.execution.") &&
111 event.type !== "session.execution.started",
112 ),
113 Stream.runHead,
114 Effect.forkChild,
115 );
116 yield* client.session.prompt({
117 sessionID: session.id,
118 text: "Reply with exactly: OPENCODE2_CLIENT_OK",
119 });
120 const terminal = yield* Fiber.join(finished).pipe(
121 Effect.flatMap(Effect.fromOption),
122 Effect.timeout("120 seconds"),
123 );
124 assert.strictEqual(terminal.type, "session.execution.succeeded");
125
126 // One message per page makes every request after the first send only a cursor.
127 const history = yield* OpenCode2Client.paginate(
128 { sessionID: session.id, order: "asc", limit: 1 },
129 client.message.list,
130 ).pipe(Stream.runCollect);
131 assert.deepStrictEqual(
132 history.map((message) => message.type),
133 ["user", "assistant", "idle"],
134 );
135 const assistant = history[1];
136 assert(assistant?.type === "assistant");
137 const reply = assistant.content.flatMap((part) =>
138 part.type === "text" ? [part.text] : [],
139 );
140 assert.include(reply.join(""), "OPENCODE2_CLIENT_OK");
141 }).pipe(
142 Effect.scoped,
143 Effect.provide(
144 OpenCode2Client.layer.pipe(
145 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
146 ),
147 ),
148 ),
149 180_000,
150 );
151});
packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts created+48
...@@ -0,0 +1,48 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5import { HttpClient, HttpClientResponse } from "effect/http";
6import { describe } from "vite-plus/test";
7
8import * as OpenCode2Client from "./OpenCode2Client.ts";
9
10const INFO_BODY = '{"version":"2.0.18","pid":4242,"urls":[],"paths":{"tmp":"/tmp"}}';
11
12/** Sends one request through the client and returns the Authorization header it carried. */
13const authorizationFor = (password: string | Redacted.Redacted) => {
14 const seen: Array<string | undefined> = [];
15 const layerCapturing = Layer.succeed(
16 HttpClient.HttpClient,
17 HttpClient.make((request) =>
18 Effect.sync(() => {
19 seen.push(request.headers.authorization);
20 return HttpClientResponse.fromWeb(
21 request,
22 new Response(INFO_BODY, { headers: { "content-type": "application/json" } }),
23 );
24 }),
25 ),
26 );
27 return Effect.gen(function* () {
28 const opencode = yield* OpenCode2Client.OpenCode2Client;
29 const { client } = yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password });
30 yield* client.server.info();
31 return seen[0];
32 }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerCapturing))));
33};
34
35const utf8Basic = (password: string) =>
36 `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
37
38describe("OpenCode2Client authorization", () => {
39 // OpenCode decodes Basic credentials as UTF-8. Latin-1 (`btoa`) encoding gets
40 // `pässwörd` rejected with a 401 and throws on characters such as `€`.
41 it.effect("encodes non-ASCII passwords as UTF-8", () =>
42 Effect.gen(function* () {
43 assert.strictEqual(yield* authorizationFor("pässwörd"), utf8Basic("pässwörd"));
44 assert.strictEqual(yield* authorizationFor("pass€word"), utf8Basic("pass€word"));
45 assert.strictEqual(yield* authorizationFor(Redacted.make("pässwörd")), utf8Basic("pässwörd"));
46 }),
47 );
48});
packages/provider-opencode/src/server/v2/OpenCode2Client.ts created+223
...@@ -0,0 +1,223 @@
1import { OpenCode, type OpenCodeClient } from "@opencode/client/effect";
2import { OpenCodeEvent } from "@opencode/protocol/groups/event";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Redacted from "effect/Redacted";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10import * as Stream from "effect/Stream";
11import * as Sse from "effect/encoding/Sse";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientError from "effect/http/HttpClientError";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import * as HttpClientResponse from "effect/http/HttpClientResponse";
16
17/** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */
18const OPENCODE_USERNAME = "opencode";
19
20/**
21 * A session's execution ended, but this build could not decode the event (a
22 * newer server added a field or a value). Only the type and session survive,
23 * so the turn still ends.
24 */
25export interface OpenCode2UnreadableTerminal {
26 readonly type: "unreadable.execution.ended";
27 readonly executionType: (typeof EXECUTION_ENDS)[number];
28 readonly sessionID: string;
29}
30
31/**
32 * A session's execution started, but this build could not decode the event.
33 * It marks where a new execution begins and never ends a turn.
34 */
35export interface OpenCode2UnreadableStart {
36 readonly type: "unreadable.execution.started";
37 readonly sessionID: string;
38}
39
40/** The events that end an execution; any other undecodable event is skipped. */
41const EXECUTION_ENDS = [
42 "session.execution.succeeded",
43 "session.execution.failed",
44 "session.execution.interrupted",
45] as const;
46const isExecutionEnd = (type: string): type is (typeof EXECUTION_ENDS)[number] =>
47 (EXECUTION_ENDS as ReadonlyArray<string>).includes(type);
48
49/** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */
50export class OpenCode2SilentStreamError extends Schema.TaggedError<OpenCode2SilentStreamError>()(
51 "OpenCode2SilentStreamError",
52 {},
53) {
54 override get message(): string {
55 return "The OpenCode server stopped sending events.";
56 }
57}
58
59export type OpenCode2StreamEvent =
60 | OpenCodeEvent
61 | OpenCode2UnreadableTerminal
62 | OpenCode2UnreadableStart;
63
64/**
65 * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The
66 * client's own `event.subscribe` fails the whole stream on the first event a
67 * newer server adds, so `events` decodes each frame on its own and skips the
68 * ones this build does not know, except an execution's end or start, which it
69 * keeps as an {@link OpenCode2UnreadableTerminal} or {@link OpenCode2UnreadableStart}.
70 * `events` succeeds once the server accepted the subscription: the stream is
71 * volatile, so callers subscribe before they start work whose events they need.
72 */
73export interface OpenCode2Api {
74 readonly client: OpenCodeClient;
75 readonly events: Effect.Effect<
76 Stream.Stream<
77 OpenCode2StreamEvent,
78 HttpClientError.HttpClientError | Sse.Retry | Sse.SseError | OpenCode2SilentStreamError
79 >,
80 HttpClientError.HttpClientError
81 >;
82}
83
84/** Builds clients for OpenCode 2 servers, one per base URL and password. */
85export class OpenCode2Client extends Context.Service<
86 OpenCode2Client,
87 {
88 readonly connect: (input: {
89 readonly baseUrl: string;
90 readonly password: string | Redacted.Redacted;
91 }) => Effect.Effect<OpenCode2Api>;
92 }
93>()("@t3tools/provider-opencode/server/v2/OpenCode2Client") {}
94
95/**
96 * OpenCode writes a `: heartbeat` comment every 10 to 15 seconds, so this long
97 * without a single byte means the server is stuck, not idle.
98 */
99const SILENT_STREAM_TIMEOUT = "45 seconds";
100
101const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent));
102// Just enough of an event to route it, for frames the full schema rejects.
103const decodeEnvelope = Schema.decodeUnknownOption(
104 Schema.fromJsonString(
105 Schema.Struct({
106 type: Schema.String,
107 data: Schema.optional(Schema.Struct({ sessionID: Schema.optional(Schema.String) })),
108 }),
109 ),
110);
111
112const undecodable = (data: string) =>
113 Effect.gen(function* () {
114 const envelope = decodeEnvelope(data);
115 const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : "<unreadable>";
116 const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined;
117 if (isExecutionEnd(type) && sessionID !== undefined) {
118 yield* Effect.logWarning(
119 "Ended an OpenCode execution from an event this build cannot decode.",
120 {
121 type,
122 },
123 );
124 return Result.succeed<OpenCode2StreamEvent>({
125 type: "unreadable.execution.ended",
126 executionType: type,
127 sessionID,
128 });
129 }
130 if (type === "session.execution.started" && sessionID !== undefined) {
131 yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode.");
132 return Result.succeed<OpenCode2StreamEvent>({
133 type: "unreadable.execution.started",
134 sessionID,
135 });
136 }
137 yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type });
138 return Result.failVoid;
139 });
140
141/** Subscribes to `/api/event`, then streams every frame this build can route. */
142const readEvents = (httpClient: HttpClient.HttpClient) =>
143 httpClient.get("/api/event", { headers: { accept: "text/event-stream" } }).pipe(
144 Effect.flatMap(HttpClientResponse.filterStatusOk),
145 Effect.map((response) =>
146 response.stream.pipe(
147 Stream.timeoutOrElse({
148 duration: SILENT_STREAM_TIMEOUT,
149 orElse: () => Stream.fail(new OpenCode2SilentStreamError()),
150 }),
151 Stream.decodeText,
152 Stream.pipeThroughChannel(Sse.decode()),
153 Stream.filterMapEffect((frame) => {
154 const decoded = decodeEvent(frame.data);
155 return Result.isSuccess(decoded)
156 ? Effect.succeed(Result.succeed<OpenCode2StreamEvent>(decoded.success))
157 : undecodable(frame.data);
158 }),
159 ),
160 ),
161 );
162
163/**
164 * OpenCode decodes Basic credentials as UTF-8. `HttpClientRequest.basicAuth`
165 * encodes them as Latin-1 (`btoa`), which gets non-ASCII passwords rejected.
166 */
167const basicAuthorization = (password: string | Redacted.Redacted) => {
168 const plain = Redacted.isRedacted(password) ? Redacted.value(password) : password;
169 return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`;
170};
171
172/** @public Service construction is part of the canonical Effect module API. */
173export const make = Effect.gen(function* () {
174 const httpClient = yield* HttpClient.HttpClient;
175 return OpenCode2Client.of({
176 connect: ({ baseUrl, password }) => {
177 const authenticated = HttpClient.mapRequest(
178 httpClient,
179 HttpClientRequest.setHeader("Authorization", basicAuthorization(password)),
180 );
181 return OpenCode.make({ baseUrl }).pipe(
182 Effect.provideService(HttpClient.HttpClient, authenticated),
183 Effect.map((client) => ({
184 client,
185 events: readEvents(
186 HttpClient.mapRequest(authenticated, HttpClientRequest.prependUrl(baseUrl)),
187 ),
188 })),
189 );
190 },
191 });
192});
193
194export const layer = Layer.effect(OpenCode2Client, make);
195
196/**
197 * Streams every item of a cursor-paged OpenCode 2 list. The first request
198 * carries the caller's input (including `order`); later requests send only
199 * the cursor, because OpenCode rejects a cursor combined with `order`.
200 */
201export const paginate = <Input extends { readonly cursor?: unknown }, Item, E, R>(
202 input: Input,
203 list: (
204 input: Input,
205 ) => Effect.Effect<
206 { readonly data: ReadonlyArray<Item>; readonly cursor: { readonly next?: Input["cursor"] } },
207 E,
208 R
209 >,
210): Stream.Stream<Item, E, R> =>
211 Stream.paginate(input, (request) =>
212 list(request).pipe(
213 Effect.map(
214 (page) =>
215 [
216 page.data,
217 page.data.length === 0 || page.cursor.next === undefined
218 ? Option.none()
219 : Option.some({ ...request, order: undefined, cursor: page.cursor.next }),
220 ] as const,
221 ),
222 ),
223 );
packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts created+238
...@@ -0,0 +1,238 @@
1/**
2 * Live check of the OpenCode 2 server lifecycle against a real binary:
3 *
4 * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Server.live.test.ts
5 *
6 * No model is called. The server runs with isolated HOME and XDG directories.
7 */
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { AbsolutePath, Location } from "@opencode/client/effect";
10import { assert, it } from "@effect/vitest";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as FileSystem from "effect/FileSystem";
15import * as Filter from "effect/Filter";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as Redacted from "effect/Redacted";
20import * as Scope from "effect/Scope";
21import * as Stream from "effect/Stream";
22import { FetchHttpClient } from "effect/http";
23import { ChildProcess, ChildProcessSpawner } from "effect/process";
24import { describe } from "vite-plus/test";
25
26import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
27import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts";
28import * as OpenCode2Client from "./OpenCode2Client.ts";
29import * as OpenCode2Server from "./OpenCode2Server.ts";
30
31const binaryPath = process.env.OPENCODE2_BIN;
32
33// A negative pid probes the whole process group, which T3 stops as one unit.
34const isAlive = (pid: number) => {
35 try {
36 process.kill(pid, 0);
37 return true;
38 } catch {
39 return false;
40 }
41};
42
43/**
44 * Stops a server the test spawned itself: SIGTERM, then SIGKILL if it is still
45 * alive after five seconds. Keyed on the PID captured at spawn, never on a
46 * pattern. Servers T3 spawns are stopped by T3, through the instance scope.
47 */
48const stopByPid = (pid: number) =>
49 Effect.gen(function* () {
50 const signal = (name: NodeJS.Signals) => {
51 try {
52 process.kill(pid, name);
53 } catch {
54 // Already exited.
55 }
56 };
57 signal("SIGTERM");
58 for (let attempt = 0; attempt < 50 && isAlive(pid); attempt++) {
59 yield* Effect.sleep("100 millis");
60 }
61 if (isAlive(pid)) signal("SIGKILL");
62 });
63
64/**
65 * Starts `opencode serve` the way a user would run it themselves. It is stopped
66 * by its PID when the calling scope closes, whether or not the test passed.
67 */
68const startExternalServer = Effect.fn("OpenCode2ServerLive.startExternalServer")(function* (input: {
69 readonly environment: NodeJS.ProcessEnv;
70 readonly directory: string;
71}) {
72 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
73 const child = yield* spawner.spawn(
74 ChildProcess.make(binaryPath!, ["serve", "--hostname=127.0.0.1", "--port=0"], {
75 cwd: input.directory,
76 env: input.environment,
77 }),
78 );
79 const pid = Number(child.pid);
80 yield* Effect.addFinalizer(() => stopByPid(pid));
81 const url = yield* child.stdout.pipe(
82 Stream.decodeText(),
83 Stream.splitLines,
84 Stream.filterMap(
85 Filter.fromPredicateOption((line: string) =>
86 Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]),
87 ),
88 ),
89 Stream.runHead,
90 Effect.flatMap(Effect.fromOption),
91 Effect.timeout("30 seconds"),
92 );
93 return { url, pid };
94});
95
96describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => {
97 it.live(
98 "spawns one authenticated server per instance and stops it by PID",
99 () =>
100 Effect.gen(function* () {
101 const fs = yield* FileSystem.FileSystem;
102 const path = yield* Path.Path;
103 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-server-live-" });
104 const first = path.join(root, "first");
105 const second = path.join(root, "second");
106 yield* fs.makeDirectory(first);
107 yield* fs.makeDirectory(second);
108 const environment = {
109 PATH: process.env.PATH,
110 HOME: root,
111 XDG_CONFIG_HOME: path.join(root, "config"),
112 XDG_DATA_HOME: path.join(root, "data"),
113 XDG_STATE_HOME: path.join(root, "state"),
114 XDG_CACHE_HOME: path.join(root, "cache"),
115 // Ignored: the T3 password is the only one the server sees.
116 OPENCODE_SERVER_PASSWORD: "ambient-password",
117 };
118
119 // The instance owns its server: building the layer spawns nothing, and
120 // closing its scope stops whatever it spawned. The scope is released
121 // even when an assertion below fails; it is closed early on purpose.
122 const instanceScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
123 Scope.close(scope, Exit.void),
124 );
125 const server = Context.get(
126 yield* Layer.buildWithScope(
127 OpenCode2Server.layer({
128 binaryPath: binaryPath!,
129 serverUrl: "",
130 serverPassword: "",
131 directory: first,
132 environment,
133 }),
134 instanceScope,
135 ),
136 OpenCode2Server.OpenCode2Server,
137 );
138
139 const spawned = yield* server.withConnection((connection) =>
140 Effect.gen(function* () {
141 const info = yield* connection.client.server.info();
142 const session = yield* connection.client.session.create({
143 title: "first location",
144 location: Location.PublicRef.make({ directory: AbsolutePath.make(first) }),
145 });
146 return { ...connection, pid: info.pid, session };
147 }),
148 );
149 assert.strictEqual(spawned.version, "2.0.18");
150 assert.isFalse(spawned.external);
151 assert.isTrue(isAlive(spawned.pid));
152
153 // A second location is served by the same process.
154 const secondLocation = yield* server.withConnection((connection) =>
155 Effect.gen(function* () {
156 const info = yield* connection.client.server.info();
157 const session = yield* connection.client.session.create({
158 title: "second location",
159 location: Location.PublicRef.make({ directory: AbsolutePath.make(second) }),
160 });
161 return { url: connection.url, pid: info.pid, session };
162 }),
163 );
164 assert.strictEqual(secondLocation.url, spawned.url);
165 assert.strictEqual(secondLocation.pid, spawned.pid);
166 assert.strictEqual(secondLocation.session.location.directory, second);
167 assert.strictEqual(spawned.session.location.directory, first);
168
169 // External URL: the configured password works, a wrong one is a 401.
170 const external = yield* OpenCode2Server.make({
171 binaryPath: binaryPath!,
172 serverUrl: spawned.url,
173 serverPassword: "wrong-password",
174 directory: first,
175 environment,
176 });
177 const rejected = yield* external.withConnection(() => Effect.void).pipe(Effect.flip);
178 assert.include(rejected.detail, "rejected the server password");
179 const ambient = yield* OpenCode2Server.make({
180 binaryPath: binaryPath!,
181 serverUrl: spawned.url,
182 serverPassword: environment.OPENCODE_SERVER_PASSWORD,
183 directory: first,
184 environment,
185 });
186 const ambientRejected = yield* ambient.withConnection(() => Effect.void).pipe(Effect.flip);
187 assert.include(ambientRejected.detail, "rejected the server password");
188
189 // An external server whose password is not ASCII: OpenCode decodes Basic
190 // credentials as UTF-8, so the configured password must be sent that way.
191 const utf8Password = "pässwörd€";
192 const externalServer = yield* startExternalServer({
193 environment: OpenCode2Server.serverEnvironment(environment, Redacted.make(utf8Password)),
194 directory: second,
195 });
196 const utf8 = yield* OpenCode2Server.make({
197 binaryPath: binaryPath!,
198 serverUrl: externalServer.url,
199 serverPassword: utf8Password,
200 directory: second,
201 environment,
202 });
203 const utf8Connection = yield* utf8.withConnection((connection) =>
204 Effect.succeed(connection),
205 );
206 assert.strictEqual(utf8Connection.version, "2.0.18");
207 assert.isTrue(utf8Connection.external);
208 const asciiOnly = yield* OpenCode2Server.make({
209 binaryPath: binaryPath!,
210 serverUrl: externalServer.url,
211 serverPassword: "passwrd",
212 directory: second,
213 environment,
214 });
215 const asciiRejected = yield* asciiOnly.withConnection(() => Effect.void).pipe(Effect.flip);
216 assert.include(asciiRejected.detail, "rejected the server password");
217
218 // Closing the instance stops the server it spawned.
219 yield* Scope.close(instanceScope, Exit.void);
220 for (let attempt = 0; attempt < 50 && isAlive(-spawned.pid); attempt++) {
221 yield* Effect.sleep("100 millis");
222 }
223 assert.isFalse(isAlive(spawned.pid), `opencode serve ${spawned.pid} outlived its instance`);
224 assert.isFalse(isAlive(-spawned.pid), `a process in group ${spawned.pid} outlived it`);
225 const unreachable = yield* external.withConnection(() => Effect.void).pipe(Effect.flip);
226 assert.include(unreachable.detail, "Could not reach the OpenCode server");
227 }).pipe(
228 Effect.scoped,
229 Effect.provide(
230 Layer.mergeAll(
231 OpenCode2Client.layer,
232 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
233 ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))),
234 ),
235 ),
236 60_000,
237 );
238});
packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts created+259
...@@ -0,0 +1,259 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 HostProcessEnvironment,
5 HostProcessExecutablePath,
6 HostProcessPlatform,
7} from "@t3tools/shared/hostProcess";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Redacted from "effect/Redacted";
14import * as TestClock from "effect/testing/TestClock";
15import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
16import { describe } from "vite-plus/test";
17
18import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
19import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts";
20import * as OpenCode2Client from "./OpenCode2Client.ts";
21import * as OpenCode2Server from "./OpenCode2Server.ts";
22
23// Shapes and content types observed on `opencode serve` 2.0.18 (pid and port replaced). 1.x
24// answers /api/info with its web UI: 200 text/html.
25const INFO_BODY =
26 '{"version":"2.0.18","pid":4242,"urls":["http://127.0.0.1:4096"],"paths":{"tmp":"/tmp/opencode"}}';
27const UNAUTHORIZED_BODY = '{"_tag":"UnauthorizedError","message":"Authentication required"}';
28const SPA_BODY = "<!doctype html><html><head><title>OpenCode</title></head></html>";
29
30const layerServerReplying = (reply: {
31 readonly status: number;
32 readonly contentType?: string;
33 readonly body: string;
34}) =>
35 Layer.succeed(
36 HttpClient.HttpClient,
37 HttpClient.make((request) =>
38 Effect.succeed(
39 HttpClientResponse.fromWeb(
40 request,
41 new Response(reply.body, {
42 status: reply.status,
43 headers: reply.contentType === undefined ? {} : { "content-type": reply.contentType },
44 }),
45 ),
46 ),
47 ),
48 );
49
50const verify = (httpClient: Layer.Layer<HttpClient.HttpClient>, url = "http://127.0.0.1:4096") =>
51 Effect.gen(function* () {
52 const opencode = yield* OpenCode2Client.OpenCode2Client;
53 const { client } = yield* opencode.connect({ baseUrl: url, password: "secret" });
54 return yield* OpenCode2Server.verifyServer(client);
55 }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(httpClient))));
56
57describe("OpenCode2Server.verifyServer", () => {
58 it.effect("returns the version of an authenticated OpenCode 2 server", () =>
59 Effect.gen(function* () {
60 const version = yield* verify(
61 layerServerReplying({ status: 200, contentType: "application/json", body: INFO_BODY }),
62 );
63 assert.strictEqual(version, "2.0.18");
64 }),
65 );
66
67 it.effect("reports a rejected password, not a wrong server", () =>
68 Effect.gen(function* () {
69 const error = yield* verify(
70 layerServerReplying({
71 status: 401,
72 contentType: "application/json",
73 body: UNAUTHORIZED_BODY,
74 }),
75 ).pipe(Effect.flip);
76 assert.include(error.detail, "rejected the server password");
77 }),
78 );
79
80 // 1.x rejects a wrong password with an empty 401, which the client cannot decode.
81 it.effect("reports an empty-body 401 as a rejected password", () =>
82 Effect.gen(function* () {
83 const error = yield* verify(layerServerReplying({ status: 401, body: "" })).pipe(Effect.flip);
84 assert.include(error.detail, "rejected the server password");
85 }),
86 );
87
88 it.effect("reports a server error as a server error, not as unreachable", () =>
89 Effect.gen(function* () {
90 for (const status of [500, 502]) {
91 const error = yield* verify(
92 layerServerReplying({ status, contentType: "text/plain", body: "upstream failed" }),
93 ).pipe(Effect.flip);
94 assert.include(error.detail, `returned HTTP ${status}`);
95 }
96 }),
97 );
98
99 // 1.x (and the 2.x web UI on unknown paths) answers /api/info with 200 HTML.
100 it.effect("rejects a server that answers with the web UI's HTML", () =>
101 Effect.gen(function* () {
102 const error = yield* verify(
103 layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }),
104 ).pipe(Effect.flip);
105 assert.include(error.detail, "is not an OpenCode 2 server");
106 }),
107 );
108
109 it.effect("reports an unreachable server as unreachable", () =>
110 Effect.gen(function* () {
111 const error = yield* verify(FetchHttpClient.layer, "http://127.0.0.1:1").pipe(Effect.flip);
112 assert.include(error.detail, "Could not reach the OpenCode server");
113 }),
114 );
115});
116
117describe("OpenCode2Server error details", () => {
118 // `detail` reaches clients through the provider status message, and a
119 // `serverUrl` can carry credentials in its userinfo or query.
120 const serverUrl = "http://user:url-secret@127.0.0.1:1/?token=query-secret";
121 const detailFor = (httpClient: Layer.Layer<HttpClient.HttpClient>) =>
122 Effect.gen(function* () {
123 const server = yield* OpenCode2Server.make({
124 binaryPath: "opencode",
125 serverUrl,
126 serverPassword: "secret",
127 directory: "/project",
128 environment: {},
129 });
130 const error = yield* server.withConnection(() => Effect.void).pipe(Effect.flip);
131 return error.detail;
132 }).pipe(
133 Effect.provide(
134 Layer.mergeAll(
135 OpenCode2Client.layer.pipe(Layer.provide(httpClient)),
136 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
137 ).pipe(Layer.provideMerge(NodeServices.layer)),
138 ),
139 );
140
141 it.effect("never include the server URL", () =>
142 Effect.gen(function* () {
143 const layerHanging = Layer.succeed(
144 HttpClient.HttpClient,
145 HttpClient.make(() => Effect.never),
146 );
147 const timedOut = yield* detailFor(layerHanging).pipe(Effect.forkChild);
148 yield* TestClock.adjust("10 seconds");
149 const details = [
150 yield* detailFor(FetchHttpClient.layer),
151 yield* detailFor(layerServerReplying({ status: 401, body: "" })),
152 yield* detailFor(layerServerReplying({ status: 502, contentType: "text/plain", body: "" })),
153 yield* detailFor(
154 layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }),
155 ),
156 yield* Fiber.join(timedOut),
157 ];
158 assert.deepStrictEqual(details, [
159 "Could not reach the OpenCode server.",
160 "The OpenCode server rejected the server password.",
161 "The OpenCode server returned HTTP 502.",
162 "The server is not an OpenCode 2 server.",
163 "Timed out waiting for the OpenCode server.",
164 ]);
165 for (const detail of details) {
166 assert.notInclude(detail, "url-secret");
167 assert.notInclude(detail, "query-secret");
168 assert.notInclude(detail, "127.0.0.1");
169 }
170 }).pipe(Effect.provide(TestClock.layer())),
171 );
172});
173
174describe("OpenCode2Server passwords", () => {
175 it.effect("generates a distinct 256-bit password per server", () =>
176 Effect.gen(function* () {
177 const first = yield* OpenCode2Server.generatePassword;
178 const second = yield* OpenCode2Server.generatePassword;
179 assert.match(Redacted.value(first), /^[A-Za-z0-9_-]{43}$/);
180 assert.notStrictEqual(Redacted.value(first), Redacted.value(second));
181 assert.notInclude(String(first), Redacted.value(first));
182 }).pipe(Effect.provide(NodeServices.layer)),
183 );
184
185 it("hands the spawned server only the T3 password", () => {
186 const password = Redacted.make("t3-generated");
187 const environment = OpenCode2Server.serverEnvironment(
188 { PATH: "/bin", OPENCODE_SERVER_PASSWORD: "ambient", OPENCODE_PASSWORD: "ambient" },
189 password,
190 );
191 assert.deepStrictEqual(environment, { PATH: "/bin", OPENCODE_PASSWORD: "t3-generated" });
192 });
193});
194
195// Serves /api/info only with the password from OPENCODE_PASSWORD, like 2.x, and
196// prints the 2.x banner (plus the generated-password line 2.x prints when no
197// password is set, which T3 must never need).
198const FAKE_SERVER = `import { createServer } from "node:http";
199const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64");
200const server = createServer((request, response) => {
201 if (request.headers.authorization !== expected) {
202 response.writeHead(401, { "content-type": "application/json", "www-authenticate": "Basic" });
203 response.end(${JSON.stringify(UNAUTHORIZED_BODY)});
204 return;
205 }
206 response.writeHead(200, { "content-type": "application/json" });
207 response.end(JSON.stringify({ version: "2.0.18", pid: process.pid, urls: [], paths: { tmp: "/tmp" } }));
208});
209server.listen(0, "127.0.0.1", () => {
210 process.stdout.write("server listening on http://127.0.0.1:" + server.address().port + "\\n");
211 process.stdout.write("server password not-the-t3-password\\n");
212});
213`;
214
215describe("OpenCode2Server spawned server", () => {
216 it.live(
217 "is ready once /api/info accepts the generated password",
218 () =>
219 Effect.gen(function* () {
220 const fs = yield* FileSystem.FileSystem;
221 const path = yield* Path.Path;
222 const platform = yield* HostProcessPlatform;
223 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-fake-" });
224 const isWindows = platform === "win32";
225 const binaryPath = path.join(directory, isWindows ? "opencode.cmd" : "opencode");
226 const scriptPath = path.join(directory, "opencode.mjs");
227 yield* fs.writeFileString(scriptPath, FAKE_SERVER);
228 yield* fs.writeFileString(
229 binaryPath,
230 isWindows
231 ? `@echo off\r\n"${yield* HostProcessExecutablePath}" "${scriptPath}" %*\r\n`
232 : `#!/bin/sh\nexec "${yield* HostProcessExecutablePath}" "${scriptPath}" "$@"\n`,
233 );
234 if (!isWindows) yield* fs.chmod(binaryPath, 0o755);
235
236 const server = yield* OpenCode2Server.make({
237 binaryPath,
238 serverUrl: "",
239 serverPassword: "",
240 directory,
241 environment: { ...(yield* HostProcessEnvironment), OPENCODE_SERVER_PASSWORD: "ambient" },
242 });
243 const first = yield* server.withConnection((connection) => Effect.succeed(connection));
244 const second = yield* server.withConnection((connection) => Effect.succeed(connection));
245 assert.strictEqual(first.version, "2.0.18");
246 assert.strictEqual(first.external, false);
247 assert.strictEqual(second.client, first.client);
248 }).pipe(
249 Effect.scoped,
250 Effect.provide(
251 Layer.mergeAll(
252 OpenCode2Client.layer,
253 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
254 ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))),
255 ),
256 ),
257 15_000,
258 );
259});
packages/provider-opencode/src/server/v2/OpenCode2Server.ts created+198
...@@ -0,0 +1,198 @@
1/**
2 * The OpenCode 2 server behind one provider instance. A 2.x server serves every
3 * location from one process, so an instance shares one server across all of its
4 * threads and directories.
5 *
6 * @module provider/opencode2/OpenCode2Server
7 */
8import type { OpenCodeClient } from "@opencode/client/effect";
9import * as Context from "effect/Context";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as Base64Url from "effect/encoding/Base64Url";
13import * as Layer from "effect/Layer";
14import * as P from "effect/Predicate";
15import * as Redacted from "effect/Redacted";
16import * as HttpClientError from "effect/http/HttpClientError";
17
18import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
19import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
20import * as OpenCode2Client from "./OpenCode2Client.ts";
21
22const INFO_TIMEOUT = "5 seconds";
23
24export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api {
25 readonly url: string;
26 readonly version: string;
27 readonly external: boolean;
28}
29
30export class OpenCode2Server extends Context.Service<
31 OpenCode2Server,
32 {
33 /** Runs `use` against the instance's server, spawning it first when T3 owns it. */
34 readonly withConnection: <A, E, R>(
35 use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>,
36 ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
37 }
38>()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {}
39
40/**
41 * A fresh password for a spawned server. OpenCode 2 always requires one and
42 * prints a generated one to stdout otherwise, so T3 supplies its own and keeps
43 * it in memory.
44 */
45export const generatePassword = Effect.gen(function* () {
46 const crypto = yield* Crypto.Crypto;
47 const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie);
48 return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" });
49});
50
51/**
52 * The environment for a spawned 2.x server. `OPENCODE_PASSWORD` wins over
53 * `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so the inherited 1.x variable is
54 * dropped to keep the T3 password the only one in play.
55 */
56export const serverEnvironment = (
57 environment: NodeJS.ProcessEnv,
58 password: Redacted.Redacted,
59): NodeJS.ProcessEnv => {
60 const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment;
61 return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) };
62};
63
64/** The client wraps HTTP failures in a `ClientError`; this unwraps either shape. */
65const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => {
66 if (HttpClientError.isHttpClientError(cause)) return cause;
67 if (
68 P.isTagged(cause, "ClientError") &&
69 P.hasProperty(cause, "cause") &&
70 HttpClientError.isHttpClientError(cause.cause)
71 ) {
72 return cause.cause;
73 }
74 return undefined;
75};
76
77/**
78 * Describes a failed `/api/info` call. Only a transport failure means the
79 * server is unreachable; a 401 without the 2.x error body is still a rejected
80 * password (1.x sends it empty), and any other failed status is a server error.
81 * A 2xx the client cannot decode is not OpenCode 2 (1.x answers with HTML).
82 */
83const describeInfoFailure = (cause: unknown) => {
84 const failure = httpFailureOf(cause);
85 if (failure?.reason._tag === "TransportError") {
86 return "Could not reach the OpenCode server.";
87 }
88 const status = failure?.response?.status;
89 if (status === 401) return "The OpenCode server rejected the server password.";
90 if (status !== undefined && (status < 200 || status >= 300)) {
91 return `The OpenCode server returned HTTP ${status}.`;
92 }
93 return "The server is not an OpenCode 2 server.";
94};
95
96/**
97 * Confirms a server is an authenticated OpenCode 2 server through `/api/info`
98 * and returns its version. `/health` and friends answer 200 with the web UI's
99 * HTML on every version, so only this endpoint proves readiness. Details are
100 * fixed text because they reach clients and a `serverUrl` can carry
101 * credentials; the underlying failure stays in `cause`.
102 */
103export const verifyServer = (client: OpenCodeClient) =>
104 client.server.info().pipe(
105 Effect.timeoutOrElse({
106 duration: INFO_TIMEOUT,
107 orElse: () =>
108 Effect.fail(
109 new OpenCodeRuntime.OpenCodeRuntimeError({
110 operation: "server.info",
111 detail: "Timed out waiting for the OpenCode server.",
112 }),
113 ),
114 }),
115 Effect.catchTags({
116 UnauthorizedError: (cause) =>
117 Effect.fail(
118 new OpenCodeRuntime.OpenCodeRuntimeError({
119 operation: "server.info",
120 detail: "The OpenCode server rejected the server password.",
121 cause,
122 }),
123 ),
124 }),
125 Effect.mapError((cause) =>
126 OpenCodeRuntime.OpenCodeRuntimeError.is(cause)
127 ? cause
128 : new OpenCodeRuntime.OpenCodeRuntimeError({
129 operation: "server.info",
130 detail: describeInfoFailure(cause),
131 cause,
132 }),
133 ),
134 Effect.map((info) => info.version),
135 );
136
137/**
138 * One server per provider instance. With a `serverUrl` it connects to that
139 * server with the configured password; otherwise it spawns `binaryPath serve`
140 * with a generated password through {@link OpenCodeServerOwner}, which shares
141 * the process between borrowers and stops it after an idle period. Clients are
142 * built once per server; a failed check is not remembered.
143 */
144export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
145 readonly binaryPath: string;
146 readonly serverUrl: string;
147 readonly serverPassword: string;
148 readonly directory: string;
149 readonly environment: NodeJS.ProcessEnv;
150}) {
151 const opencode = yield* OpenCode2Client.OpenCode2Client;
152 const connectTo = (url: string, password: Redacted.Redacted, external: boolean) =>
153 Effect.gen(function* () {
154 const api = yield* opencode.connect({ baseUrl: url, password });
155 const version = yield* verifyServer(api.client);
156 return { ...api, url, version, external } satisfies OpenCode2Connection;
157 });
158 let latest: OpenCode2Connection | undefined;
159 const remember = (connection: OpenCode2Connection) =>
160 Effect.sync(() => {
161 latest = connection;
162 return connection.version;
163 });
164
165 const serverUrl = input.serverUrl.trim();
166 if (serverUrl.length > 0) {
167 const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe(
168 Effect.tap(remember),
169 );
170 return OpenCode2Server.of({
171 withConnection: (use) =>
172 Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe(
173 Effect.flatMap(use),
174 ),
175 });
176 }
177
178 const password = yield* generatePassword;
179 const owner = yield* OpenCodeServerOwner.make({
180 binaryPath: input.binaryPath,
181 directory: input.directory,
182 environment: serverEnvironment(input.environment, password),
183 verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)),
184 });
185 return OpenCode2Server.of({
186 withConnection: (use) =>
187 owner.withServer((server) =>
188 // The owner verifies every server it starts before lending it out.
189 latest?.url === server.url
190 ? use(latest)
191 : Effect.die(new Error("OpenCode 2 server was lent before verification.")),
192 ),
193 });
194});
195
196/** Built once per provider instance from its settings; closing it stops a spawned server. */
197export const layer = (input: Parameters<typeof make>[0]) =>
198 Layer.effect(OpenCode2Server, make(input));
packages/provider-opencode/src/server/v2/adapter.ts created+4290
...@@ -0,0 +1,4290 @@
1/**
2 * The OpenCode 2 runtime behind the `opencode` driver. It talks to the
3 * instance's `opencode serve` process through the HTTP client and reads that
4 * server's `/api/event` stream, routed here by session id.
5 *
6 * A turn is one `session.prompt` (or `session.command`, or `session.compact`
7 * for `/compact`); the session's next `session.execution.*` terminal ends it.
8 * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at
9 * the running execution's next step boundary, so it ends with the turn it
10 * joined. Fork and rollback cut the native history before the user message a
11 * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of
12 * session permission rules, plan mode is also OpenCode's `plan` agent
13 * (switched before the prompt like the model), and OpenCode's permission asks
14 * and question forms become runtime requests on the asking session's thread, a
15 * subagent's on its parent's.
16 *
17 * A `subagent` call runs in a child session shown as a subagent thread. A
18 * background one outlives its turn; when it ends, OpenCode starts a parent
19 * execution T3 did not ask for, which waits here for the continuation turn T3
20 * opens for it.
21 *
22 * @module orchestration-v2/Adapters/OpenCode2AdapterV2
23 */
24
25import {
26 AbsolutePath,
27 Agent,
28 Form,
29 Location,
30 Model,
31 Permission,
32 Provider,
33 Session,
34 SessionMessage,
35 Skill,
36 type OpenCodeEvent,
37} from "@opencode/client/effect";
38import { Mcp } from "@opencode/schema/mcp";
39import {
40 isOrchestrationV2WorkActive,
41 type OrchestrationV2AppThread,
42 type OrchestrationV2ConversationMessage,
43 type OrchestrationV2ExecutionNode,
44 type OrchestrationV2ProviderCapabilities,
45 type OrchestrationV2ProviderSession,
46 type OrchestrationV2ProviderThread,
47 type OrchestrationV2ProviderTurn,
48 type OrchestrationV2RuntimeRequest,
49 type OrchestrationV2Subagent,
50 type OrchestrationV2TurnItem,
51 type OrchestrationV2UserInputQuestion,
52 type ModelSelection,
53 type ProviderApprovalDecision,
54 type ProviderInstanceId,
55 type RunId,
56 type RuntimeRequestId,
57} from "@t3tools/contracts";
58import type * as Cause from "effect/Cause";
59import * as DateTime from "effect/DateTime";
60import * as Deferred from "effect/Deferred";
61import * as Crypto from "effect/Crypto";
62import * as Effect from "effect/Effect";
63import * as Hex from "effect/encoding/Hex";
64import * as Exit from "effect/Exit";
65import * as Option from "effect/Option";
66import * as Queue from "effect/Queue";
67import * as Schedule from "effect/Schedule";
68import * as Scope from "effect/Scope";
69import * as Schema from "effect/Schema";
70import * as Semaphore from "effect/Semaphore";
71import * as Stream from "effect/Stream";
72
73import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
74import * as OpenCode2Client from "./OpenCode2Client.ts";
75import * as OpenCode2Server from "./OpenCode2Server.ts";
76import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
77import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
78import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
79import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions";
80import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
81import * as KeyedLock from "@t3tools/shared/KeyedLock";
82import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
83import { causeErrorTag } from "@t3tools/shared/observability";
84
85import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt";
86import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
87import {
88 backgroundWorkNotification,
89 type BackgroundWorkReport,
90} from "@t3tools/provider-core/server/notification";
91import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
92import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
93import {
94 makeSubagentChildThread,
95 makeSubagentConversationArtifacts,
96 subagentThreadTitle,
97} from "@t3tools/provider-core/server/subagentProjection";
98import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
99import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
100import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "../adapter.ts";
101import { openCodeToolTurnItem } from "../toolItems.ts";
102
103const OpenCode2ProviderCapabilities = {
104 sessions: {
105 // One server serves every location, so one session runtime owns them all.
106 supportsMultipleProviderThreadsPerSession: true,
107 supportsModelSwitchInSession: true,
108 supportsProviderSwitchingViaHandoff: true,
109 // A mode change rewrites the session's rules when its next turn resumes it.
110 supportsRuntimeModeSwitchInSession: true,
111 pendingRequestsSurviveRestart: false,
112 },
113 threads: {
114 canCreateEmptyThread: true,
115 canReadThreadSnapshot: true,
116 canRollbackThread: true,
117 canForkThread: true,
118 canForkFromTurn: true,
119 canForkFromSubagentThread: false,
120 exposesNativeThreadId: true,
121 },
122 turns: {
123 exposesNativeTurnId: false,
124 emitsTurnStarted: true,
125 emitsTurnCompleted: true,
126 supportsInterrupt: true,
127 supportsActiveSteering: true,
128 supportsSteeringByInterruptRestart: true,
129 // T3 holds queued messages and starts each as its own turn once the one
130 // before it ends, so OpenCode's own `queue` delivery is never used.
131 supportsQueuedMessages: true,
132 terminalStatusQuality: "strong",
133 },
134 streaming: {
135 streamsAssistantText: true,
136 streamsReasoning: true,
137 streamsToolOutput: false,
138 streamsPlanText: false,
139 emitsMessageCompleted: true,
140 },
141 tools: {
142 exposesToolItemIds: true,
143 emitsToolStarted: true,
144 emitsToolCompleted: true,
145 emitsToolOutput: true,
146 supportsMcpTools: false,
147 supportsDynamicToolCallbacks: false,
148 },
149 approvals: {
150 supportsCommandApproval: true,
151 supportsFileReadApproval: true,
152 supportsFileChangeApproval: true,
153 supportsApplyPatchApproval: true,
154 approvalsHaveNativeRequestIds: true,
155 approvalCallbacksAreLiveOnly: true,
156 approvalsCanOriginateFromSubagents: true,
157 },
158 planning: {
159 emitsPlanUpdated: false,
160 emitsTodoList: false,
161 emitsProposedPlan: false,
162 supportsStructuredQuestions: true,
163 planDeltasHaveItemIds: false,
164 },
165 subagents: {
166 supportsSubagents: true,
167 exposesSubagentThreadIds: true,
168 emitsSubagentLifecycle: true,
169 canWaitForSubagents: true,
170 canCloseSubagents: false,
171 canForkSubagentThread: false,
172 },
173 context: {
174 acceptsSystemContext: false,
175 acceptsDeveloperContext: false,
176 acceptsSyntheticUserContext: true,
177 canGenerateSummaries: false,
178 canConsumeHandoffSummaries: true,
179 supportsDeltaHandoff: true,
180 supportsFullThreadHandoff: true,
181 maxRecommendedHandoffChars: null,
182 },
183 checkpointing: {
184 appCanCheckpointFilesystem: true,
185 supportsNestedCheckpointScopes: true,
186 providerCanRollbackConversation: true,
187 providerRollbackReturnsSnapshot: true,
188 providerCanReadConversationSnapshot: true,
189 },
190 identity: {
191 nativeThreadIds: "strong",
192 nativeTurnIds: "weak",
193 nativeItemIds: "strong",
194 nativeRequestIds: "strong",
195 },
196 // OpenCode enforces each runtime mode through the session's permission rules.
197 runtimePolicy: { enforcement: "native" },
198} satisfies OrchestrationV2ProviderCapabilities;
199
200type EventOf<T extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { readonly type: T }>;
201type Tokens = EventOf<"session.step.ended">["data"]["tokens"];
202
203/** What a turn reports against: a run's own turn, or a subagent session's runless one. */
204interface TurnOwner {
205 readonly appThread: OrchestrationV2AppThread;
206 readonly threadId: OrchestrationV2AppThread["id"];
207 readonly runId: RunId | null;
208 readonly runOrdinal: number;
209 readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"];
210 readonly modelSelection: ModelSelection;
211 /** Where the turn runs: a subagent runs in its parent turn's directory. */
212 readonly runtimePolicy: Pick<ProviderAdapter.ProviderAdapterV2RuntimePolicy, "cwd">;
213}
214
215interface ActiveTurn {
216 readonly input: TurnOwner;
217 providerTurn: OrchestrationV2ProviderTurn;
218 /** Open text and reasoning blocks, keyed `<assistantMessageID>:<kind>:<ordinal>`. */
219 readonly texts: Map<string, OpenBlock>;
220 readonly tools: Map<string, { readonly name: string; input: Record<string, unknown> }>;
221 readonly startedAt: Map<string, DateTime.Utc>;
222 readonly ordinals: Map<string, number>;
223 nextOrdinal: number;
224 /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */
225 readonly usage: {
226 input: number;
227 cached: number;
228 cacheWrite: number;
229 output: number;
230 reasoning: number;
231 };
232 steps: number;
233 lastStep: Tokens | undefined;
234 /**
235 * The history item this turn's execution follows, for a turn with no prompt
236 * id of T3's: the session's newest item before a `/name` command (null when
237 * the history was empty), since `session.command` takes no id and answers
238 * without one; the report a continuation turn's execution answers; or, on a
239 * subagent's session, the first item queued for its turn. A reconnect
240 * backfills that turn from everything after this. Other turns backfill from
241 * their own prompt's id (`nativeTurnRef`).
242 */
243 before: string | null | undefined;
244 /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */
245 compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined;
246 compactions: number;
247 interrupted: boolean;
248 /**
249 * Set until the turn's prompt, command or compaction is sent. A Stop before
250 * then has nothing on the server to stop, so it ends the turn here and the
251 * request is never sent.
252 */
253 unsent: boolean;
254 /**
255 * Steers sent into this turn that OpenCode has not delivered yet, by inbox
256 * id. Each one wakes the session, so an execution that ends before reading
257 * one is followed by another that does, and the turn spans both.
258 */
259 readonly steers: Set<string>;
260 /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */
261 readonly settledInbox: Set<string>;
262 /** How the last execution ended, while the turn waits for the one that delivers its steers. */
263 heldEnd: TurnTerminal | undefined;
264 /**
265 * Set on a turn started after a timed-out Stop's run left the server: that
266 * run's tail can still be on the stream, and everything before this turn's
267 * own `session.execution.started` belongs to it.
268 */
269 awaitingStart: boolean;
270 /**
271 * Prefixes a subagent session's tool ids in native item ids: the model
272 * names tool calls, so a child's could repeat its parent's.
273 */
274 readonly scope: string;
275 /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */
276 usedSubagents: boolean;
277}
278
279/** A `subagent` tool call. A background one outlives the turn that made it. */
280interface SubagentCall {
281 readonly toolId: string;
282 readonly nativeId: string;
283 /** The calling session and the turn whose item the call is. */
284 readonly state: ThreadState;
285 readonly turn: ActiveTurn;
286 readonly ordinal: number;
287 readonly startedAt: DateTime.Utc;
288 prompt: string;
289 title: string | null;
290 agent: string | undefined;
291 model: string | null;
292 /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */
293 background: boolean;
294 child: ThreadState | undefined;
295 status: OrchestrationV2Subagent["status"];
296 result: string | null;
297 completedAt: DateTime.Utc | null;
298}
299
300/**
301 * An execution OpenCode ran on a thread's session without T3 asking: the
302 * parent's answer once a background subagent ended. Its events are held until
303 * the continuation turn T3 opens for it takes them.
304 */
305interface Wake {
306 readonly events: Array<OpenCode2Client.OpenCode2StreamEvent>;
307 running: boolean;
308 /** The background subagents whose end it answers, as its turn's notification names them. */
309 readonly reports: Array<BackgroundWorkReport>;
310 /** What OpenCode told the model, the continuation's prompt text. */
311 readonly detail: string | null;
312 /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */
313 dropped: boolean;
314 /** The last report it delivered (inbox ids are history ids): its execution follows it. */
315 readonly after: string | undefined;
316 /** The first report it delivered, where fork and rollback cut before its turn. */
317 readonly first: string | undefined;
318}
319
320interface OpenBlock {
321 readonly block: { readonly assistantMessageID: string; readonly ordinal: number };
322 readonly kind: "text" | "reasoning";
323 readonly startedAt: DateTime.Utc;
324 text: string;
325}
326
327interface ThreadState {
328 readonly sessionId: string;
329 providerThread: OrchestrationV2ProviderThread;
330 readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>;
331 active: ActiveTurn | undefined;
332 /** What the native session runs now, so a changed selection is switched before prompting. */
333 model: ModelRef | undefined;
334 /**
335 * Set when a turn ended here while OpenCode may still be running it: a Stop
336 * that timed out, a prompt whose request failed without a clear answer, or a
337 * request T3 could not answer. Execution events carry only the session id,
338 * so the next execution end belongs to that run; it clears this and ends no turn.
339 */
340 unsettled: boolean;
341 /** The session's location, where its agents' path rules are read. */
342 directory: string;
343 /**
344 * The agent the session runs (`build`, or `plan` in plan mode); its own path
345 * rules stay in force, and a changed mode switches it before prompting.
346 */
347 agent: string;
348 /** The native session's rules as T3 last read or wrote them, and the policy they are for. */
349 rules: ReadonlyArray<Rule> | undefined;
350 policy: RulesPolicy;
351 /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */
352 readonly grants: Array<Rule>;
353 /** The session's `subagent` calls still running, by tool call id. */
354 readonly calls: Map<string, SubagentCall>;
355 /**
356 * Set on a subagent's session: the call that runs it and the thread it
357 * shows in. Each of its executions is a runless turn there.
358 */
359 subagent:
360 | {
361 call: SubagentCall;
362 readonly appThread: OrchestrationV2AppThread;
363 turns: number;
364 /** The prompt its next execution answers, shown as that turn's user message. */
365 prompt: string | undefined;
366 /**
367 * The first item queued for the session since its last turn began (its
368 * prompt, or a report it answers): where the next turn's history
369 * begins, which a reconnect reads back.
370 */
371 queued: string | undefined;
372 }
373 | undefined;
374 /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */
375 readonly wakes: Array<Wake>;
376 /**
377 * Background subagents whose end OpenCode queued for this session and has
378 * not delivered yet, by inbox id. The wake that delivers them names them.
379 */
380 readonly reports: Map<
381 string,
382 {
383 readonly inboxId: string;
384 readonly childId: string;
385 readonly report: BackgroundWorkReport;
386 readonly text: string;
387 }
388 >;
389 /**
390 * Background subagent sessions T3 stopped. OpenCode wakes the parent to
391 * report them; a wake that reports only these is stopped as well.
392 */
393 readonly stoppedChildren: Set<string>;
394 /**
395 * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's
396 * inbox, where the next prompt would deliver them first, so it cancels them.
397 */
398 readonly strandedSteers: Set<string>;
399 /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */
400 mcp:
401 | { readonly name: string; readonly directory: string; readonly credential: string }
402 | undefined;
403 instructions: string | undefined;
404}
405
406type TurnTerminal =
407 | { readonly status: "completed" | "interrupted" }
408 | { readonly status: "failed"; readonly failure: ReturnType<typeof makeProviderFailure> };
409
410type Rule = Permission.Rule;
411type RulesPolicy = Pick<
412 ProviderAdapter.ProviderAdapterV2RuntimePolicy,
413 "runtimeMode" | "interactionMode"
414>;
415type NativeForm = EventOf<"form.created">["data"]["form"];
416
417/** A permission ask or question form shown to the user and not answered yet. */
418interface PendingRequest {
419 readonly request: OrchestrationV2RuntimeRequest;
420 readonly item: OrchestrationV2TurnItem;
421 readonly node: OrchestrationV2ExecutionNode;
422 /** The thread the request is shown on: the asking session's, or its parent's. */
423 readonly state: ThreadState;
424 readonly turn: ActiveTurn;
425 /** The session that asked: the thread's own, or one of its subagents'. */
426 readonly sessionId: string;
427 /** Set once T3 sends its answer; the orchestrator has already recorded it. */
428 answering: boolean;
429 readonly native:
430 | {
431 readonly type: "permission";
432 readonly id: string;
433 readonly action: string;
434 readonly resources: ReadonlyArray<string>;
435 readonly save: ReadonlyArray<string>;
436 }
437 | { readonly type: "form"; readonly id: string; readonly form: NativeForm };
438}
439
440const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect });
441
442/**
443 * A session's permission rules. OpenCode checks the agent's rules and then
444 * these, and the last rule that matches decides, so these override the
445 * agent's. `paths` are the agent's own allows for its directories (saved tool
446 * output, the plan agent's plan directory), which the blanket rules here would
447 * otherwise override; `grants` are "Always allow this session" answers.
448 */
449/**
450 * T3's MCP server is registered per directory, not per session, so each thread
451 * gets its own `t3-code-<thread>` entry with its own credential. OpenCode names
452 * an MCP tool's permission `<server>_<tool>` (non-alphanumerics become `_`).
453 * OpenCode skips every tool of a server whose name is over 64 characters (its
454 * tool namespace limit), and its router rejects adding one over 100, so a
455 * thread id that does not fit is replaced by a digest of it.
456 */
457export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) {
458 const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
459 if (name.length <= 64) return name;
460 const crypto = yield* Crypto.Crypto;
461 const digest = yield* crypto
462 .digest("SHA-256", new TextEncoder().encode(threadId))
463 .pipe(Effect.orDie);
464 return `t3-code-${Hex.encode(digest).slice(0, 16)}`;
465});
466
467/**
468 * The rules that keep T3's MCP servers to their own thread, after the mode's:
469 * the last matching rule wins, so every thread's T3 server is denied and then
470 * this thread's own is allowed again, in every mode. A subagent's session
471 * inherits the thread's.
472 */
473const mcpRules = (mcpServerName: string | null): ReadonlyArray<Rule> =>
474 mcpServerName === null
475 ? []
476 : [
477 { action: "t3-code-*", resource: "*", effect: "deny" },
478 { action: `${mcpServerName}_*`, resource: "*", effect: "allow" },
479 ];
480
481const sessionRules = (
482 policy: RulesPolicy,
483 paths: ReadonlyArray<Rule>,
484 grants: ReadonlyArray<Rule>,
485 mcpServerName: string | null,
486): ReadonlyArray<Rule> => [
487 ...(policy.runtimeMode === "full-access"
488 ? [rule("*", "allow")]
489 : [
490 rule("shell", "ask"),
491 rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"),
492 rule("external_directory", "ask"),
493 ]),
494 ...grants,
495 // Plan mode writes only its plan, which `paths` allows again. Shell and read
496 // are never denied: the free tier refuses sessions whose rules deny them.
497 ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []),
498 ...paths,
499 ...mcpRules(mcpServerName),
500];
501
502const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) =>
503 left?.length === right.length &&
504 left.every(
505 (entry, index) =>
506 entry.action === right[index]?.action &&
507 entry.resource === right[index]?.resource &&
508 entry.effect === right[index]?.effect,
509 );
510
511/**
512 * Sent with a declined permission. A reject without a message is OpenCode's
513 * "stop": it ends the whole execution, which is Cancel.
514 */
515const DECLINED = "The user declined this request.";
516
517/**
518 * Steered into the session before a decline: a declined shell call reaches the
519 * model only as "Unable to execute command", which it retries.
520 */
521const declinedNote = (action: string, resources: ReadonlyArray<string>) =>
522 `The user declined the ${action} request${resources.length === 0 ? "" : ` (${resources.join(", ")})`}. Do not retry it; continue without it or ask the user how to proceed.`;
523
524/** The session-wide choice for a request whose `save` patterns OpenCode would remember. */
525const sessionGrantLabel = (action: string, save: ReadonlyArray<string>) =>
526 save.every((pattern) => pattern === "*")
527 ? `Allow every ${action} request this session`
528 : `Allow ${save.join(", ")} this session`;
529
530const text = (value: string | undefined, fallback: string) => value?.trim() || fallback;
531
532/**
533 * A form's fields as T3 questions, or why T3 cannot ask them: a link to open,
534 * a field shown only for another answer, a hidden field, or a number or yes/no
535 * value. OpenCode's question tool only asks text and multi-select fields.
536 */
537const formQuestions = (
538 form: NativeForm,
539):
540 | { readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion> }
541 | { readonly unsupported: string } => {
542 const questions: Array<OrchestrationV2UserInputQuestion> = [];
543 for (const [index, field] of form.fields.entries()) {
544 if (field.type === "external") return { unsupported: "a link to open" };
545 if (field.type !== "string" && field.type !== "multiselect") {
546 return { unsupported: `a ${field.type} value` };
547 }
548 if (field.hidden === true) return { unsupported: "a hidden field" };
549 if ((field.when?.length ?? 0) > 0) return { unsupported: "a field that depends on another" };
550 const header = text(field.title, `Question ${index + 1}`);
551 const options = (field.options ?? []).map((option) => {
552 const label = text(option.label, text(option.value, "Option"));
553 return { label, description: text(option.description, label), value: option.value };
554 });
555 questions.push({
556 id: field.key,
557 header,
558 question: text(field.description, header),
559 options,
560 multiSelect: field.type === "multiselect",
561 allowCustomAnswer: field.custom === true || options.length === 0,
562 });
563 }
564 return { questions };
565};
566
567/** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */
568const formAnswer = (form: NativeForm, answers: Readonly<Record<string, unknown>>) => {
569 const answer: Record<string, string | ReadonlyArray<string>> = {};
570 for (const field of form.fields) {
571 const raw = answers[field.key];
572 const values = (Array.isArray(raw) ? raw : [raw]).filter(
573 (value): value is string => typeof value === "string" && value.trim().length > 0,
574 );
575 if (values.length > 0) {
576 answer[field.key] = field.type === "multiselect" ? values : values.join(", ");
577 }
578 }
579 return answer;
580};
581
582const approves = (decision: ProviderApprovalDecision) =>
583 decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways";
584
585/**
586 * An answer to a request OpenCode already dropped (its execution ended, or
587 * its session is gone): nothing waits on it, so it counts as delivered.
588 */
589const permissionGone = {
590 PermissionNotFoundError: () => Effect.void,
591 SessionNotFoundError: () => Effect.void,
592};
593const formGone = {
594 FormNotFoundError: () => Effect.void,
595 FormAlreadySettledError: () => Effect.void,
596 SessionNotFoundError: () => Effect.void,
597};
598
599/** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */
600const agentPaths = (rules: ReadonlyArray<Rule>) =>
601 rules.filter(
602 (entry) =>
603 entry.effect === "allow" &&
604 entry.resource !== "*" &&
605 (entry.action === "edit" || entry.action === "external_directory"),
606 );
607
608const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({
609 driver: OPENCODE_PROVIDER,
610 nativeId,
611 strength,
612});
613
614const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => {
615 const nativeId = providerThread.nativeThreadRef?.nativeId;
616 return nativeId === undefined || nativeId === null
617 ? Effect.fail(
618 new ProviderAdapter.ProviderAdapterProtocolError({
619 driver: OPENCODE_PROVIDER,
620 detail: `Provider thread ${providerThread.id} has no OpenCode session`,
621 }),
622 )
623 : Effect.succeed(nativeId);
624};
625
626const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) =>
627 content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n");
628
629const stringField = (record: Readonly<Record<string, unknown>> | undefined, key: string) => {
630 const value = record?.[key];
631 return typeof value === "string" && value.trim().length > 0 ? value : undefined;
632};
633
634/** How a background subagent ended, from the `state` of OpenCode's report to its parent. */
635const reportOutcome = (state: string | undefined) =>
636 state === "completed"
637 ? ("completed" as const)
638 : state === "error"
639 ? ("failed" as const)
640 : state === "cancelled"
641 ? ("cancelled" as const)
642 : ("unknown" as const);
643
644/** A subagent's answer, without the `<subagent …>` wrapper OpenCode gives the model. */
645const subagentOutput = (text: string) =>
646 /^<subagent\b[^>]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text;
647
648const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) =>
649 turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider";
650
651/** The native turn id of a continuation turn, which answers a wake and has no prompt. */
652const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`;
653const isWakeTurn = (turn: OrchestrationV2ProviderTurn) =>
654 turn.nativeTurnRef?.nativeId?.includes(":wake:") === true;
655
656const INTERRUPT_TIMEOUT = "10 seconds";
657/** The session instructions entry T3 writes its per-turn system prompt to. */
658const INSTRUCTIONS_KEY = "t3-code";
659/** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */
660const RECONNECT_ATTEMPTS = 5;
661const RECONNECT_DELAY = "2 seconds";
662const RECONCILE_TIMEOUT = "15 seconds";
663/** How long a new turn waits for a reconnect in progress. */
664const RECONNECT_WAIT = "30 seconds";
665/** A background subagent's result when its end was lost with the event stream. */
666const LOST_BACKGROUND =
667 "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown.";
668/** How long a turn waits on the directory's commands or skills before sending the text as is. */
669const INVENTORY_TIMEOUT = "5 seconds";
670const ACTIVE_CHECK_TIMEOUT = "5 seconds";
671/** Answers that mean the server refused a prompt; any other failure may have been accepted. */
672const CLEAR_PROMPT_REJECTIONS: ReadonlySet<string> = new Set([
673 "InvalidRequestError",
674 "ConflictError",
675 "UnauthorizedError",
676 "CommandNotFoundError",
677 "SkillNotFoundError",
678]);
679
680export const OPENCODE_2_STILL_STOPPING =
681 "OpenCode is still stopping the previous turn. Send the message again in a moment.";
682const REQUEST_REPLY_TIMEOUT = "10 seconds";
683
684/** Whether an answer to a paused request reached the server, trying twice. */
685const deliver = <E>(answer: Effect.Effect<void, E>) =>
686 answer.pipe(
687 Effect.retry({ times: 1 }),
688 Effect.timeout(REQUEST_REPLY_TIMEOUT),
689 Effect.exit,
690 Effect.map(Exit.isSuccess),
691 );
692
693type ModelRef = ReturnType<typeof Model.Ref.make>;
694
695/**
696 * The user message ids T3 prompts under. OpenCode takes a client id (it must
697 * start with `msg_`) and answers a repeat of one in the same session with the
698 * item it already has, so a retried request never queues a second message,
699 * and a turn knows the message fork and rollback cut at before OpenCode
700 * answers. The id is unique across the whole server, which refuses it in any
701 * other session (409), so it names the session: another T3 database or
702 * environment on the same server repeats thread ids and run ordinals, never
703 * session ids. A turn keeps its id in `nativeTurnRef`.
704 */
705const turnPromptId = (sessionId: string, attemptId: string) =>
706 SessionMessage.ID.make(`msg_t3_turn_${sessionId}:${attemptId}`);
707const steerPromptId = (sessionId: string, messageId: string) =>
708 SessionMessage.ID.make(`msg_t3_steer_${sessionId}:${messageId}`);
709
710/**
711 * The user message a turn prompted with. Turns from before T3 chose prompt ids
712 * recorded `<session>:attempt:<id>`, which is no message.
713 */
714const promptOf = (turn: OrchestrationV2ProviderTurn) => {
715 const nativeId = turn.nativeTurnRef?.nativeId;
716 return nativeId?.startsWith("msg_") === true ? nativeId : undefined;
717};
718
719/**
720 * Where fork and rollback cut to keep the session's turns up to `kept` (all of
721 * them before the first turn when undefined): before the message that starts
722 * the next turn that reached OpenCode, which is in `prompts`. That is a turn's
723 * prompt, or for a continuation the report it answers. A turn refused before
724 * it prompted is not in the session and is passed over, and so is a
725 * continuation that took no report. `null` means nothing follows, so there is
726 * no cut; a later turn from before T3 chose prompt ids has no known message,
727 * so no cut is safe.
728 */
729const boundaryAfter = (
730 turns: ReadonlyArray<OrchestrationV2ProviderTurn>,
731 providerThreadId: OrchestrationV2ProviderThread["id"],
732 kept: OrchestrationV2ProviderTurn | undefined,
733 prompts: ReadonlySet<string>,
734) => {
735 const later = turns
736 .filter(
737 (turn) =>
738 turn.providerThreadId === providerThreadId &&
739 (kept === undefined || turn.ordinal > kept.ordinal),
740 )
741 .toSorted((left, right) => left.ordinal - right.ordinal);
742 if (later.some((turn) => promptOf(turn) === undefined && !isWakeTurn(turn))) {
743 return Effect.fail(
744 new ProviderAdapter.ProviderAdapterProtocolError({
745 driver: OPENCODE_PROVIDER,
746 detail:
747 "This OpenCode conversation has turns from an earlier T3 Code version, so it can't be cut there.",
748 }),
749 );
750 }
751 return Effect.succeed(
752 later.map(promptOf).find((messageId) => messageId !== undefined && prompts.has(messageId)) ??
753 null,
754 );
755};
756
757// Errors already in the adapter channel keep their tag; only lower-level ones are wrapped.
758const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error);
759
760/**
761 * The model OpenCode should run for a `provider/model` slug and its reasoning
762 * variant, or undefined for any other slug: sending none would run OpenCode's
763 * default while T3 records the requested model.
764 */
765const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => {
766 const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(selection.model);
767 if (parsed === null) return undefined;
768 const variant = getModelSelectionStringOptionValue(selection, "variant");
769 return Model.Ref.make({
770 providerID: Provider.ID.make(parsed.providerID),
771 id: Model.ID.make(parsed.modelID),
772 ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
773 });
774};
775const malformedModel = (model: string) =>
776 `OpenCode model '${model}' must use provider/model format`;
777const sameModel = (left: ModelRef, right: ModelRef | undefined) =>
778 left.providerID === right?.providerID &&
779 left.id === right?.id &&
780 (left.variant ?? "default") === (right?.variant ?? "default");
781
782/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */
783const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) =>
784 input.runtimePolicy.interactionMode === "plan" ? "plan" : "build";
785
786/** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */
787const commandOf = (text: string) => {
788 const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
789 return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" };
790};
791
792/** Whether a prompt names any skill at all, before the directory's skills are read. */
793const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u");
794
795/** The workspace skills a prompt names with the composer's `$skill` tokens. */
796const skillsNamed = (text: string, known: ReadonlySet<string>) => [
797 ...new Set(
798 [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) =>
799 known.has(match[2] ?? "") ? [match[2]!] : [],
800 ),
801 ),
802];
803
804/**
805 * The turn's own tokens: steps add up, and the last step's input is the live
806 * context size. A subagent's tokens are its own session's, never these.
807 */
808const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) =>
809 turn.steps === 0
810 ? {
811 usageScope: "main_agent" as const,
812 usageStatus: "unavailable" as const,
813 hasSubagents: turn.usedSubagents,
814 }
815 : {
816 usageScope: "main_agent" as const,
817 usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const),
818 inputTokens: turn.usage.input,
819 cachedInputTokens: turn.usage.cached,
820 cacheCreationTokens: turn.usage.cacheWrite,
821 outputTokens: turn.usage.output,
822 reasoningTokens: turn.usage.reasoning,
823 hasSubagents: turn.usedSubagents,
824 };
825
826/**
827 * The adapter for one provider instance. It talks to the instance's
828 * {@link OpenCode2Server.OpenCode2Server}, which the driver builds from the instance's settings.
829 */
830export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) {
831 const server = yield* OpenCode2Server.OpenCode2Server;
832 const idAllocator = yield* IdAllocator.IdAllocatorV2;
833 const host = yield* ProviderHost.ProviderHost;
834 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
835 const crypto = yield* Crypto.Crypto;
836 const driver = OPENCODE_PROVIDER;
837 const mcpServerNameFor = (threadId: string) =>
838 t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto));
839
840 /**
841 * Lends the instance's server to a session until its scope closes. A spawned
842 * server that died is started again on the next borrow.
843 */
844 const borrow = Effect.gen(function* () {
845 const lent = yield* Deferred.make<
846 OpenCode2Server.OpenCode2Connection,
847 OpenCodeRuntime.OpenCodeRuntimeError
848 >();
849 yield* server
850 .withConnection((connection) =>
851 Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)),
852 )
853 .pipe(
854 Effect.catch((error) => Deferred.fail(lent, error)),
855 Effect.forkScoped,
856 );
857 return yield* Deferred.await(lent);
858 });
859
860 const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* (
861 input: Parameters<ProviderAdapter.ProviderAdapterV2Shape["openSession"]>[0],
862 initial: {
863 readonly connection: OpenCode2Server.OpenCode2Connection;
864 readonly scope: Scope.Closeable;
865 },
866 ) {
867 let connection = initial.connection;
868 // Replaced when the session reconnects to a restarted server.
869 let client = connection.client;
870 const sessionScope = yield* Effect.scope;
871 // Context windows by directory, then `provider/model`: a project's own
872 // OpenCode config can change a model's limits, and this one runtime serves
873 // the instance's threads in every directory.
874 const contextWindows = new Map<string, Map<string, number>>();
875 /** A thread without a worktree runs where T3 does, as its session is created. */
876 const directoryOf = (cwd: string | null | undefined) => cwd ?? host.paths.cwd;
877 const windowOf = (cwd: string | null | undefined, model: string) =>
878 contextWindows.get(directoryOf(cwd))?.get(model);
879 const now = yield* DateTime.now;
880 let session: OrchestrationV2ProviderSession = {
881 id: input.providerSessionId,
882 driver,
883 providerInstanceId: instanceId,
884 status: "ready",
885 cwd: input.runtimePolicy.cwd ?? host.paths.cwd,
886 model: input.modelSelection.model,
887 capabilities: OpenCode2ProviderCapabilities,
888 createdAt: now,
889 updatedAt: now,
890 lastError: null,
891 };
892 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>();
893 // Thread sessions, and the sessions of their subagents.
894 const threads = new Map<string, ThreadState>();
895 // A subagent's session, by its id, to the thread whose session started it.
896 const childOwners = new Map<string, ThreadState>();
897 // What OpenCode announced about a subagent's session, until its call names it.
898 const announced = new Map<string, EventOf<"session.created">["data"]>();
899 // Sessions of these threads with an execution running, seen on the stream.
900 const busy = new Set<string>();
901 const pending = new Map<RuntimeRequestId, PendingRequest>();
902 // Events and a wake's replay into its turn are handled one at a time, in order.
903 const lock = yield* Semaphore.make(1);
904 // Sessions whose `revert.clear` has not had its empty execution yet.
905 const clearing = new Map<string, Deferred.Deferred<void>>();
906 // Sessions a failed rollback may have left with a staged revert.
907 const stagedReverts = new Set<string>();
908 // Starting a turn and cutting the history take turns on a session: each
909 // checks that the other is not running before its own requests yield.
910 const sessionGates = yield* KeyedLock.make<string>();
911 const exclusive =
912 (providerThread: OrchestrationV2ProviderThread) =>
913 <A, E, R>(effect: Effect.Effect<A, E, R>) => {
914 const sessionId = providerThread.nativeThreadRef?.nativeId;
915 if (sessionId == null) return effect;
916 return sessionGates.withLock(sessionId, effect);
917 };
918 const emit = (event: ProviderAdapter.ProviderAdapterV2Event) =>
919 Queue.offer(events, event).pipe(Effect.asVoid);
920 const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId);
921
922 const newThreadState = (
923 sessionId: string,
924 providerThread: OrchestrationV2ProviderThread,
925 directory: string,
926 subagent: ThreadState["subagent"],
927 ): ThreadState => ({
928 sessionId,
929 providerThread,
930 providerTurns: new Map(),
931 active: undefined,
932 model: undefined,
933 unsettled: false,
934 directory,
935 agent: "build",
936 rules: undefined,
937 policy: input.runtimePolicy,
938 grants: [],
939 calls: new Map(),
940 subagent,
941 wakes: [],
942 reports: new Map(),
943 stoppedChildren: new Set(),
944 strandedSteers: new Set(),
945 mcp: undefined,
946 instructions: undefined,
947 });
948
949 /** The thread whose session started this one, through any nesting. */
950 const rootOf = (state: ThreadState): ThreadState => {
951 let current = state;
952 while (current.subagent !== undefined) current = current.subagent.call.state;
953 return current;
954 };
955
956 /** A thread's session and its subagents' sessions, through any nesting. */
957 const sessionsOf = (thread: ThreadState): ReadonlyArray<ThreadState> => [
958 thread,
959 ...[...threads.values()].filter((state) => {
960 for (let above = state.subagent?.call.state; above; above = above.subagent?.call.state) {
961 if (above === thread) return true;
962 }
963 return false;
964 }),
965 ];
966
967 /**
968 * A thread's `subagent` calls still running, its subagents' included: a
969 * subagent's background call runs on after the call that made it ended.
970 */
971 const runningCalls = (thread: ThreadState): ReadonlyArray<SubagentCall> =>
972 sessionsOf(thread).flatMap((state) => [...state.calls.values()]);
973
974 /** The `subagent` calls that lead to a session, from its own up to the thread's. */
975 const callsAbove = (sessionId: string) => {
976 const calls: Array<SubagentCall> = [];
977 let current = threads.get(sessionId);
978 while (current?.subagent !== undefined) {
979 calls.push(current.subagent.call);
980 current = current.subagent.call.state;
981 }
982 return calls;
983 };
984
985 /**
986 * The turn a request from `sessionId` is shown under. A background
987 * subagent's goes to the turn that started it, whose run waits on the
988 * subagent; anything else goes to the thread's running turn.
989 */
990 const requestTurn = (sessionId: string) => {
991 const state = ownerOf(sessionId);
992 if (state === undefined) return undefined;
993 const background = callsAbove(sessionId).findLast((call) => call.background);
994 if (background !== undefined) {
995 return isOrchestrationV2WorkActive(background.status)
996 ? { state, turn: background.turn }
997 : undefined;
998 }
999 return state.active === undefined ? undefined : { state, turn: state.active };
1000 };
1001
1002 /**
1003 * What a session still owes its thread once the thread's turn ended:
1004 * background subagents, held executions, reports OpenCode queued for the
1005 * follow-up it will start, and anything a subagent's session still runs,
1006 * such as its own follow-up to such a report.
1007 */
1008 const owesWork = (state: ThreadState) =>
1009 state.wakes.length > 0 ||
1010 state.reports.size > 0 ||
1011 (state.subagent !== undefined && busy.has(state.sessionId)) ||
1012 [...state.calls.values()].some((call) => call.background);
1013
1014 /** Work that outlives the thread's turn, on its own session or its subagents'. */
1015 const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork);
1016
1017 const setSessionStatus = (
1018 status: OrchestrationV2ProviderSession["status"],
1019 lastError: string | null,
1020 ) =>
1021 Effect.gen(function* () {
1022 session = { ...session, status, lastError, updatedAt: yield* DateTime.now };
1023 yield* emit({ type: "provider_session.updated", driver, providerSession: session });
1024 });
1025
1026 const ordinalOf = (turn: ActiveTurn, nativeId: string) => {
1027 const known = turn.ordinals.get(nativeId);
1028 if (known !== undefined) return known;
1029 const next = turn.nextOrdinal++;
1030 turn.ordinals.set(nativeId, next);
1031 return next;
1032 };
1033
1034 const itemBase = (
1035 state: ThreadState,
1036 turn: ActiveTurn,
1037 nativeId: string,
1038 status: OrchestrationV2TurnItem["status"],
1039 startedAt: DateTime.Utc,
1040 completedAt: DateTime.Utc | null,
1041 updatedAt: DateTime.Utc,
1042 ) => ({
1043 id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: nativeId }),
1044 threadId: turn.input.threadId,
1045 runId: turn.input.runId,
1046 nodeId: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }),
1047 providerThreadId: state.providerThread.id,
1048 providerTurnId: turn.providerTurn.id,
1049 nativeItemRef: ref(nativeId),
1050 parentItemId: null,
1051 ordinal: ordinalOf(turn, nativeId),
1052 status,
1053 title: null,
1054 startedAt,
1055 completedAt,
1056 updatedAt,
1057 });
1058
1059 const emitNode = (
1060 state: ThreadState,
1061 turn: ActiveTurn,
1062 nativeId: string,
1063 kind: OrchestrationV2ExecutionNode["kind"],
1064 status: OrchestrationV2ExecutionNode["status"],
1065 startedAt: DateTime.Utc,
1066 completedAt: DateTime.Utc | null,
1067 ) =>
1068 emit({
1069 type: "node.updated",
1070 driver,
1071 node: {
1072 id: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }),
1073 threadId: turn.input.threadId,
1074 runId: turn.input.runId,
1075 parentNodeId: turn.input.rootNodeId,
1076 rootNodeId: turn.input.rootNodeId,
1077 kind,
1078 status,
1079 countsForRun: false,
1080 providerThreadId: state.providerThread.id,
1081 providerTurnId: turn.providerTurn.id,
1082 nativeItemRef: ref(nativeId),
1083 runtimeRequestId: null,
1084 checkpointScopeId: null,
1085 startedAt,
1086 completedAt,
1087 },
1088 });
1089
1090 /** One text or reasoning block, re-emitted with its accumulated text on every change. */
1091 const emitText = Effect.fnUntraced(function* (
1092 state: ThreadState,
1093 turn: ActiveTurn,
1094 data: {
1095 readonly assistantMessageID: string;
1096 readonly ordinal: number;
1097 readonly text?: string;
1098 },
1099 kind: "text" | "reasoning",
1100 update: (current: string) => string,
1101 completed = "text" in data,
1102 ) {
1103 const nativeId = `${data.assistantMessageID}:${kind}:${data.ordinal}`;
1104 const updatedAt = yield* DateTime.now;
1105 const entry = turn.texts.get(nativeId) ?? {
1106 block: data,
1107 kind,
1108 startedAt: updatedAt,
1109 text: "",
1110 };
1111 entry.text = update(entry.text);
1112 if (completed) turn.texts.delete(nativeId);
1113 else turn.texts.set(nativeId, entry);
1114 if (entry.text.length === 0) return;
1115 const status = completed ? "completed" : "running";
1116 const completedAt = completed ? updatedAt : null;
1117 const nodeKind = kind === "text" ? "assistant_message" : "reasoning";
1118 yield* emitNode(state, turn, nativeId, nodeKind, status, entry.startedAt, completedAt);
1119 const base = itemBase(state, turn, nativeId, status, entry.startedAt, completedAt, updatedAt);
1120 if (kind === "reasoning") {
1121 yield* emit({
1122 type: "turn_item.updated",
1123 driver,
1124 turnItem: { ...base, type: "reasoning", text: entry.text, streaming: !completed },
1125 });
1126 return;
1127 }
1128 const messageId = idAllocator.derive.messageFromProviderItem({
1129 driver,
1130 nativeItemId: nativeId,
1131 });
1132 const message: OrchestrationV2ConversationMessage = {
1133 createdBy: "agent",
1134 creationSource: "provider",
1135 id: messageId,
1136 threadId: turn.input.threadId,
1137 runId: turn.input.runId,
1138 nodeId: base.nodeId,
1139 role: "assistant",
1140 text: entry.text,
1141 attachments: [],
1142 streaming: !completed,
1143 createdAt: entry.startedAt,
1144 updatedAt,
1145 };
1146 yield* emit({ type: "message.updated", driver, message });
1147 yield* emit({
1148 type: "turn_item.updated",
1149 driver,
1150 turnItem: {
1151 ...base,
1152 type: "assistant_message",
1153 messageId,
1154 text: entry.text,
1155 streaming: !completed,
1156 },
1157 });
1158 });
1159
1160 const emitTool = Effect.fnUntraced(function* (
1161 state: ThreadState,
1162 turn: ActiveTurn,
1163 id: string,
1164 status: "running" | "completed" | "failed" | "interrupted",
1165 result?: { readonly output: string | undefined; readonly metadata: unknown },
1166 ) {
1167 const tool = turn.tools.get(id);
1168 if (tool === undefined) return;
1169 const updatedAt = yield* DateTime.now;
1170 const startedAt = turn.startedAt.get(id) ?? updatedAt;
1171 const completedAt = status === "running" ? null : updatedAt;
1172 const nativeId = `${turn.scope}${id}`;
1173 yield* emitNode(state, turn, nativeId, "tool_call", status, startedAt, completedAt);
1174 yield* emit({
1175 type: "turn_item.updated",
1176 driver,
1177 turnItem: openCodeToolTurnItem(
1178 itemBase(state, turn, nativeId, status, startedAt, completedAt, updatedAt),
1179 {
1180 name: tool.name,
1181 input: tool.input,
1182 output: result?.output,
1183 completedMetadata: status === "completed" ? result?.metadata : undefined,
1184 },
1185 ),
1186 });
1187 });
1188
1189 /** The turn's compaction item; its summary is the text OpenCode carries forward. */
1190 const emitCompaction = Effect.fnUntraced(function* (
1191 state: ThreadState,
1192 turn: ActiveTurn,
1193 status: "running" | "completed" | "failed" | "interrupted",
1194 summary?: string,
1195 ) {
1196 const compaction = turn.compaction;
1197 if (compaction === undefined) return;
1198 const updatedAt = yield* DateTime.now;
1199 const completedAt = status === "running" ? null : updatedAt;
1200 yield* emit({
1201 type: "turn_item.updated",
1202 driver,
1203 turnItem: {
1204 ...itemBase(
1205 state,
1206 turn,
1207 compaction.nativeId,
1208 status,
1209 compaction.startedAt,
1210 completedAt,
1211 updatedAt,
1212 ),
1213 nodeId: turn.input.rootNodeId,
1214 type: "compaction",
1215 driver,
1216 title:
1217 status === "running"
1218 ? "Compacting context"
1219 : status === "completed"
1220 ? "Context compacted"
1221 : status === "failed"
1222 ? "Compaction failed"
1223 : "Compaction interrupted",
1224 ...(summary === undefined || summary.length === 0 ? {} : { summary }),
1225 },
1226 });
1227 if (status !== "running") turn.compaction = undefined;
1228 });
1229
1230 const emitProviderTurn = (
1231 state: ThreadState,
1232 turn: ActiveTurn,
1233 providerTurn: OrchestrationV2ProviderTurn,
1234 ) => {
1235 state.providerTurns.set(String(providerTurn.id), providerTurn);
1236 return emit({
1237 type: "provider_turn.updated",
1238 driver,
1239 threadId: turn.input.threadId,
1240 providerTurn,
1241 });
1242 };
1243
1244 const makeTurn = (
1245 owner: TurnOwner,
1246 providerTurn: OrchestrationV2ProviderTurn,
1247 options: { readonly scope: string; readonly awaitingStart: boolean },
1248 ): ActiveTurn => ({
1249 input: owner,
1250 providerTurn,
1251 texts: new Map(),
1252 tools: new Map(),
1253 startedAt: new Map(),
1254 ordinals: new Map(),
1255 nextOrdinal: providerTurn.ordinal * 100 + 1,
1256 usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 },
1257 steps: 0,
1258 lastStep: undefined,
1259 before: undefined,
1260 compaction: undefined,
1261 compactions: 0,
1262 interrupted: false,
1263 unsent: false,
1264 steers: new Set(),
1265 settledInbox: new Set(),
1266 heldEnd: undefined,
1267 awaitingStart: options.awaitingStart,
1268 scope: options.scope,
1269 usedSubagents: false,
1270 });
1271
1272 /** A subagent call as its parent turn's item, node and subagent row. */
1273 const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) {
1274 const updatedAt = yield* DateTime.now;
1275 const { turn, state } = call;
1276 const nodeId = idAllocator.derive.nodeFromProviderItem({
1277 driver,
1278 nativeItemId: call.nativeId,
1279 });
1280 const completedAt = isOrchestrationV2WorkActive(call.status)
1281 ? null
1282 : (call.completedAt ?? updatedAt);
1283 const childProviderThreadId = call.child?.providerThread.id ?? null;
1284 const childThreadId = call.child?.subagent?.appThread.id ?? null;
1285 yield* emit({
1286 type: "node.updated",
1287 driver,
1288 node: {
1289 id: nodeId,
1290 threadId: turn.input.threadId,
1291 runId: turn.input.runId,
1292 parentNodeId: turn.input.rootNodeId,
1293 rootNodeId: turn.input.rootNodeId,
1294 kind: "subagent",
1295 status: call.status,
1296 countsForRun: false,
1297 providerThreadId: childProviderThreadId ?? state.providerThread.id,
1298 providerTurnId: turn.providerTurn.id,
1299 nativeItemRef: ref(call.nativeId),
1300 runtimeRequestId: null,
1301 checkpointScopeId: null,
1302 startedAt: call.startedAt,
1303 completedAt,
1304 },
1305 });
1306 yield* emit({
1307 type: "subagent.updated",
1308 driver,
1309 subagent: {
1310 id: nodeId,
1311 threadId: turn.input.threadId,
1312 runId: turn.input.runId,
1313 parentNodeId: turn.input.rootNodeId,
1314 origin: "provider_native",
1315 createdBy: "agent",
1316 driver,
1317 providerInstanceId: instanceId,
1318 providerThreadId: childProviderThreadId,
1319 childThreadId,
1320 nativeTaskRef: ref(call.nativeId),
1321 prompt: call.prompt,
1322 title: call.title,
1323 model: call.model,
1324 status: call.status,
1325 result: call.result,
1326 startedAt: call.startedAt,
1327 completedAt,
1328 updatedAt,
1329 },
1330 });
1331 yield* emit({
1332 type: "turn_item.updated",
1333 driver,
1334 turnItem: {
1335 id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: call.nativeId }),
1336 threadId: turn.input.threadId,
1337 runId: turn.input.runId,
1338 nodeId,
1339 providerThreadId: state.providerThread.id,
1340 providerTurnId: turn.providerTurn.id,
1341 nativeItemRef: ref(call.nativeId),
1342 parentItemId: null,
1343 ordinal: call.ordinal,
1344 status: call.status,
1345 title: call.title,
1346 startedAt: call.startedAt,
1347 completedAt,
1348 updatedAt,
1349 type: "subagent",
1350 subagentId: nodeId,
1351 origin: "provider_native",
1352 driver,
1353 providerInstanceId: instanceId,
1354 childThreadId,
1355 prompt: call.prompt,
1356 result: call.result,
1357 },
1358 });
1359 yield* syncRoster(rootOf(call.state));
1360 });
1361
1362 /**
1363 * Lists a thread's background subagents on its provider thread, as typed
1364 * `subagent` work: the thread shows it waits on them after its turn ends,
1365 * and a Stop reaches them. Emitted only when the list changes.
1366 */
1367 const syncRoster = Effect.fnUntraced(function* (state: ThreadState) {
1368 const roster = runningCalls(state)
1369 .filter((call) => call.background)
1370 .map((call) => ({
1371 taskId: call.nativeId,
1372 kind: "subagent" as const,
1373 ...(call.title?.trim() ? { description: call.title.trim() } : {}),
1374 ...(call.child?.subagent === undefined
1375 ? {}
1376 : { childThreadId: call.child.subagent.appThread.id }),
1377 }));
1378 const current = state.providerThread.pendingBackgroundTasks ?? [];
1379 if (
1380 current.length === roster.length &&
1381 current.every(
1382 (task, index) =>
1383 task.taskId === roster[index]?.taskId &&
1384 task.description === roster[index]?.description &&
1385 (task.kind === "subagent" ? task.childThreadId : undefined) ===
1386 roster[index]?.childThreadId,
1387 )
1388 ) {
1389 return;
1390 }
1391 state.providerThread = {
1392 ...state.providerThread,
1393 pendingBackgroundTasks: roster,
1394 updatedAt: yield* DateTime.now,
1395 };
1396 yield* emit({
1397 type: "provider_thread.updated",
1398 driver,
1399 providerThread: state.providerThread,
1400 });
1401 });
1402
1403 const withReportedModel = (
1404 providerThread: OrchestrationV2ProviderThread,
1405 model: ModelRef | undefined,
1406 ): OrchestrationV2ProviderThread => {
1407 if (model === undefined) return providerThread;
1408 const modelSelection: ModelSelection = {
1409 instanceId,
1410 model: `${model.providerID}/${model.id}`,
1411 options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }],
1412 };
1413 if (
1414 providerThread.nativeMetadata?.modelSelection !== undefined &&
1415 modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection)
1416 )
1417 return providerThread;
1418 return {
1419 ...providerThread,
1420 nativeMetadata: { ...providerThread.nativeMetadata, modelSelection },
1421 };
1422 };
1423
1424 /**
1425 * Gives a subagent call its session once both are known: OpenCode names
1426 * the session on the call's progress, and announces it just before. The
1427 * session becomes a child thread under the call's item.
1428 */
1429 const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) {
1430 if (call.child !== undefined) return;
1431 const info = announced.get(childId);
1432 announced.delete(childId);
1433 const now = yield* DateTime.now;
1434 const nodeId = idAllocator.derive.nodeFromProviderItem({
1435 driver,
1436 nativeItemId: call.nativeId,
1437 });
1438 const childThreadId = idAllocator.derive.threadFromProviderThread({
1439 driver,
1440 nativeThreadId: childId,
1441 providerInstanceId: instanceId,
1442 });
1443 const providerThreadId = idAllocator.derive.providerThread({
1444 driver,
1445 nativeThreadId: childId,
1446 providerInstanceId: instanceId,
1447 });
1448 if (info?.model !== undefined) call.model = `${info.model.providerID}/${info.model.id}`;
1449 call.title = call.title ?? info?.title ?? null;
1450 const parentThread = call.turn.input.appThread;
1451 const appThread = makeSubagentChildThread({
1452 parentThread,
1453 childThreadId,
1454 parentNodeId: nodeId,
1455 activeProviderThreadId: providerThreadId,
1456 providerInstanceId: instanceId,
1457 modelSelection: {
1458 instanceId,
1459 model: call.model ?? call.turn.input.modelSelection.model,
1460 },
1461 title: subagentThreadTitle({
1462 parentTitle: parentThread.title,
1463 title: call.title,
1464 prompt: call.prompt,
1465 ordinal: call.ordinal,
1466 }),
1467 now,
1468 createdBy: "agent",
1469 creationSource: "provider",
1470 });
1471 const providerThread: OrchestrationV2ProviderThread = {
1472 id: providerThreadId,
1473 driver,
1474 providerInstanceId: instanceId,
1475 providerSessionId: input.providerSessionId,
1476 appThreadId: childThreadId,
1477 ownerNodeId: nodeId,
1478 nativeThreadRef: ref(childId),
1479 nativeConversationHeadRef: null,
1480 status: "active",
1481 firstRunOrdinal: null,
1482 lastRunOrdinal: null,
1483 handoffIds: [],
1484 forkedFrom: null,
1485 createdAt: now,
1486 updatedAt: now,
1487 };
1488 // A subagent called again keeps its session's state: how many turns it
1489 // ran (each one's native id is its own), its grants and rules, and the
1490 // background work an earlier call left running, which Stop must reach.
1491 const previous = threads.get(childId);
1492 const subagent = {
1493 call,
1494 appThread,
1495 turns: previous?.subagent?.turns ?? 0,
1496 prompt: call.prompt,
1497 queued: undefined,
1498 };
1499 const child =
1500 previous ?? newThreadState(childId, providerThread, call.state.directory, subagent);
1501 child.subagent = subagent;
1502 child.model = info?.model ?? child.model;
1503 child.providerThread = withReportedModel(providerThread, child.model);
1504 child.directory = call.state.directory;
1505 child.agent = info?.agent ?? call.agent ?? child.agent;
1506 // OpenCode gives a new session its parent's rules, which are the thread's.
1507 child.rules = info?.permissions ?? child.rules;
1508 threads.set(childId, child);
1509 childOwners.set(childId, rootOf(call.state));
1510 call.child = child;
1511 yield* emit({ type: "app_thread.created", driver, appThread });
1512 yield* emit({
1513 type: "provider_thread.updated",
1514 driver,
1515 providerThread: child.providerThread,
1516 });
1517 yield* emitSubagent(call);
1518 // A session called again was not made now, so it may hold the rules of
1519 // a mode the thread has left. OpenCode applies a rules change to the
1520 // asks after it, so it gets the thread's as soon as its call names it.
1521 if (info === undefined) {
1522 yield* writeRules(child, rootOf(call.state).policy).pipe(
1523 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1524 Effect.ignore({ log: true }),
1525 );
1526 }
1527 });
1528
1529 /**
1530 * Ends a subagent call and every call under it that is still running. A
1531 * background one outlives the call that made it, as it outlives a turn,
1532 * unless that call failed.
1533 */
1534 const settleCall: (
1535 call: SubagentCall,
1536 status: OrchestrationV2Subagent["status"],
1537 result?: string,
1538 ) => Effect.Effect<void> = Effect.fnUntraced(function* (call, status, result) {
1539 if (!isOrchestrationV2WorkActive(call.status)) return;
1540 call.status = status;
1541 call.completedAt = yield* DateTime.now;
1542 if (result !== undefined) call.result = result;
1543 call.state.calls.delete(call.toolId);
1544 const child = call.child;
1545 if (child !== undefined) {
1546 // A snapshot: settling a call removes it from the map.
1547 for (const nested of Array.from(child.calls.values())) {
1548 if (nested.background && status !== "failed") continue;
1549 yield* settleCall(nested, status === "completed" ? "interrupted" : status);
1550 }
1551 if (child.active !== undefined && status !== "completed") {
1552 yield* finishTurn(child, { status: "interrupted" });
1553 }
1554 child.providerThread = {
1555 ...child.providerThread,
1556 status: "idle",
1557 updatedAt: call.completedAt,
1558 };
1559 yield* emit({
1560 type: "provider_thread.updated",
1561 driver,
1562 providerThread: child.providerThread,
1563 });
1564 }
1565 yield* emitSubagent(call);
1566 });
1567
1568 /**
1569 * Opens the runless turn a subagent's session runs each execution in, on
1570 * its child thread, with the prompt it answers as the turn's user message.
1571 */
1572 const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) {
1573 const subagent = child.subagent;
1574 if (subagent === undefined || child.active !== undefined) return;
1575 subagent.turns += 1;
1576 const startedAt = yield* DateTime.now;
1577 const nativeTurnId = `${child.sessionId}:turn:${subagent.turns}`;
1578 const rootNodeId = idAllocator.derive.nodeFromProviderItem({
1579 driver,
1580 nativeItemId: `${nativeTurnId}:root`,
1581 });
1582 const providerTurn: OrchestrationV2ProviderTurn = {
1583 id: idAllocator.derive.providerTurn({ driver, nativeTurnId }),
1584 providerThreadId: child.providerThread.id,
1585 nodeId: rootNodeId,
1586 runAttemptId: null,
1587 nativeTurnRef: ref(nativeTurnId, "weak"),
1588 ordinal: subagent.turns,
1589 status: "running",
1590 startedAt,
1591 completedAt: null,
1592 };
1593 const turn = makeTurn(
1594 {
1595 appThread: subagent.appThread,
1596 threadId: subagent.appThread.id,
1597 runId: null,
1598 runOrdinal: subagent.call.turn.input.runOrdinal,
1599 rootNodeId,
1600 modelSelection: subagent.appThread.modelSelection,
1601 runtimePolicy: subagent.call.turn.input.runtimePolicy,
1602 },
1603 providerTurn,
1604 { scope: `${child.sessionId}:`, awaitingStart: false },
1605 );
1606 turn.before = subagent.queued;
1607 subagent.queued = undefined;
1608 child.active = turn;
1609 yield* emit({
1610 type: "node.updated",
1611 driver,
1612 node: {
1613 id: rootNodeId,
1614 threadId: turn.input.threadId,
1615 runId: null,
1616 parentNodeId: null,
1617 rootNodeId,
1618 kind: "root_turn",
1619 status: "running",
1620 countsForRun: false,
1621 providerThreadId: child.providerThread.id,
1622 providerTurnId: providerTurn.id,
1623 nativeItemRef: ref(nativeTurnId, "weak"),
1624 runtimeRequestId: null,
1625 checkpointScopeId: null,
1626 startedAt,
1627 completedAt: null,
1628 },
1629 });
1630 yield* emitProviderTurn(child, turn, providerTurn);
1631 const prompt = subagent.prompt;
1632 subagent.prompt = undefined;
1633 if (prompt !== undefined && prompt.length > 0) {
1634 const nativeId = `${nativeTurnId}:prompt`;
1635 const artifacts = makeSubagentConversationArtifacts({
1636 messageId: idAllocator.derive.messageFromProviderItem({ driver, nativeItemId: nativeId }),
1637 senderThreadId: subagent.call.turn.input.threadId,
1638 turnItemId: idAllocator.derive.turnItemFromProviderItem({
1639 driver,
1640 nativeItemId: nativeId,
1641 }),
1642 threadId: turn.input.threadId,
1643 rootNodeId,
1644 providerThreadId: child.providerThread.id,
1645 providerTurnId: providerTurn.id,
1646 nativeItemRef: ref(nativeId, "weak"),
1647 role: "user",
1648 text: prompt,
1649 ordinal: ordinalOf(turn, nativeId),
1650 now: startedAt,
1651 });
1652 yield* emit({ type: "message.updated", driver, message: artifacts.message });
1653 yield* emit({ type: "turn_item.updated", driver, turnItem: artifacts.turnItem });
1654 }
1655 });
1656
1657 /**
1658 * Asks the orchestrator for the continuation turn a held wake needs. It
1659 * names the subagents whose end woke the thread. A wake a Stop or an
1660 * earlier turn already took needs no turn, so its offer is dropped.
1661 */
1662 const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) {
1663 const route = state.providerThread.appThreadId;
1664 if (route === null) return;
1665 const notification = backgroundWorkNotification(wake.reports);
1666 yield* continuationRequests.offer({
1667 threadId: route,
1668 providerThreadId: state.providerThread.id,
1669 driver,
1670 detail: wake.detail,
1671 ...(notification === null ? {} : { notification }),
1672 dispatchIfCurrent: (dispatch) =>
1673 wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some),
1674 });
1675 });
1676
1677 const finishTurn = Effect.fnUntraced(function* (
1678 state: ThreadState,
1679 terminal: TurnTerminal,
1680 threadDisposition: "reusable" | "broken" = "reusable",
1681 ) {
1682 const turn = state.active;
1683 if (turn === undefined) return;
1684 state.active = undefined;
1685 for (const inboxID of turn.steers) state.strandedSteers.add(inboxID);
1686 // OpenCode drops a request when the asking session's execution ends. A
1687 // subagent's request shown on this turn ends with it too, unless the
1688 // subagent runs in the background past a turn that did not fail.
1689 for (const entry of pending.values()) {
1690 const background = callsAbove(entry.sessionId).some((call) => call.background);
1691 if (
1692 entry.sessionId === state.sessionId ||
1693 (entry.turn === turn && !(background && terminal.status !== "failed"))
1694 ) {
1695 yield* settleRequest(entry, "cancelled");
1696 }
1697 }
1698 const completedAt = yield* DateTime.now;
1699 // Blocks still open when the execution ends are final as they stand.
1700 for (const open of turn.texts.values()) {
1701 yield* emitText(state, turn, open.block, open.kind, (text) => text, true);
1702 }
1703 for (const id of turn.tools.keys()) {
1704 yield* emitTool(
1705 state,
1706 turn,
1707 id,
1708 terminal.status === "completed" ? "completed" : "interrupted",
1709 );
1710 }
1711 // A foreground subagent ends with the turn that waits on it. A background
1712 // one outlives a finished or interrupted turn (a user Stop has already
1713 // stopped it), and a failed turn stops it.
1714 // A snapshot: settling a call removes it from the map.
1715 for (const call of Array.from(state.calls.values())) {
1716 if (call.turn !== turn) continue;
1717 if (call.background && terminal.status !== "failed") continue;
1718 yield* settleCall(call, terminal.status === "completed" ? "completed" : terminal.status);
1719 }
1720 if (state.subagent !== undefined) {
1721 yield* emit({
1722 type: "node.updated",
1723 driver,
1724 node: {
1725 id: turn.input.rootNodeId,
1726 threadId: turn.input.threadId,
1727 runId: null,
1728 parentNodeId: null,
1729 rootNodeId: turn.input.rootNodeId,
1730 kind: "root_turn",
1731 status: terminal.status,
1732 countsForRun: false,
1733 providerThreadId: state.providerThread.id,
1734 providerTurnId: turn.providerTurn.id,
1735 nativeItemRef: turn.providerTurn.nativeTurnRef,
1736 runtimeRequestId: null,
1737 checkpointScopeId: null,
1738 startedAt: turn.providerTurn.startedAt,
1739 completedAt,
1740 },
1741 });
1742 }
1743 yield* emitCompaction(state, turn, terminal.status === "failed" ? "failed" : "interrupted");
1744 const window = windowOf(turn.input.runtimePolicy.cwd, turn.input.modelSelection.model);
1745 const lastStep = turn.lastStep;
1746 yield* emitProviderTurn(state, turn, {
1747 ...turn.providerTurn,
1748 status: terminal.status,
1749 completedAt,
1750 turnTokenUsage: turnTokenUsage(turn, terminal.status),
1751 ...(lastStep === undefined
1752 ? {}
1753 : {
1754 tokenUsage: {
1755 usedTokens:
1756 lastStep.input + lastStep.cache.read + lastStep.cache.write + lastStep.output,
1757 maxTokens: window ?? null,
1758 inputTokens: lastStep.input + lastStep.cache.read + lastStep.cache.write,
1759 cachedInputTokens: lastStep.cache.read,
1760 outputTokens: lastStep.output,
1761 reasoningOutputTokens: lastStep.reasoning,
1762 updatedAt: DateTime.formatIso(completedAt),
1763 },
1764 }),
1765 });
1766 // A subagent's session is its call's to settle: a turn there is runless,
1767 // and T3 has no terminal to wait on.
1768 if (state.subagent !== undefined) {
1769 const call = state.subagent.call;
1770 if (!call.background && terminal.status !== "completed") {
1771 yield* settleCall(call, terminal.status);
1772 }
1773 return;
1774 }
1775 state.providerThread = {
1776 ...state.providerThread,
1777 status: threadDisposition === "broken" ? "error" : "idle",
1778 updatedAt: completedAt,
1779 };
1780 yield* emit({
1781 type: "provider_thread.updated",
1782 driver,
1783 providerThread: state.providerThread,
1784 });
1785 const anyActive = [...threads.values()].some(
1786 (candidate) => candidate.subagent === undefined && candidate.active !== undefined,
1787 );
1788 yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null);
1789 const base = {
1790 type: "turn.terminal" as const,
1791 driver,
1792 // The provider thread the turn started on. After a native fork the
1793 // session's own thread has a fresh id, and the terminal must name the
1794 // thread the start was recorded against.
1795 providerThreadId: turn.providerTurn.providerThreadId,
1796 providerTurnId: turn.providerTurn.id,
1797 runOrdinal: turn.input.runOrdinal,
1798 threadDisposition,
1799 };
1800 yield* emit(
1801 terminal.status === "failed"
1802 ? {
1803 ...base,
1804 status: "failed",
1805 failure: terminal.failure,
1806 failureItemOrdinal: ordinalOf(turn, `terminal-failure:${turn.providerTurn.id}`),
1807 }
1808 : { ...base, status: terminal.status, failure: null },
1809 );
1810 // A spawned server lists its models lazily, so a window still unknown is
1811 // read again for the next turn, off this stream so it never delays one.
1812 if (window === undefined) {
1813 yield* Effect.forkIn(readModels(directoryOf(turn.input.runtimePolicy.cwd)), sessionScope);
1814 }
1815 });
1816
1817 /**
1818 * Ends the turn a request T3 could not answer would block, and stops the
1819 * session: OpenCode waits on an unanswered request forever. The stopped
1820 * run's end is its own, not the next turn's.
1821 */
1822 const abandonRequest = Effect.fnUntraced(function* (state: ThreadState, reason: string) {
1823 yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", {
1824 reason,
1825 });
1826 if (state.active === undefined) return;
1827 state.unsettled = true;
1828 yield* finishTurn(state, {
1829 status: "failed",
1830 failure: makeProviderFailure({
1831 message: "OpenCode is waiting on a request T3 Code couldn't answer.",
1832 class: "provider_error",
1833 }),
1834 });
1835 yield* client.session
1836 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
1837 .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true }));
1838 });
1839
1840 /** Stops tracking a request; the session is waiting only while any remain. */
1841 const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) {
1842 if (!pending.delete(entry.request.id)) return false;
1843 if (pending.size === 0 && session.status === "waiting") {
1844 yield* setSessionStatus("running", null);
1845 }
1846 return true;
1847 });
1848
1849 /**
1850 * Settles a request OpenCode answered or dropped on its own. T3's own
1851 * answers are only forgotten: the orchestrator already recorded them.
1852 */
1853 const settleRequest = Effect.fnUntraced(function* (
1854 entry: PendingRequest,
1855 status: "resolved" | "cancelled",
1856 ) {
1857 if (!(yield* forgetRequest(entry)) || entry.answering) return;
1858 const now = yield* DateTime.now;
1859 const itemStatus = status === "resolved" ? "completed" : "cancelled";
1860 yield* emit({
1861 type: "runtime_request.updated",
1862 driver,
1863 threadId: entry.turn.input.threadId,
1864 runtimeRequest: { ...entry.request, status, resolvedAt: now },
1865 });
1866 yield* emit({
1867 type: "node.updated",
1868 driver,
1869 node: { ...entry.node, status: itemStatus, completedAt: now },
1870 });
1871 yield* emit({
1872 type: "turn_item.updated",
1873 driver,
1874 turnItem: { ...entry.item, status: itemStatus, completedAt: now, updatedAt: now },
1875 });
1876 });
1877
1878 /**
1879 * Stops a run no turn of T3's waits on (one a Stop left running, or one
1880 * found asking after a reconnect): a reject without a message and a
1881 * cancelled form both end OpenCode's execution. Its end is not a turn's.
1882 */
1883 const stopStaleRequest = (
1884 sessionId: string,
1885 native: { readonly type: "permission" | "form"; readonly id: string },
1886 ): Effect.Effect<void> =>
1887 native.type === "permission"
1888 ? client.permission
1889 .reply({
1890 sessionID: Session.ID.make(sessionId),
1891 requestID: Permission.ID.make(native.id),
1892 decision: "reject",
1893 })
1894 .pipe(
1895 Effect.catchTags(permissionGone),
1896 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1897 Effect.ignore({ log: true }),
1898 )
1899 : client.session.form
1900 .cancel({ sessionID: sessionId, formID: Form.ID.make(native.id) })
1901 .pipe(
1902 Effect.catchTags(formGone),
1903 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1904 Effect.ignore({ log: true }),
1905 );
1906
1907 /**
1908 * Stops what a session still waits on when this runtime first loads it:
1909 * T3 shows none of those requests (a restart or a closed session expired
1910 * them), and OpenCode would wait on them forever.
1911 */
1912 const stopLeftoverRequests = Effect.fnUntraced(function* (state: ThreadState) {
1913 const sessionID = Session.ID.make(state.sessionId);
1914 const listed = yield* Effect.all([
1915 client.permission.list({ sessionID }),
1916 client.session.form.list({ sessionID }),
1917 ]).pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT), Effect.option);
1918 if (listed._tag === "None") {
1919 return yield* Effect.logWarning("Could not list an OpenCode session's open requests.");
1920 }
1921 const [permissions, forms] = listed.value;
1922 if (permissions.length === 0 && forms.length === 0) return;
1923 // The stopped run's end is not the next turn's.
1924 state.unsettled = true;
1925 for (const request of permissions) {
1926 yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id });
1927 }
1928 for (const form of forms) {
1929 yield* stopStaleRequest(state.sessionId, { type: "form", id: form.id });
1930 }
1931 });
1932
1933 /**
1934 * Shows a permission ask or question form on the thread whose session (or
1935 * subagent session) asked, under that thread's running turn. A request no
1936 * turn of T3's is waiting on is left for OpenCode's own clients.
1937 */
1938 const showRequest = Effect.fnUntraced(function* (
1939 sessionId: string,
1940 native: PendingRequest["native"],
1941 body:
1942 | {
1943 readonly type: "approval_request";
1944 readonly requestKind: Extract<
1945 OrchestrationV2TurnItem,
1946 { type: "approval_request" }
1947 >["requestKind"];
1948 readonly prompt: string;
1949 readonly options: Extract<
1950 OrchestrationV2TurnItem,
1951 { type: "approval_request" }
1952 >["options"];
1953 }
1954 | {
1955 readonly type: "user_input_request";
1956 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
1957 },
1958 ) {
1959 const target = requestTurn(sessionId);
1960 if (target === undefined) return;
1961 const { state, turn } = target;
1962 if ([...pending.values()].some((entry) => entry.native.id === native.id)) return;
1963 const now = yield* DateTime.now;
1964 const requestId = yield* idAllocator.allocate.runtimeRequest({
1965 driver,
1966 providerTurnId: turn.providerTurn.id,
1967 nativeRequestId: native.id,
1968 });
1969 const nodeId = idAllocator.derive.approvalNode({ requestId });
1970 const request: OrchestrationV2RuntimeRequest = {
1971 id: requestId,
1972 nodeId,
1973 providerTurnId: turn.providerTurn.id,
1974 nativeRequestRef: ref(native.id),
1975 kind: body.type === "approval_request" ? body.requestKind : "user_input",
1976 status: "pending",
1977 responseCapability: { type: "live", providerSessionId: input.providerSessionId },
1978 createdAt: now,
1979 resolvedAt: null,
1980 };
1981 const node: OrchestrationV2ExecutionNode = {
1982 id: nodeId,
1983 threadId: turn.input.threadId,
1984 runId: turn.input.runId,
1985 parentNodeId: turn.input.rootNodeId,
1986 rootNodeId: turn.input.rootNodeId,
1987 kind: body.type,
1988 status: "waiting",
1989 countsForRun: false,
1990 providerThreadId: state.providerThread.id,
1991 providerTurnId: turn.providerTurn.id,
1992 nativeItemRef: ref(native.id),
1993 runtimeRequestId: requestId,
1994 checkpointScopeId: null,
1995 startedAt: now,
1996 completedAt: null,
1997 };
1998 const base = {
1999 id: idAllocator.derive.approvalTurnItem({ requestId }),
2000 threadId: turn.input.threadId,
2001 runId: turn.input.runId,
2002 nodeId,
2003 providerThreadId: state.providerThread.id,
2004 providerTurnId: turn.providerTurn.id,
2005 nativeItemRef: ref(native.id),
2006 parentItemId: null,
2007 ordinal: ordinalOf(turn, native.id),
2008 status: "waiting" as const,
2009 startedAt: now,
2010 completedAt: null,
2011 updatedAt: now,
2012 };
2013 const item: OrchestrationV2TurnItem =
2014 body.type === "approval_request"
2015 ? {
2016 ...base,
2017 title: native.type === "permission" ? native.action : null,
2018 type: "approval_request",
2019 requestId,
2020 requestKind: body.requestKind,
2021 prompt: body.prompt,
2022 ...(body.options === undefined ? {} : { options: body.options }),
2023 }
2024 : {
2025 ...base,
2026 title: "User input",
2027 type: "user_input_request",
2028 requestId,
2029 questions: body.questions,
2030 };
2031 pending.set(requestId, {
2032 request,
2033 item,
2034 node,
2035 state,
2036 turn,
2037 sessionId,
2038 answering: false,
2039 native,
2040 });
2041 yield* emit({ type: "node.updated", driver, node });
2042 yield* emit({
2043 type: "runtime_request.updated",
2044 driver,
2045 threadId: turn.input.threadId,
2046 runtimeRequest: request,
2047 });
2048 yield* emit({ type: "turn_item.updated", driver, turnItem: item });
2049 yield* setSessionStatus("waiting", null);
2050 });
2051
2052 const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) {
2053 // The asking session's own turn knows the tool: a subagent's session runs its own.
2054 const turn = threads.get(data.sessionID)?.active;
2055 const toolName =
2056 data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name;
2057 const save = data.save ?? [];
2058 yield* showRequest(
2059 data.sessionID,
2060 { type: "permission", id: data.id, action: data.action, resources: data.resources, save },
2061 {
2062 type: "approval_request",
2063 requestKind: openCodePermissionRequestKind(data.action, toolName),
2064 prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"),
2065 // "Always" in OpenCode saves a grant for the whole project, so the
2066 // session-wide choice is T3's own rule on this session instead.
2067 options: [
2068 { decision: "cancel", label: "Cancel" },
2069 { decision: "decline", label: "Decline" },
2070 ...(save.length > 0
2071 ? [
2072 {
2073 decision: "acceptForSession" as const,
2074 label: sessionGrantLabel(data.action, save),
2075 },
2076 ]
2077 : []),
2078 { decision: "accept", label: "Approve" },
2079 ],
2080 },
2081 );
2082 });
2083
2084 const onFormCreated = Effect.fnUntraced(function* (form: NativeForm) {
2085 const target = requestTurn(form.sessionID);
2086 if (target === undefined) return;
2087 const { state } = target;
2088 const mapped = formQuestions(form);
2089 if ("questions" in mapped) {
2090 return yield* showRequest(
2091 form.sessionID,
2092 { type: "form", id: form.id, form },
2093 { type: "user_input_request", questions: mapped.questions },
2094 );
2095 }
2096 // Cancelling ends OpenCode's execution as a user stop, so the turn is
2097 // failed here with the reason and that stop's end is skipped.
2098 yield* Effect.logWarning("Declined an OpenCode form T3 Code cannot show.", {
2099 reason: mapped.unsupported,
2100 });
2101 const cancelled = yield* deliver(
2102 client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }),
2103 );
2104 // The session that asked is the one blocked on the form and stopped by
2105 // its cancel: a subagent's own, whose parent reads its failed call and
2106 // goes on. A subagent's session has no Stop end to skip.
2107 const asker = threads.get(form.sessionID) ?? state;
2108 if (!cancelled) return yield* abandonRequest(asker, "form cancel failed");
2109 if (asker.subagent === undefined) asker.unsettled = true;
2110 yield* finishTurn(asker, {
2111 status: "failed",
2112 failure: makeProviderFailure({
2113 message: `OpenCode asked for ${mapped.unsupported}, which T3 Code can't show. The question was declined.`,
2114 class: "provider_error",
2115 }),
2116 });
2117 });
2118
2119 /**
2120 * A permission ask or question form, from the stream or read back after a
2121 * reconnect: shown under the turn it blocks, or stopped when it is the run
2122 * a Stop left behind, which nothing answers.
2123 */
2124 const onAsked = Effect.fnUntraced(function* (
2125 asked:
2126 | { readonly type: "permission"; readonly request: Permission.Request }
2127 | { readonly type: "form"; readonly form: NativeForm },
2128 ) {
2129 const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID;
2130 const state = ownerOf(asking);
2131 if (state === undefined) return;
2132 const target = requestTurn(asking);
2133 if (target !== undefined && !target.turn.awaitingStart) {
2134 if (asked.type === "permission") return yield* onPermissionAsked(asked.request);
2135 return yield* onFormCreated(asked.form);
2136 }
2137 if (state.unsettled || state.active?.awaitingStart === true) {
2138 yield* stopStaleRequest(
2139 asking,
2140 asked.type === "permission"
2141 ? { type: "permission", id: asked.request.id }
2142 : { type: "form", id: asked.form.id },
2143 );
2144 }
2145 });
2146
2147 const sessionOfEvent = (event: OpenCode2Client.OpenCode2StreamEvent) =>
2148 event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started"
2149 ? event.sessionID
2150 : "sessionID" in event.data && typeof event.data.sessionID === "string"
2151 ? event.data.sessionID
2152 : undefined;
2153
2154 const executionEnd = (type: string) =>
2155 type === "session.execution.succeeded" ||
2156 type === "session.execution.failed" ||
2157 type === "session.execution.interrupted";
2158
2159 /** A `subagent` call's item, made when the model starts it. */
2160 const startCall = Effect.fnUntraced(function* (
2161 state: ThreadState,
2162 turn: ActiveTurn,
2163 toolId: string,
2164 ) {
2165 const nativeId = `${turn.scope}${toolId}`;
2166 const call: SubagentCall = {
2167 toolId,
2168 nativeId,
2169 state,
2170 turn,
2171 ordinal: ordinalOf(turn, nativeId),
2172 startedAt: yield* DateTime.now,
2173 prompt: "",
2174 title: null,
2175 agent: undefined,
2176 model: null,
2177 background: false,
2178 child: undefined,
2179 status: "running",
2180 result: null,
2181 completedAt: null,
2182 };
2183 turn.usedSubagents = true;
2184 state.calls.set(toolId, call);
2185 yield* emitSubagent(call);
2186 });
2187
2188 /**
2189 * One event for a session's running turn: its text, tools, steps and end.
2190 * A `subagent` call is its item and child thread rather than a tool.
2191 */
2192 const onTurnEvent = Effect.fnUntraced(function* (
2193 state: ThreadState,
2194 turn: ActiveTurn,
2195 event: OpenCode2Client.OpenCode2StreamEvent,
2196 ) {
2197 switch (event.type) {
2198 case "session.text.started":
2199 case "session.reasoning.started":
2200 case "session.text.delta":
2201 case "session.reasoning.delta":
2202 case "session.text.ended":
2203 case "session.reasoning.ended": {
2204 const data = event.data;
2205 const kind = event.type.startsWith("session.text.") ? "text" : "reasoning";
2206 return yield* emitText(state, turn, data, kind, (text) =>
2207 "delta" in data ? text + data.delta : "text" in data ? data.text : text,
2208 );
2209 }
2210 case "session.tool.input.started":
2211 // Its form is the item the user answers; the tool call would repeat it.
2212 if (event.data.name === "question") return;
2213 if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id);
2214 turn.tools.set(event.data.id, { name: event.data.name, input: {} });
2215 turn.startedAt.set(event.data.id, yield* DateTime.now);
2216 return yield* emitTool(state, turn, event.data.id, "running");
2217 case "session.tool.called": {
2218 const call = state.calls.get(event.data.id);
2219 if (call !== undefined) {
2220 const { input } = event.data;
2221 call.prompt = stringField(input, "prompt") ?? call.prompt;
2222 call.title = stringField(input, "description") ?? call.title;
2223 call.agent = stringField(input, "agent");
2224 call.model = stringField(input, "model") ?? call.model;
2225 call.background = input["background"] === true;
2226 return yield* emitSubagent(call);
2227 }
2228 const tool = turn.tools.get(event.data.id);
2229 if (tool !== undefined) tool.input = event.data.input;
2230 return yield* emitTool(state, turn, event.data.id, "running");
2231 }
2232 case "session.tool.progress": {
2233 const call = state.calls.get(event.data.id);
2234 const childId = stringField(event.data.metadata, "sessionID");
2235 if (call !== undefined && childId !== undefined) yield* attachChild(call, childId);
2236 return;
2237 }
2238 case "session.tool.success": {
2239 const call = state.calls.get(event.data.id);
2240 if (call !== undefined) {
2241 const childId = stringField(event.data.metadata, "sessionID");
2242 if (childId !== undefined) yield* attachChild(call, childId);
2243 // A background call returns at launch; its report settles it.
2244 if (event.data.metadata?.["status"] === "running") return;
2245 return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content)));
2246 }
2247 const output = textOf(event.data.content);
2248 yield* emitTool(state, turn, event.data.id, "completed", {
2249 output,
2250 metadata: event.data.metadata,
2251 });
2252 turn.tools.delete(event.data.id);
2253 return;
2254 }
2255 case "session.tool.failed": {
2256 const aborted = event.data.error.type === "aborted";
2257 const call = state.calls.get(event.data.id);
2258 // A refused call (unknown agent, the nesting limit) is a failed subagent.
2259 if (call !== undefined) {
2260 return yield* settleCall(
2261 call,
2262 aborted ? "interrupted" : "failed",
2263 event.data.error.message,
2264 );
2265 }
2266 yield* emitTool(state, turn, event.data.id, aborted ? "interrupted" : "failed", {
2267 output: event.data.error.message,
2268 metadata: event.data.metadata,
2269 });
2270 turn.tools.delete(event.data.id);
2271 return;
2272 }
2273 // `/compact` and OpenCode's own compaction when the context fills
2274 // (`reason: "auto"`) both run inside a turn's execution.
2275 case "session.compaction.started":
2276 turn.compaction = {
2277 nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`,
2278 startedAt: yield* DateTime.now,
2279 };
2280 return yield* emitCompaction(state, turn, "running");
2281 case "session.compaction.ended":
2282 return yield* emitCompaction(state, turn, "completed", event.data.text);
2283 case "session.compaction.failed":
2284 return yield* emitCompaction(state, turn, "failed");
2285 case "session.step.ended":
2286 case "session.step.failed": {
2287 const tokens = event.data.tokens;
2288 if (tokens === undefined) return;
2289 turn.steps += 1;
2290 turn.lastStep = tokens;
2291 turn.usage.input += tokens.input + tokens.cache.read + tokens.cache.write;
2292 turn.usage.cached += tokens.cache.read;
2293 turn.usage.cacheWrite += tokens.cache.write;
2294 turn.usage.output += tokens.output + tokens.reasoning;
2295 turn.usage.reasoning += tokens.reasoning;
2296 return;
2297 }
2298 case "session.execution.started":
2299 // The execution that delivers steers the last one ended without.
2300 turn.heldEnd = undefined;
2301 return;
2302 case "session.execution.succeeded":
2303 return yield* endExecution(state, turn, {
2304 status: turn.interrupted ? "interrupted" : "completed",
2305 });
2306 case "session.execution.interrupted":
2307 return yield* endExecution(state, turn, { status: "interrupted" });
2308 case "session.execution.failed":
2309 return yield* endExecution(state, turn, {
2310 status: "failed",
2311 failure: makeProviderFailure({
2312 message: event.data.error.message,
2313 code: event.data.error.type,
2314 class: "provider_error",
2315 }),
2316 });
2317 default:
2318 return;
2319 }
2320 });
2321
2322 /**
2323 * An execution OpenCode started on a thread's session with no turn of T3's
2324 * running: the parent's answer to a background subagent's report. It is
2325 * held for the continuation turn it asks for, or stopped when it only
2326 * reports subagents a Stop ended.
2327 */
2328 /**
2329 * Takes the reports an execution OpenCode started on its own delivers. When
2330 * they all report subagents a Stop ended, that execution only answers the
2331 * Stop, so it is stopped and `stopped` is true.
2332 */
2333 const takeReports = Effect.fnUntraced(function* (state: ThreadState) {
2334 const delivered = [...state.reports.values()];
2335 state.reports.clear();
2336 const stopped =
2337 delivered.length > 0 &&
2338 delivered.every((entry) => state.stoppedChildren.has(entry.childId));
2339 for (const entry of delivered) state.stoppedChildren.delete(entry.childId);
2340 if (stopped) {
2341 state.unsettled = true;
2342 yield* client.session
2343 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
2344 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
2345 }
2346 return { delivered, stopped };
2347 });
2348
2349 const onWake = Effect.fnUntraced(function* (state: ThreadState) {
2350 const { delivered, stopped } = yield* takeReports(state);
2351 if (stopped) return;
2352 const wake: Wake = {
2353 events: [],
2354 running: true,
2355 reports: delivered.map((entry) => entry.report),
2356 detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"),
2357 dropped: false,
2358 after: delivered.at(-1)?.inboxId,
2359 first: delivered[0]?.inboxId,
2360 };
2361 state.wakes.push(wake);
2362 yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", {
2363 providerThreadId: state.providerThread.id,
2364 });
2365 yield* offerWake(state, wake);
2366 });
2367
2368 /** A background subagent's end, as OpenCode queues it for its parent. */
2369 const onReport = Effect.fnUntraced(function* (
2370 state: ThreadState,
2371 inboxId: string,
2372 payload: {
2373 readonly text: string;
2374 readonly metadata?: Readonly<Record<string, unknown>> | undefined;
2375 },
2376 ) {
2377 const childId = stringField(payload.metadata, "childID");
2378 if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return;
2379 const call = [...state.calls.values()].find(
2380 (candidate) => candidate.child?.sessionId === childId,
2381 );
2382 const outcome = reportOutcome(stringField(payload.metadata, "state"));
2383 state.reports.set(inboxId, {
2384 inboxId,
2385 childId,
2386 text: payload.text,
2387 report: {
2388 kind: "subagent",
2389 label: call?.title ?? stringField(payload.metadata, "description"),
2390 childThreadId: call?.child?.subagent?.appThread.id,
2391 outcome,
2392 },
2393 });
2394 if (call === undefined) return;
2395 yield* settleCall(
2396 call,
2397 state.stoppedChildren.has(childId)
2398 ? "interrupted"
2399 : outcome === "failed"
2400 ? "failed"
2401 : outcome === "cancelled"
2402 ? "cancelled"
2403 : "completed",
2404 subagentOutput(payload.text),
2405 );
2406 });
2407
2408 /**
2409 * The thread whose held execution an event belongs to: the thread's own
2410 * session, or a subagent session its held execution started.
2411 */
2412 const holderOf = (sessionId: string) => {
2413 const own = threads.get(sessionId);
2414 if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined;
2415 // A subagent a held execution started is named only when that execution replays.
2416 const parent = announced.get(sessionId)?.parentID;
2417 const holder = parent === undefined ? undefined : threads.get(parent);
2418 return holder !== undefined && holder.active === undefined && holder.wakes.length > 0
2419 ? holder
2420 : undefined;
2421 };
2422
2423 /**
2424 * Ends the turn with its execution, unless a steer sent into it is still
2425 * undelivered: OpenCode then starts another execution for it, which the
2426 * turn owns too. A Stop leaves steers undelivered and starts nothing.
2427 */
2428 const endExecution = Effect.fnUntraced(function* (
2429 state: ThreadState,
2430 turn: ActiveTurn,
2431 end: TurnTerminal,
2432 ) {
2433 if (end.status === "completed" && turn.steers.size > 0) {
2434 turn.heldEnd = end;
2435 return;
2436 }
2437 yield* finishTurn(state, end);
2438 });
2439
2440 /** Ends a turn held for its steers once none is left to deliver. */
2441 const endIfSettled = (state: ThreadState, turn: ActiveTurn) =>
2442 turn.heldEnd !== undefined && turn.steers.size === 0
2443 ? finishTurn(state, turn.heldEnd)
2444 : Effect.void;
2445
2446 const handleEvent = Effect.fnUntraced(function* (event: OpenCode2Client.OpenCode2StreamEvent) {
2447 const sessionId = sessionOfEvent(event);
2448 // `revert.clear` wakes the session into an empty execution of its own
2449 // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of
2450 // T3's, and no follow-up to a subagent either.
2451 const cleared = sessionId === undefined ? undefined : clearing.get(sessionId);
2452 if (cleared !== undefined) {
2453 if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) {
2454 return yield* Deferred.succeed(cleared, undefined);
2455 }
2456 if (
2457 event.type === "session.execution.started" ||
2458 event.type === "unreadable.execution.started"
2459 ) {
2460 return;
2461 }
2462 }
2463 if (sessionId !== undefined) {
2464 if (
2465 event.type === "session.execution.started" ||
2466 event.type === "unreadable.execution.started"
2467 ) {
2468 busy.add(sessionId);
2469 } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) {
2470 busy.delete(sessionId);
2471 }
2472 }
2473 // A subagent's session: named on its parent's `subagent` call just after.
2474 if (event.type === "session.created" && event.data.parentID !== undefined) {
2475 const owner = ownerOf(event.data.parentID);
2476 if (owner !== undefined) {
2477 childOwners.set(event.data.sessionID, owner);
2478 announced.set(event.data.sessionID, event.data);
2479 }
2480 return;
2481 }
2482 // What a held execution does, and what the subagents it starts do,
2483 // waits for the turn that takes it.
2484 const holder = sessionId === undefined ? undefined : holderOf(sessionId);
2485 const held = holder?.wakes.at(-1);
2486 if (holder !== undefined && held !== undefined) {
2487 held.events.push(event);
2488 if (
2489 sessionId === holder.sessionId &&
2490 (event.type === "unreadable.execution.ended" || executionEnd(event.type))
2491 ) {
2492 held.running = false;
2493 }
2494 return;
2495 }
2496 return yield* route(event, sessionId);
2497 });
2498
2499 /** Handles one event for its session, as the turn running there sees it. */
2500 const route = Effect.fnUntraced(function* (
2501 event: OpenCode2Client.OpenCode2StreamEvent,
2502 sessionId: string | undefined,
2503 ) {
2504 // The end of the run a timed-out Stop left behind; no turn is its own.
2505 const ended =
2506 event.type === "unreadable.execution.ended" || executionEnd(event.type)
2507 ? threads.get(sessionId ?? "")
2508 : undefined;
2509 if (ended?.unsettled === true) {
2510 ended.unsettled = false;
2511 return;
2512 }
2513 // Marks where a running turn's own execution begins; it never ends one.
2514 // With no turn running it is a subagent's or a follow-up's start, below.
2515 if (event.type === "unreadable.execution.started") {
2516 const turn = threads.get(event.sessionID)?.active;
2517 if (turn !== undefined) {
2518 turn.awaitingStart = false;
2519 turn.heldEnd = undefined;
2520 return;
2521 }
2522 }
2523 if (event.type === "unreadable.execution.ended") {
2524 const state = threads.get(event.sessionID);
2525 const turn = state?.active;
2526 if (state === undefined || turn === undefined || turn.awaitingStart) return;
2527 return yield* endExecution(
2528 state,
2529 turn,
2530 event.executionType === "session.execution.succeeded"
2531 ? { status: turn.interrupted ? "interrupted" : "completed" }
2532 : event.executionType === "session.execution.interrupted"
2533 ? { status: "interrupted" }
2534 : {
2535 status: "failed",
2536 failure: makeProviderFailure({
2537 message: "OpenCode ended the turn with an error this version cannot read.",
2538 class: "provider_error",
2539 }),
2540 },
2541 );
2542 }
2543 if (event.type === "permission.asked") {
2544 return yield* onAsked({ type: "permission", request: event.data });
2545 }
2546 if (event.type === "form.created")
2547 return yield* onAsked({ type: "form", form: event.data.form });
2548 // Answered in another OpenCode client, or dropped by OpenCode: a reject
2549 // it sends on its own (a Stop, or another reject in the same session)
2550 // cancels the request. T3's own answers are settled where they are sent.
2551 if (
2552 event.type === "permission.replied" ||
2553 event.type === "form.replied" ||
2554 event.type === "form.cancelled"
2555 ) {
2556 const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id;
2557 const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId);
2558 if (entry === undefined || entry.answering) return;
2559 const answered =
2560 event.type === "form.replied" ||
2561 (event.type === "permission.replied" && event.data.reply !== "reject");
2562 return yield* settleRequest(entry, answered ? "resolved" : "cancelled");
2563 }
2564 if (sessionId === undefined) return;
2565 const state = threads.get(sessionId);
2566 if (state === undefined) return;
2567 if (event.type === "session.model.selected" || event.type === "session.step.started") {
2568 if (event.type === "session.model.selected") state.model = event.data.model;
2569 const providerThread = withReportedModel(state.providerThread, event.data.model);
2570 if (providerThread !== state.providerThread) {
2571 state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now };
2572 yield* emit({
2573 type: "provider_thread.updated",
2574 driver,
2575 providerThread: state.providerThread,
2576 });
2577 }
2578 }
2579 if (
2580 event.type === "session.inbox.enqueued" &&
2581 state.subagent !== undefined &&
2582 state.active === undefined
2583 ) {
2584 state.subagent.queued ??= event.data.inboxID;
2585 }
2586 if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") {
2587 return yield* onReport(state, event.data.inboxID, event.data.item.payload);
2588 }
2589 // Cancelled, or delivered into a turn already running: no wake reports it.
2590 // A steer of the running turn's is settled the same way.
2591 if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") {
2592 state.reports.delete(event.data.inboxID);
2593 const turn = state.active;
2594 if (turn !== undefined && turn.steers.delete(event.data.inboxID)) {
2595 turn.settledInbox.add(event.data.inboxID);
2596 // A cancelled steer may leave a held turn nothing to wait for.
2597 if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn);
2598 }
2599 return;
2600 }
2601 // A subagent's later prompts (a resumed subagent) open its next turn's message.
2602 if (
2603 event.type === "session.inbox.enqueued" &&
2604 event.data.item.type === "user" &&
2605 state.subagent !== undefined &&
2606 state.subagent.prompt === undefined &&
2607 state.active === undefined
2608 ) {
2609 state.subagent.prompt = event.data.item.payload.text;
2610 return;
2611 }
2612 const started =
2613 event.type === "session.execution.started" || event.type === "unreadable.execution.started";
2614 // Each execution of a subagent's session is a turn on its child thread,
2615 // unless it only answers the reports of nested subagents a Stop ended.
2616 if (state.subagent !== undefined && state.active === undefined && started) {
2617 if ((yield* takeReports(state)).stopped) {
2618 state.subagent.queued = undefined;
2619 return;
2620 }
2621 return yield* startChildTurn(state);
2622 }
2623 const turn = state.active;
2624 if (turn === undefined) {
2625 // OpenCode started the thread's session on its own.
2626 if (started && state.subagent === undefined) return yield* onWake(state);
2627 return;
2628 }
2629 // A session runs one execution at a time, and each opens with `started`
2630 // on this ordered stream, so what comes before it is the stopped run's.
2631 if (turn.awaitingStart) {
2632 if (event.type === "session.execution.started") turn.awaitingStart = false;
2633 return;
2634 }
2635 return yield* onTurnEvent(state, turn, event);
2636 });
2637
2638 // The stream is the only terminal signal, and it is volatile: events sent
2639 // while it is down are gone, and a restarted server never ends the
2640 // execution it lost. So a lost stream reconnects, then reconciles each
2641 // running turn from the server's own state (see `reconcile`). Only when
2642 // reconnecting keeps failing are the turns failed and the session broken,
2643 // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses.
2644 let streamFailure: string | undefined;
2645 let reconnected = yield* Deferred.make<void>();
2646 const failAll = Effect.fnUntraced(function* (message: string) {
2647 streamFailure = message;
2648 yield* Deferred.succeed(reconnected, undefined);
2649 for (const state of threads.values()) {
2650 const failure = makeProviderFailure({ message, class: "transport_error" });
2651 yield* finishTurn(state, { status: "failed", failure }, "broken");
2652 }
2653 // Nothing can report a background subagent's end any more.
2654 for (const state of threads.values()) {
2655 for (const call of runningCalls(state)) yield* settleCall(call, "failed");
2656 for (const wake of state.wakes.splice(0)) wake.dropped = true;
2657 state.reports.clear();
2658 }
2659 yield* setSessionStatus("error", message);
2660 yield* Queue.end(events);
2661 });
2662 /**
2663 * Emits what a running turn missed while the stream was down, from the
2664 * session's history since the turn began: text, reasoning and tools, each
2665 * under the same native id its live events would have used, so nothing
2666 * already shown is duplicated. A turn begins at its prompt, whose id T3
2667 * chose (`nativeTurnRef`), or after `before` for a `/name` command or a
2668 * continuation.
2669 * Returns how that history says the turn's execution ended: the `idle` item
2670 * OpenCode appends after each one, or undefined when there is none after
2671 * the turn's start.
2672 */
2673 const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) {
2674 const turn = state.active;
2675 if (turn === undefined) return undefined;
2676 const promptId = promptOf(turn.providerTurn);
2677 const { before } = turn;
2678 if (promptId === undefined && before === undefined) return undefined;
2679 const start = before !== undefined ? before : promptId;
2680 const read = yield* OpenCode2Client.paginate(
2681 { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 },
2682 client.message.list,
2683 ).pipe(
2684 Stream.takeUntil((message) => message.id === start),
2685 Stream.runCollect,
2686 );
2687 // Without its start the history holds only earlier turns (the stream
2688 // dropped before the prompt landed): none of it is this turn's. A null
2689 // `before` is a command that started on an empty history.
2690 const found = start === null || read.at(-1)?.id === start;
2691 if (!found) return undefined;
2692 const recent = before !== undefined && before !== null ? read.slice(0, -1) : read;
2693 const idle = recent.find((message) => message.type === "idle");
2694 for (const message of recent.toReversed()) {
2695 if (message.type !== "assistant" || state.active !== turn) continue;
2696 const ordinals = { text: 0, reasoning: 0 };
2697 for (const part of message.content) {
2698 if (part.type === "text" || part.type === "reasoning") {
2699 const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ };
2700 yield* emitText(state, turn, block, part.type, () => part.text, true);
2701 continue;
2702 }
2703 if (part.type !== "tool") continue;
2704 if (!turn.tools.has(part.id)) {
2705 turn.tools.set(part.id, { name: part.name, input: {} });
2706 turn.startedAt.set(part.id, yield* DateTime.now);
2707 }
2708 const tool = turn.tools.get(part.id)!;
2709 if (typeof part.state.input === "object" && part.state.input !== null) {
2710 tool.input = part.state.input as Record<string, unknown>;
2711 }
2712 if (part.state.status === "completed") {
2713 yield* emitTool(state, turn, part.id, "completed", {
2714 output: textOf(part.state.content),
2715 metadata: part.state.metadata,
2716 });
2717 turn.tools.delete(part.id);
2718 } else if (part.state.status === "error") {
2719 yield* emitTool(state, turn, part.id, "failed", {
2720 output: part.state.error.message,
2721 metadata: part.state.metadata,
2722 });
2723 turn.tools.delete(part.id);
2724 } else {
2725 yield* emitTool(state, turn, part.id, "running");
2726 }
2727 }
2728 }
2729 return idle === undefined ? undefined : { outcome: idle.outcome };
2730 });
2731
2732 /**
2733 * Settles each thread turn that was running when the stream dropped, from
2734 * the server: a session still running keeps its turn (its next events
2735 * arrive on the new stream); one that stopped ends the turn with the
2736 * outcome of the `idle` item after the turn's start. A restarted server
2737 * writes no such item for the execution it lost, and `session.outcome` is
2738 * still the previous execution's, so no `idle` means the turn was
2739 * interrupted. A continuation turn is settled the same way, from the report
2740 * its execution answers. A turn still holding for an undelivered steer only
2741 * ends once the session is idle. Permission asks and question forms are
2742 * read back from every session still running, so a run that asked during
2743 * the gap can be answered.
2744 *
2745 * What the stream carried outside a turn is not read back: a background
2746 * subagent's report, a follow-up OpenCode started on its own, or one held
2747 * for its continuation turn. So background work is settled, not resumed: a
2748 * subagent whose session stopped ends as interrupted, and a thread's held
2749 * follow-ups and queued reports are dropped, so nothing waits on an event
2750 * that already went by.
2751 */
2752 const reconcile = Effect.gen(function* () {
2753 // A subagent's turn ends before the turn that waits on it, so the
2754 // deepest sessions are read back first.
2755 const running = [...threads]
2756 .filter(([, state]) => state.active !== undefined)
2757 .toSorted(([left], [right]) => callsAbove(right).length - callsAbove(left).length);
2758 const background = [...threads.values()].filter(
2759 (state) => state.subagent === undefined && hasBackground(state),
2760 );
2761 if (running.length === 0 && background.length === 0 && busy.size === 0) return;
2762 const active = yield* client.session.active();
2763 // An execution that ended while the stream was down never said so.
2764 for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId);
2765 for (const [sessionId, state] of running) {
2766 const turn = state.active;
2767 if (turn === undefined) continue;
2768 const ended = yield* backfill(sessionId, state);
2769 if (sessionId in active || state.active !== turn) continue;
2770 yield* finishTurn(
2771 state,
2772 ended?.outcome === "succeeded"
2773 ? { status: turn.interrupted ? "interrupted" : "completed" }
2774 : ended?.outcome === "failed"
2775 ? {
2776 status: "failed",
2777 failure: makeProviderFailure({
2778 message:
2779 "OpenCode ended the turn with an error while T3 Code was reconnecting.",
2780 class: "provider_error",
2781 }),
2782 }
2783 : { status: "interrupted" },
2784 );
2785 }
2786 for (const state of background) {
2787 // A subagent still running keeps its call; its end arrives on the new stream.
2788 for (const call of runningCalls(state)) {
2789 const child = call.child?.sessionId;
2790 if (child === undefined || !(child in active)) {
2791 yield* settleCall(call, "interrupted", LOST_BACKGROUND);
2792 }
2793 }
2794 // A subagent's session holds reports of its own background subagents.
2795 for (const session of sessionsOf(state)) {
2796 for (const wake of session.wakes.splice(0)) wake.dropped = true;
2797 session.reports.clear();
2798 }
2799 }
2800 // A request asked while the stream was down was never shown, and the
2801 // run waits on it; one T3 shows that OpenCode no longer lists was
2802 // answered elsewhere or dropped with its execution. A session that
2803 // stopped dropped its requests.
2804 const listed = new Map<string, Set<string>>();
2805 for (const sessionId of Object.keys(active)) {
2806 if (ownerOf(sessionId) === undefined) continue;
2807 const sessionID = Session.ID.make(sessionId);
2808 const [permissions, forms] = yield* Effect.all([
2809 client.permission.list({ sessionID }),
2810 client.session.form.list({ sessionID }),
2811 ]);
2812 listed.set(sessionId, new Set([...permissions, ...forms].map((request) => request.id)));
2813 const shown = new Set([...pending.values()].map((entry) => entry.native.id));
2814 for (const request of permissions) {
2815 if (!shown.has(request.id)) yield* onAsked({ type: "permission", request });
2816 }
2817 for (const form of forms) {
2818 if (!shown.has(form.id)) yield* onAsked({ type: "form", form });
2819 }
2820 }
2821 // A snapshot: settling a request removes it from the map.
2822 for (const entry of Array.from(pending.values())) {
2823 if (listed.get(entry.sessionId)?.has(entry.native.id) !== true) {
2824 yield* settleRequest(entry, "cancelled");
2825 }
2826 }
2827 });
2828
2829 /**
2830 * Subscribes again, on a restarted server if the old one is gone, and
2831 * reconciles. Retried a few times; the caller fails everything after that.
2832 */
2833 const reconnect = Effect.gen(function* () {
2834 // Returned on any failure, including the interrupt a closing session
2835 // sends a reconnect still in flight, so a spawned server can idle-stop.
2836 const scope = yield* Scope.make();
2837 const { next, stream } = yield* Effect.gen(function* () {
2838 const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope));
2839 return { next, stream: yield* next.events };
2840 }).pipe(Effect.onError(() => Scope.close(scope, Exit.void)));
2841 const previous = currentScope;
2842 connection = next;
2843 client = next.client;
2844 currentScope = scope;
2845 yield* Scope.close(previous, Exit.void);
2846 // A restarted server forgot T3's MCP servers; the next turn adds them again.
2847 for (const state of threads.values()) state.mcp = undefined;
2848 yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT));
2849 return stream;
2850 }).pipe(
2851 Effect.retry({ times: RECONNECT_ATTEMPTS - 1, schedule: Schedule.spaced(RECONNECT_DELAY) }),
2852 );
2853
2854 let currentScope = initial.scope;
2855 // The borrow in use when the session closes is returned with it, so a
2856 // spawned server can still reach its idle shutdown.
2857 yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void));
2858 const follow = (
2859 stream: Stream.Stream<OpenCode2Client.OpenCode2StreamEvent, unknown>,
2860 ): Effect.Effect<void> =>
2861 stream.pipe(
2862 Stream.runForEach((event) => lock.withPermit(handleEvent(event))),
2863 Effect.exit,
2864 Effect.flatMap(() =>
2865 Effect.gen(function* () {
2866 streamFailure = "The OpenCode event stream was lost. Reconnecting.";
2867 yield* Effect.logWarning("Lost the OpenCode event stream; reconnecting.");
2868 // The last failure is kept for the log: it can carry the server's
2869 // URL or response text, so only its tag is annotated and the full
2870 // failure stays in the log's cause.
2871 const next = yield* reconnect.pipe(
2872 Effect.tapCause((cause) =>
2873 Effect.logWarning(
2874 "Could not reconnect to the OpenCode event stream; failing the session.",
2875 cause,
2876 ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })),
2877 ),
2878 Effect.option,
2879 );
2880 if (next._tag === "None") {
2881 return yield* failAll("The OpenCode event stream was lost and could not reconnect.");
2882 }
2883 streamFailure = undefined;
2884 const done = reconnected;
2885 reconnected = yield* Deferred.make<void>();
2886 yield* Deferred.succeed(done, undefined);
2887 return yield* follow(next.value);
2888 }),
2889 ),
2890 );
2891 // Subscribed before any session or prompt call, so no event of theirs is missed.
2892 yield* follow(yield* connection.events).pipe(Effect.forkScoped);
2893
2894 // A server T3 did not start keeps running after T3 stops, so stop the turns
2895 // it would otherwise finish unseen. A spawned server stops with its owner.
2896 if (connection.external) {
2897 yield* Effect.addFinalizer(() =>
2898 Effect.forEach(
2899 [...threads].filter(([, state]) => state.active !== undefined),
2900 ([sessionId]) =>
2901 client.session
2902 .interrupt({ sessionID: Session.ID.make(sessionId) })
2903 .pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })),
2904 { concurrency: 8, discard: true },
2905 ),
2906 );
2907 }
2908
2909 // Context windows come from the server's model list for a directory, read
2910 // when the session opens, before a thread first runs in another directory,
2911 // and again after a turn whose model had none yet. A directory counts as
2912 // known once its read starts, so a failed read is retried only after a turn.
2913 const readModels = (directory: string) =>
2914 Effect.suspend(() => {
2915 const windows = contextWindows.get(directory) ?? new Map<string, number>();
2916 contextWindows.set(directory, windows);
2917 return client.model.list({ location: { directory } }).pipe(
2918 Effect.timeout("5 seconds"),
2919 Effect.tap((models) =>
2920 Effect.sync(() => {
2921 for (const model of models.data) {
2922 // A model's input limit, when it has one, is its real headroom.
2923 windows.set(
2924 `${model.providerID}/${model.id}`,
2925 model.limit.input ?? model.limit.context,
2926 );
2927 }
2928 }),
2929 ),
2930 Effect.ignore({ log: true }),
2931 );
2932 });
2933 const readModelsOnce = (cwd: string | null | undefined) =>
2934 contextWindows.has(directoryOf(cwd)) ? Effect.void : readModels(directoryOf(cwd));
2935 yield* readModels(session.cwd);
2936
2937 // Each agent's own path allows, by the directory they were listed for.
2938 const agentRules = new Map<string, ReadonlyMap<string, ReadonlyArray<Rule>>>();
2939 const pathsFor = Effect.fnUntraced(function* (
2940 directory: string,
2941 agents: ReadonlyArray<string>,
2942 ) {
2943 let known = agentRules.get(directory);
2944 if (known === undefined) {
2945 // A fresh server lists no agents for its first moments, like its models.
2946 const listed = yield* client.agent.list({ location: { directory } }).pipe(
2947 Effect.repeat({
2948 until: (list) => list.data.length > 0,
2949 schedule: Schedule.spaced("250 millis"),
2950 }),
2951 Effect.timeout("5 seconds"),
2952 // The failure can carry the server's URL or response text, so only its
2953 // tag is annotated; the full failure stays in the log's cause.
2954 Effect.tapCause((cause) =>
2955 Effect.logWarning(
2956 "Could not list OpenCode agents; their path rules are skipped.",
2957 cause,
2958 ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })),
2959 ),
2960 Effect.option,
2961 );
2962 if (listed._tag === "None") return [];
2963 known = new Map(
2964 listed.value.data.map((agent) => [agent.id, agentPaths(agent.permissions)]),
2965 );
2966 agentRules.set(directory, known);
2967 }
2968 const paths = new Map<string, Rule>();
2969 for (const agent of agents) {
2970 for (const entry of known.get(agent) ?? []) {
2971 paths.set(`${entry.action}\u0000${entry.resource}`, entry);
2972 }
2973 }
2974 return [...paths.values()];
2975 });
2976
2977 /**
2978 * The rules a thread's session runs `policy` with. Full access allows
2979 * every path already, so the agent's own path rules are read only for the
2980 * modes that narrow it.
2981 */
2982 const rulesFor = Effect.fnUntraced(function* (
2983 thread: Pick<ThreadState, "directory" | "agent" | "grants">,
2984 policy: RulesPolicy,
2985 appThreadId: string | null,
2986 ) {
2987 const plan = policy.interactionMode === "plan";
2988 const paths =
2989 policy.runtimeMode === "full-access" && !plan
2990 ? []
2991 : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]);
2992 return sessionRules(
2993 policy,
2994 paths,
2995 policy.runtimeMode === "full-access" ? [] : thread.grants,
2996 appThreadId === null ? null : yield* mcpServerNameFor(appThreadId),
2997 );
2998 });
2999
3000 /** Writes the session's rules for `policy` when they differ from what it has. */
3001 const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) {
3002 // A subagent's session may use its thread's T3 server, the root's.
3003 const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId);
3004 if (!sameRules(state.rules, rules)) {
3005 yield* client.session.update({
3006 sessionID: Session.ID.make(state.sessionId),
3007 permissions: rules,
3008 });
3009 state.rules = rules;
3010 }
3011 state.policy = policy;
3012 });
3013
3014 const register = (
3015 providerThread: OrchestrationV2ProviderThread,
3016 native: {
3017 readonly id: string;
3018 readonly model?: ModelRef | undefined;
3019 readonly agent?: string | undefined;
3020 readonly permissions?: ReadonlyArray<Rule> | undefined;
3021 },
3022 directory: string,
3023 ) => {
3024 providerThread = withReportedModel(providerThread, native.model);
3025 const existing = threads.get(native.id);
3026 if (existing !== undefined) {
3027 existing.providerThread = providerThread;
3028 existing.model = native.model;
3029 existing.directory = directory;
3030 existing.agent = native.agent ?? existing.agent;
3031 existing.rules = native.permissions;
3032 return existing;
3033 }
3034 const state = newThreadState(native.id, providerThread, directory, undefined);
3035 state.model = native.model;
3036 state.agent = native.agent ?? state.agent;
3037 state.rules = native.permissions;
3038 threads.set(native.id, state);
3039 return state;
3040 };
3041
3042 /**
3043 * Refuses to cut or copy a session's history while something writes to
3044 * it: a turn of T3's, an execution seen running on the stream (a held
3045 * follow-up), or a run on the server this runtime does not own: one a
3046 * timed-out Stop or an unanswered request left behind, or any run on a
3047 * session loaded after the server outlived T3. Only that last case asks
3048 * the server. A rollback also waits for background subagents, whose
3049 * reports wake the session into the history it would cut.
3050 */
3051 const ensureQuiet = Effect.fnUntraced(function* (
3052 sessionId: string,
3053 verb: "roll back" | "fork",
3054 ) {
3055 const state = threads.get(sessionId);
3056 const busyHere =
3057 state !== undefined &&
3058 (state.active !== undefined ||
3059 busy.has(sessionId) ||
3060 (verb === "roll back" && hasBackground(state)));
3061 const busyThere =
3062 !busyHere &&
3063 (state === undefined || state.unsettled) &&
3064 sessionId in (yield* client.session.active().pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT)));
3065 if (busyHere || busyThere) {
3066 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3067 driver: OPENCODE_PROVIDER,
3068 detail: `Cannot ${verb} OpenCode session ${sessionId} while it is still working`,
3069 });
3070 }
3071 });
3072
3073 /**
3074 * Takes back steers a finished turn left in OpenCode's inbox. One whose
3075 * cancel fails may still be there, so it is tried again before the next prompt.
3076 */
3077 const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) {
3078 const sessionID = Session.ID.make(state.sessionId);
3079 // A snapshot: a cancelled steer leaves the set.
3080 for (const inboxID of Array.from(state.strandedSteers)) {
3081 const cancelled = yield* client.session.inbox
3082 .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) })
3083 .pipe(
3084 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3085 Effect.tapCause((cause) =>
3086 Effect.logWarning("Could not take back an OpenCode steer.", cause),
3087 ),
3088 Effect.exit,
3089 Effect.map(Exit.isSuccess),
3090 );
3091 if (cancelled) state.strandedSteers.delete(inboxID);
3092 }
3093 });
3094
3095 /**
3096 * Clears a staged revert and waits out the empty execution `clear` runs,
3097 * which is no turn of T3's. 2.0.18 wakes the session after every clear,
3098 * with or without a stage (seen live), so that execution always comes.
3099 */
3100 const clearRevert = Effect.fnUntraced(function* (sessionId: string) {
3101 const settled = yield* Deferred.make<void>();
3102 clearing.set(sessionId, settled);
3103 yield* client.session.revert
3104 .clear({ sessionID: Session.ID.make(sessionId) })
3105 .pipe(
3106 Effect.andThen(Deferred.await(settled)),
3107 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3108 Effect.ensuring(Effect.sync(() => clearing.delete(sessionId))),
3109 );
3110 stagedReverts.delete(sessionId);
3111 });
3112
3113 /**
3114 * Truncates the session's history before `boundary`: OpenCode stages the
3115 * revert, then commits it. If either fails or is interrupted the stage is
3116 * cleared, since OpenCode commits a staged revert on the next prompt; a
3117 * clear that fails too is tried again before that prompt. Files stay where
3118 * they are: T3 restores its own checkpoint when the user asked for files,
3119 * and a rewind without files must not touch them.
3120 */
3121 const rollBackTo = Effect.fnUntraced(function* (
3122 sessionId: string,
3123 boundary: SessionMessage.ID,
3124 ) {
3125 const sessionID = Session.ID.make(sessionId);
3126 // Each step is bounded: a server that never answers would otherwise
3127 // hold the session's gate, and the next turn with it.
3128 yield* client.session.revert.stage({ sessionID, messageID: boundary, files: false }).pipe(
3129 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3130 Effect.andThen(
3131 client.session.revert.commit({ sessionID }).pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT)),
3132 ),
3133 Effect.onError(() =>
3134 clearRevert(sessionId).pipe(
3135 Effect.tapError(() => Effect.sync(() => stagedReverts.add(sessionId))),
3136 Effect.ignore({ log: true }),
3137 ),
3138 ),
3139 );
3140 });
3141
3142 /**
3143 * The ids fork and rollback can cut before: the session's prompts, and the
3144 * reports continuations answer. The list takes one type, so it reads all.
3145 */
3146 const userMessages = (sessionId: string) =>
3147 OpenCode2Client.paginate(
3148 { sessionID: Session.ID.make(sessionId), limit: 100 },
3149 client.message.list,
3150 ).pipe(
3151 Stream.runCollect,
3152 Effect.map(
3153 (messages) =>
3154 new Set(
3155 messages.flatMap((message) =>
3156 message.type === "user" || message.type === "synthetic" ? [String(message.id)] : [],
3157 ),
3158 ),
3159 ),
3160 );
3161
3162 /** The session's history as V2 messages, read after any change T3 made to it. */
3163 const snapshotOf = Effect.fnUntraced(function* (
3164 providerThread: OrchestrationV2ProviderThread,
3165 sessionId: string,
3166 ) {
3167 const history = yield* OpenCode2Client.paginate(
3168 { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 },
3169 client.message.list,
3170 ).pipe(Stream.runCollect);
3171 const snapshotAt = yield* DateTime.now;
3172 const messages = history.flatMap((message): Array<OrchestrationV2ConversationMessage> => {
3173 const text =
3174 message.type === "user"
3175 ? message.text
3176 : message.type === "assistant"
3177 ? textOf(message.content)
3178 : "";
3179 if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) {
3180 return [];
3181 }
3182 const createdAt = message.time.created;
3183 return [
3184 {
3185 createdBy: message.type === "user" ? "user" : "agent",
3186 creationSource: "provider",
3187 id: idAllocator.derive.messageFromProviderItem({
3188 driver,
3189 nativeItemId: message.id,
3190 }),
3191 threadId: providerThread.appThreadId ?? input.threadId,
3192 runId: null,
3193 nodeId: null,
3194 role: message.type,
3195 text,
3196 attachments: [],
3197 streaming: false,
3198 createdAt,
3199 updatedAt: createdAt,
3200 },
3201 ];
3202 });
3203 const lastUser = history.findLast((message) => message.type === "user")?.id;
3204 const state = threads.get(sessionId);
3205 return {
3206 providerThread: {
3207 ...providerThread,
3208 providerSessionId: input.providerSessionId,
3209 nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"),
3210 status: "idle" as const,
3211 updatedAt: snapshotAt,
3212 },
3213 providerTurns: state === undefined ? [] : [...state.providerTurns.values()],
3214 messages,
3215 runtimeRequests: [],
3216 };
3217 });
3218
3219 const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) =>
3220 providerMessageTextWithAttachmentPaths({
3221 text: turnInput.message.text,
3222 attachments: turnInput.message.attachments,
3223 resolveAttachmentPath: host.resolveAttachmentPath,
3224 }).trim();
3225
3226 const removeMcp = (mcp: { readonly name: string; readonly directory: string }) =>
3227 Effect.suspend(() =>
3228 client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }),
3229 ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true }));
3230
3231 // T3's MCP registrations outlive a session only on an external server; a
3232 // spawned one forgets them when it stops.
3233 yield* Effect.addFinalizer(() =>
3234 Effect.forEach(
3235 [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])),
3236 removeMcp,
3237 { concurrency: 8, discard: true },
3238 ),
3239 );
3240
3241 /**
3242 * T3's MCP server for this thread, and the per-turn instructions. The MCP
3243 * server is registered for the session's directory under the thread's own
3244 * name and credential (the session rules allow only it), and removed when
3245 * the thread unloads or the session closes. OpenCode 2 has no per-prompt
3246 * system field, so the instructions are a session instructions entry,
3247 * which applies from the next step; it is only rewritten when it changes.
3248 */
3249 const prepareTurn = Effect.fnUntraced(function* (
3250 sessionId: string,
3251 state: ThreadState,
3252 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3253 ) {
3254 const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId);
3255 const directory = turnInput.runtimePolicy.cwd ?? host.paths.cwd;
3256 const name = yield* mcpServerNameFor(turnInput.threadId);
3257 // An external server may not reach T3's MCP endpoint, as with 1.x.
3258 const wanted =
3259 mcpSession === undefined || connection.external
3260 ? undefined
3261 : { name, directory, credential: mcpSession.authorizationHeader };
3262 if (
3263 state.mcp !== undefined &&
3264 (wanted === undefined ||
3265 state.mcp.directory !== wanted.directory ||
3266 state.mcp.credential !== wanted.credential)
3267 ) {
3268 yield* removeMcp(state.mcp);
3269 state.mcp = undefined;
3270 }
3271 // T3's tools are an addition: a server that cannot add them still runs the turn.
3272 if (wanted !== undefined && state.mcp === undefined) {
3273 const added = yield* client.mcp
3274 .add({
3275 server: name,
3276 location: { directory },
3277 config: new Mcp.RemoteConfig({
3278 type: "remote",
3279 url: mcpSession!.endpoint,
3280 headers: { Authorization: wanted.credential },
3281 oauth: false,
3282 }),
3283 })
3284 .pipe(
3285 Effect.timeout(INVENTORY_TIMEOUT),
3286 Effect.as(true),
3287 Effect.catchCause((cause) =>
3288 Effect.logWarning("Could not add T3 Code's MCP server to OpenCode.", cause).pipe(
3289 Effect.as(false),
3290 ),
3291 ),
3292 );
3293 if (added) state.mcp = wanted;
3294 }
3295 const instructions = [
3296 buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }),
3297 t3OrchestrationSystemPrompt(state.mcp !== undefined),
3298 ]
3299 .filter((part) => part !== undefined && part.length > 0)
3300 .join("\n\n");
3301 if (instructions !== state.instructions) {
3302 yield* client.session.instructions.entry.put({
3303 sessionID: Session.ID.make(sessionId),
3304 key: INSTRUCTIONS_KEY,
3305 value: instructions,
3306 });
3307 state.instructions = instructions;
3308 }
3309 });
3310
3311 const markBefore = (sessionId: string, before: string | null) =>
3312 Effect.sync(() => {
3313 const turn = threads.get(sessionId)?.active;
3314 if (turn !== undefined) turn.before = before;
3315 });
3316
3317 /** Installs a turn T3 started; every path after it ends the turn with a terminal. */
3318 const beginTurn = (
3319 state: ThreadState,
3320 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3321 awaitingStart: boolean,
3322 ) =>
3323 Effect.gen(function* () {
3324 const startedAt = yield* DateTime.now;
3325 const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`;
3326 const providerTurn: OrchestrationV2ProviderTurn = {
3327 id: idAllocator.derive.providerTurn({ driver, nativeTurnId }),
3328 providerThreadId: turnInput.providerThread.id,
3329 nodeId: turnInput.rootNodeId,
3330 runAttemptId: turnInput.attemptId,
3331 // The user message it prompts with, where fork and rollback cut. A
3332 // continuation prompts nothing; it records the report it answers
3333 // once it takes its execution.
3334 nativeTurnRef: ref(
3335 isContinuation(turnInput)
3336 ? wakeTurnId(state.sessionId, turnInput.attemptId)
3337 : turnPromptId(state.sessionId, turnInput.attemptId),
3338 "weak",
3339 ),
3340 ordinal: turnInput.providerTurnOrdinal,
3341 status: "running",
3342 startedAt,
3343 completedAt: null,
3344 };
3345 const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart });
3346 // No stream is left to end this turn, so it must not start.
3347 if (streamFailure !== undefined) {
3348 return yield* new ProviderAdapter.ProviderAdapterEventStreamError({
3349 driver,
3350 providerSessionId: input.providerSessionId,
3351 cause: streamFailure,
3352 });
3353 }
3354 state.active = turn;
3355 yield* emitProviderTurn(state, turn, providerTurn);
3356 state.providerThread = {
3357 ...state.providerThread,
3358 status: "active",
3359 firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal,
3360 lastRunOrdinal: turnInput.runOrdinal,
3361 updatedAt: startedAt,
3362 };
3363 yield* emit({
3364 type: "provider_thread.updated",
3365 driver,
3366 providerThread: state.providerThread,
3367 });
3368 yield* setSessionStatus("running", null);
3369 return turn;
3370 });
3371
3372 /** Feeds a held execution's events to the turn now running on its session. */
3373 const replay = Effect.fnUntraced(function* (wake: Wake) {
3374 wake.dropped = true;
3375 for (const event of wake.events) yield* route(event, sessionOfEvent(event));
3376 });
3377
3378 /**
3379 * A user turn that starts while OpenCode runs an execution on its own: the
3380 * prompt joins that execution, so the turn takes it, and its continuation
3381 * turn is no longer needed. Executions that already ended stay for theirs.
3382 */
3383 const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) {
3384 const wake = state.wakes.at(-1);
3385 if (wake?.running !== true) return;
3386 state.wakes.pop();
3387 yield* replay(wake);
3388 });
3389
3390 /**
3391 * The continuation turn for an execution OpenCode started on its own: it
3392 * takes the oldest one held and ends with it. One already taken (a user
3393 * turn joined it) leaves nothing to run, so the turn ends at once.
3394 */
3395 const runWake = Effect.fnUntraced(function* (
3396 state: ThreadState,
3397 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3398 ) {
3399 yield* beginTurn(state, turnInput, false);
3400 yield* lock.withPermit(
3401 Effect.gen(function* () {
3402 const wake = state.wakes.shift();
3403 if (wake === undefined) return yield* finishTurn(state, { status: "completed" });
3404 const turn = state.active;
3405 if (turn !== undefined && wake.after !== undefined) turn.before = wake.after;
3406 // Its first report is where its history begins, so fork and rollback cut there.
3407 if (turn !== undefined && wake.first !== undefined) {
3408 turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") };
3409 }
3410 yield* replay(wake);
3411 }),
3412 );
3413 });
3414
3415 /**
3416 * Stops a thread's background subagents and any execution OpenCode is
3417 * running on its own. Their sessions end as interrupted; the reports
3418 * OpenCode then queues wake the parent into an execution that is stopped
3419 * as well, so no turn takes it. A subagent the Stop did not reach runs on,
3420 * so its call stays tracked: the session is not released under it, and
3421 * the next Stop tries it again.
3422 */
3423 const stopBackground = Effect.fnUntraced(function* (state: ThreadState) {
3424 const calls = runningCalls(state).filter((call) => call.background);
3425 // Every session under the thread, which a stopped subagent may report to.
3426 const callers = sessionsOf(state);
3427 // A subagent whose call ended runs again when OpenCode wakes it to
3428 // answer a report of its own background subagent.
3429 const followUps = callers.filter(
3430 (caller) =>
3431 caller.subagent !== undefined &&
3432 busy.has(caller.sessionId) &&
3433 !isOrchestrationV2WorkActive(caller.subagent.call.status),
3434 );
3435 // OpenCode announces a child's session before the call's progress names
3436 // it, so a call without a child yet is stopped through its caller's
3437 // announced children.
3438 const announcedTo = (caller: ThreadState) =>
3439 [...announced.values()].flatMap((info) =>
3440 info.parentID === caller.sessionId ? [info.sessionID] : [],
3441 );
3442 const childrenOf = (call: SubagentCall) =>
3443 call.child === undefined ? announcedTo(call.state) : [call.child.sessionId];
3444 // Each child reports to the session that called it, so its marker goes
3445 // on that caller's state: the thread's own, or a subagent's for a nested one.
3446 const children = new Map(
3447 calls.flatMap((call) => childrenOf(call).map((childId) => [childId, call.state] as const)),
3448 );
3449 const unreached = new Set<string>();
3450 for (const [childId, caller] of children) {
3451 caller.stoppedChildren.add(childId);
3452 const reached = yield* client.session
3453 .interrupt({ sessionID: Session.ID.make(childId) })
3454 .pipe(
3455 // A session that is gone runs nothing.
3456 Effect.catchTags({ SessionNotFoundError: () => Effect.void }),
3457 Effect.timeout(INTERRUPT_TIMEOUT),
3458 Effect.tapCause((cause) =>
3459 Effect.logWarning("Could not stop an OpenCode subagent.", cause),
3460 ),
3461 Effect.exit,
3462 Effect.map(Exit.isSuccess),
3463 );
3464 if (!reached) unreached.add(childId);
3465 }
3466 // Its end closes its turn; one the Stop did not reach stays pending work.
3467 for (const followUp of followUps) {
3468 yield* client.session
3469 .interrupt({ sessionID: Session.ID.make(followUp.sessionId) })
3470 .pipe(
3471 Effect.catchTags({ SessionNotFoundError: () => Effect.void }),
3472 Effect.timeout(INTERRUPT_TIMEOUT),
3473 Effect.ignore({ log: true }),
3474 );
3475 }
3476 yield* lock.withPermit(
3477 Effect.gen(function* () {
3478 for (const call of calls) {
3479 if (childrenOf(call).some((childId) => unreached.has(childId))) continue;
3480 yield* settleCall(call, "interrupted");
3481 }
3482 // A report already queued starts a follow-up the Stop must end too.
3483 for (const caller of callers) {
3484 for (const report of caller.reports.values()) {
3485 caller.stoppedChildren.add(report.childId);
3486 }
3487 }
3488 // A held wake no turn will take: its execution is stopped, not replayed.
3489 const running = state.wakes.some((wake) => wake.running);
3490 for (const wake of state.wakes.splice(0)) wake.dropped = true;
3491 if (running) {
3492 state.unsettled = true;
3493 yield* client.session
3494 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
3495 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
3496 }
3497 }),
3498 );
3499 });
3500
3501 /**
3502 * What a turn sends: `/compact` compacts, `/name args` naming a workspace
3503 * command runs it, and anything else is a prompt with the `$skill`s it
3504 * names attached. Commands and skills are read from the session's
3505 * directory only when the text could use them. A turn stopped meanwhile
3506 * sends nothing.
3507 */
3508 const submit = Effect.fnUntraced(function* (
3509 sessionId: string,
3510 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3511 state: ThreadState,
3512 turn: ActiveTurn,
3513 ) {
3514 const sessionID = Session.ID.make(sessionId);
3515 /** Whether the turn still sends; past this point a Stop goes to the server. */
3516 const sending = () => {
3517 if (state.active !== turn || turn.interrupted) return false;
3518 turn.unsent = false;
3519 return true;
3520 };
3521 const text = turnInput.message.text.trim();
3522 const bare = turnInput.message.attachments.length === 0;
3523 // The turn's own id, so fork and rollback cut before this turn's item.
3524 const id = turnPromptId(sessionId, turnInput.attemptId);
3525 if (bare && text === "/compact") {
3526 if (!sending()) return;
3527 return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid);
3528 }
3529 const location = { directory: turnInput.runtimePolicy.cwd ?? host.paths.cwd };
3530 const command = bare ? commandOf(text) : undefined;
3531 if (command !== undefined) {
3532 const commands = yield* client.command.list({ location }).pipe(
3533 Effect.timeout(INVENTORY_TIMEOUT),
3534 Effect.map((list) => list.data),
3535 Effect.orElseSucceed(() => []),
3536 );
3537 if (commands.some((entry) => entry.name === command.name)) {
3538 // `session.command` takes no id of T3's and its answer carries none,
3539 // so the turn remembers where the history stood before it.
3540 const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 });
3541 yield* markBefore(sessionId, newest.data[0]?.id ?? null);
3542 if (!sending()) return;
3543 return yield* client.session.command({ sessionID, ...command });
3544 }
3545 }
3546 const skills = SKILL_MENTION.test(text)
3547 ? skillsNamed(
3548 text,
3549 yield* client.skill.list({ location }).pipe(
3550 Effect.timeout(INVENTORY_TIMEOUT),
3551 Effect.map((list) => new Set(list.data.map((skill) => skill.id))),
3552 Effect.orElseSucceed(() => new Set<string>()),
3553 ),
3554 )
3555 : [];
3556 if (!sending()) return;
3557 return yield* client.session
3558 .prompt({
3559 sessionID,
3560 id,
3561 text: promptText(turnInput),
3562 ...(skills.length === 0
3563 ? {}
3564 : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }),
3565 })
3566 .pipe(Effect.asVoid);
3567 });
3568
3569 const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
3570 instanceId,
3571 driver,
3572 providerSessionId: input.providerSessionId,
3573 get providerSession() {
3574 return session;
3575 },
3576 events: Stream.fromQueue(events),
3577 // A background subagent keeps its session busy after its parent's turn,
3578 // and a held wake still needs its turn: idle release must wait for both.
3579 hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)),
3580 hasPendingBackgroundWorkForThread: (providerThread) =>
3581 Effect.sync(() => {
3582 const nativeId = providerThread.nativeThreadRef?.nativeId;
3583 const state = nativeId == null ? undefined : threads.get(nativeId);
3584 return state !== undefined && hasBackground(state);
3585 }),
3586 // A caller that names no directory gets the one this session opened in.
3587 getModelContextWindow: (selection, cwd) =>
3588 selection.instanceId === instanceId
3589 ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model)
3590 : undefined,
3591 ensureThread: (threadInput) =>
3592 Effect.gen(function* () {
3593 if (threadInput.existingProviderThread?.nativeThreadRef != null) {
3594 return yield* runtime.resumeThread({
3595 providerThread: threadInput.existingProviderThread,
3596 threadId: threadInput.threadId,
3597 modelSelection: threadInput.modelSelection,
3598 runtimePolicy: threadInput.runtimePolicy,
3599 });
3600 }
3601 yield* readModelsOnce(threadInput.runtimePolicy.cwd);
3602 const model = modelRef(threadInput.modelSelection);
3603 if (model === undefined) {
3604 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3605 driver: OPENCODE_PROVIDER,
3606 detail: malformedModel(threadInput.modelSelection.model),
3607 });
3608 }
3609 const directory = threadInput.runtimePolicy.cwd ?? host.paths.cwd;
3610 const policy = threadInput.runtimePolicy;
3611 // A new session runs OpenCode's default agent.
3612 const permissions = yield* rulesFor(
3613 { directory, agent: "build", grants: [] },
3614 policy,
3615 threadInput.threadId,
3616 );
3617 const created = yield* client.session.create({
3618 location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }),
3619 model,
3620 permissions,
3621 });
3622 const createdAt = yield* DateTime.now;
3623 const providerThread: OrchestrationV2ProviderThread = {
3624 ...(threadInput.existingProviderThread ?? {
3625 id: idAllocator.derive.providerThread({ driver, nativeThreadId: created.id }),
3626 driver,
3627 providerInstanceId: instanceId,
3628 appThreadId: threadInput.threadId,
3629 ownerNodeId: null,
3630 firstRunOrdinal: null,
3631 lastRunOrdinal: null,
3632 handoffIds: [],
3633 forkedFrom: null,
3634 createdAt,
3635 }),
3636 providerSessionId: input.providerSessionId,
3637 nativeThreadRef: ref(created.id),
3638 nativeConversationHeadRef: null,
3639 status: "idle",
3640 updatedAt: createdAt,
3641 };
3642 const state = register(
3643 providerThread,
3644 { id: created.id, model: created.model, agent: created.agent, permissions },
3645 directory,
3646 );
3647 state.policy = policy;
3648 return state.providerThread;
3649 }).pipe(
3650 Effect.mapError((cause) =>
3651 isProviderAdapterError(cause)
3652 ? cause
3653 : new ProviderAdapter.ProviderAdapterEnsureThreadError({
3654 driver,
3655 threadId: threadInput.threadId,
3656 cause,
3657 }),
3658 ),
3659 ),
3660 resumeThread: (threadInput) =>
3661 Effect.gen(function* () {
3662 const sessionId = yield* sessionIdOf(threadInput.providerThread);
3663 if (threadInput.runtimePolicy !== undefined) {
3664 yield* readModelsOnce(threadInput.runtimePolicy.cwd);
3665 }
3666 // 1.x session ids survive the upgrade; a server without this session
3667 // fails the resume, so T3 recreates the thread with a handoff.
3668 const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) });
3669 const providerThread: OrchestrationV2ProviderThread = {
3670 ...threadInput.providerThread,
3671 providerSessionId: input.providerSessionId,
3672 status: "idle",
3673 updatedAt: yield* DateTime.now,
3674 };
3675 const cwd = threadInput.runtimePolicy?.cwd;
3676 const loaded = threads.has(sessionId);
3677 const state = register(providerThread, native, cwd ?? native.location.directory);
3678 // OpenCode keeps no request across its own restart, but a server that
3679 // outlived T3 may still wait on one T3 no longer shows.
3680 if (!loaded) yield* stopLeftoverRequests(state);
3681 // The session gets the rules for this thread's mode: it may have run
3682 // another mode, or been made by 1.x or an earlier build.
3683 yield* writeRules(state, threadInput.runtimePolicy ?? state.policy);
3684 // A thread moved to another worktree takes its session with it.
3685 if (cwd != null && native.location.directory !== cwd) {
3686 yield* client.session.move({
3687 sessionID: Session.ID.make(sessionId),
3688 directory: AbsolutePath.make(cwd),
3689 });
3690 }
3691 return state.providerThread;
3692 }).pipe(
3693 Effect.mapError((cause) =>
3694 isProviderAdapterError(cause)
3695 ? cause
3696 : new ProviderAdapter.ProviderAdapterResumeThreadError({
3697 driver,
3698 providerSessionId: input.providerSessionId,
3699 providerThreadId: threadInput.providerThread.id,
3700 cause,
3701 }),
3702 ),
3703 ),
3704 // `/compact` is its own turn, which `startTurn` sends as `session.compact`.
3705 compactThread: (turnInput) =>
3706 runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }),
3707 startTurn: (turnInput) =>
3708 Effect.gen(function* () {
3709 // A lost stream is reconnecting: wait for it rather than prompt into
3710 // a dead stream, and refuse the turn before any request if it gave up.
3711 // Waited out before the session's gate, so a rollback or fork is not
3712 // held behind a reconnect.
3713 if (streamFailure !== undefined) {
3714 yield* Deferred.await(reconnected).pipe(Effect.timeout(RECONNECT_WAIT), Effect.ignore);
3715 }
3716 if (streamFailure !== undefined) {
3717 return yield* new ProviderAdapter.ProviderAdapterEventStreamError({
3718 driver,
3719 providerSessionId: input.providerSessionId,
3720 cause: streamFailure,
3721 });
3722 }
3723 // Its checks and setup take the session's gate, so a rollback or
3724 // fork sees either no turn or this one installed; the prompt after
3725 // it does not hold the gate.
3726 const started = yield* exclusive(turnInput.providerThread)(
3727 Effect.gen(function* () {
3728 const sessionId = yield* sessionIdOf(turnInput.providerThread);
3729 const state = threads.get(sessionId);
3730 if (state === undefined) {
3731 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3732 driver: OPENCODE_PROVIDER,
3733 detail: `OpenCode session ${sessionId} is not registered`,
3734 });
3735 }
3736 if (state.active !== undefined) {
3737 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3738 driver: OPENCODE_PROVIDER,
3739 detail: `OpenCode session ${sessionId} already has an active turn`,
3740 });
3741 }
3742 // OpenCode already ran this turn on its own; it prompts nothing.
3743 if (isContinuation(turnInput)) return yield* runWake(state, turnInput);
3744 // After a timed-out Stop the server says whether that run is gone. A
3745 // run still going is stopped again and this turn fails so it can be
3746 // sent again; a run that is gone may still have its end on the
3747 // stream, which the turn skips.
3748 const afterUnsettled = state.unsettled;
3749 let stillStopping = false;
3750 if (state.unsettled) {
3751 const active = yield* client.session
3752 .active()
3753 .pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT));
3754 stillStopping = sessionId in active;
3755 if (stillStopping) {
3756 yield* client.session
3757 .interrupt({ sessionID: Session.ID.make(sessionId) })
3758 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
3759 } else {
3760 state.unsettled = false;
3761 }
3762 }
3763 // Installs the turn; every path after it ends the turn with a terminal.
3764 const begin = beginTurn(state, turnInput, afterUnsettled);
3765 if (stillStopping) {
3766 yield* begin;
3767 return yield* finishTurn(state, {
3768 status: "failed",
3769 failure: makeProviderFailure({
3770 message: OPENCODE_2_STILL_STOPPING,
3771 class: "provider_error",
3772 }),
3773 });
3774 }
3775 // A turn T3 will not run still starts and fails, so the refusal is what
3776 // the user reads.
3777 const model = modelRef(turnInput.modelSelection);
3778 if (model === undefined) {
3779 yield* begin;
3780 return yield* finishTurn(state, {
3781 status: "failed",
3782 failure: makeProviderFailure({
3783 message: malformedModel(turnInput.modelSelection.model),
3784 class: "validation_error",
3785 }),
3786 });
3787 }
3788 // Plan mode is OpenCode's `plan` agent, switched before the rules are
3789 // written so they keep that agent's own path allows. Switching to or
3790 // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt.
3791 const agent = agentFor(turnInput);
3792 if (agent !== state.agent) {
3793 yield* client.session.switchAgent({
3794 sessionID: Session.ID.make(sessionId),
3795 agent: Agent.ID.make(agent),
3796 });
3797 state.agent = agent;
3798 }
3799 // The thread's mode may have changed since the session was loaded,
3800 // and its subagents still running hold the rules they started with.
3801 // Those run on whether or not this turn starts, so theirs are best effort.
3802 yield* writeRules(state, turnInput.runtimePolicy);
3803 for (const call of runningCalls(state)) {
3804 if (call.child === undefined) continue;
3805 yield* writeRules(call.child, turnInput.runtimePolicy).pipe(
3806 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3807 Effect.ignore({ log: true }),
3808 );
3809 }
3810 // A selection changed since the last turn applies now; OpenCode keeps
3811 // the session's model otherwise.
3812 if (!sameModel(model, state.model)) {
3813 yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model });
3814 state.model = model;
3815 }
3816 // Steers a stopped turn never delivered would reach the model first,
3817 // and a revert a failed rollback left staged would be committed.
3818 yield* cancelStrandedSteers(state);
3819 if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId);
3820 yield* prepareTurn(sessionId, state, turnInput);
3821 const turn = yield* begin;
3822 turn.unsent = true;
3823 // An execution OpenCode is running on its own takes this prompt at
3824 // its next step, so this turn is that execution from here on.
3825 yield* lock.withPermit(takeRunningWake(state));
3826 return { state, sessionId, turn };
3827 }),
3828 );
3829 if (started === undefined) return;
3830 const { state, sessionId, turn } = started;
3831 yield* submit(sessionId, turnInput, state, turn).pipe(
3832 // Deleted outside T3: the thread is broken, and forgetting it makes
3833 // the next turn resume, fail, and recreate it with a handoff.
3834 Effect.catchTags({
3835 SessionNotFoundError: () =>
3836 finishTurn(
3837 state,
3838 {
3839 status: "failed",
3840 failure: makeProviderFailure({
3841 message:
3842 "The OpenCode session no longer exists. Send the message again to continue in a new session.",
3843 class: "provider_error",
3844 }),
3845 },
3846 "broken",
3847 ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))),
3848 }),
3849 Effect.tapError((cause) =>
3850 state.active === turn
3851 ? Effect.gen(function* () {
3852 // Without a clear rejection the server may have taken the
3853 // prompt, so the next turn checks before it prompts again.
3854 if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.unsettled = true;
3855 yield* finishTurn(state, {
3856 status: "failed",
3857 failure: makeProviderFailure({ cause, class: "provider_error" }),
3858 });
3859 })
3860 : Effect.void,
3861 ),
3862 );
3863 }).pipe(
3864 Effect.mapError((cause) =>
3865 isProviderAdapterError(cause)
3866 ? cause
3867 : new ProviderAdapter.ProviderAdapterTurnStartError({
3868 driver,
3869 threadId: turnInput.threadId,
3870 providerThreadId: turnInput.providerThread.id,
3871 runId: turnInput.runId,
3872 cause,
3873 }),
3874 ),
3875 ),
3876 steerTurn: (steerInput) =>
3877 Effect.gen(function* () {
3878 const sessionId = yield* sessionIdOf(steerInput.providerThread);
3879 const state = threads.get(sessionId);
3880 const turn = state?.active;
3881 if (
3882 state === undefined ||
3883 turn === undefined ||
3884 turn.providerTurn.id !== steerInput.providerTurnId ||
3885 turn.interrupted
3886 ) {
3887 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3888 driver: OPENCODE_PROVIDER,
3889 detail: `OpenCode turn ${steerInput.providerTurnId} is not active`,
3890 });
3891 }
3892 const inboxID = steerPromptId(sessionId, steerInput.message.messageId);
3893 // A retried steer that already reached the model is not sent again.
3894 if (turn.settledInbox.has(inboxID)) return;
3895 turn.steers.add(inboxID);
3896 state.strandedSteers.delete(inboxID);
3897 yield* client.session
3898 .prompt({
3899 sessionID: Session.ID.make(sessionId),
3900 id: inboxID,
3901 text: providerMessageTextWithAttachmentPaths({
3902 text: steerInput.message.text,
3903 attachments: steerInput.message.attachments,
3904 resolveAttachmentPath: host.resolveAttachmentPath,
3905 }).trim(),
3906 delivery: "steer",
3907 })
3908 .pipe(
3909 Effect.tapError((cause) =>
3910 lock.withPermit(
3911 Effect.gen(function* () {
3912 turn.steers.delete(inboxID);
3913 // Without a clear refusal OpenCode may hold it; the next prompt takes it back.
3914 if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID);
3915 if (state.active === turn) yield* endIfSettled(state, turn);
3916 }),
3917 ),
3918 ),
3919 );
3920 }).pipe(
3921 Effect.mapError((cause) =>
3922 isProviderAdapterError(cause)
3923 ? cause
3924 : new ProviderAdapter.ProviderAdapterSteerRunError({
3925 driver,
3926 providerThreadId: steerInput.providerThread.id,
3927 providerTurnId: steerInput.providerTurnId,
3928 cause,
3929 }),
3930 ),
3931 ),
3932 interruptTurn: (interruptInput) =>
3933 Effect.gen(function* () {
3934 const sessionId = yield* sessionIdOf(interruptInput.providerThread);
3935 const state = threads.get(sessionId);
3936 if (state === undefined) return;
3937 const turn = state.active;
3938 // OpenCode stops a foreground subagent with its parent, but not a
3939 // background one: a user Stop (`requestRuntimeRestart`) stops those
3940 // too, and the execution OpenCode starts to report them. A turn
3941 // interrupted to restart it with new input leaves them running.
3942 if (interruptInput.requestRuntimeRestart === true) yield* stopBackground(state);
3943 if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) {
3944 return;
3945 }
3946 // The session answers with `session.execution.interrupted`, which ends
3947 // the turn. A server that does not answer in time is stuck, so the turn
3948 // ends here instead of waiting on it.
3949 turn.interrupted = true;
3950 const reply = yield* client.session
3951 .interrupt({ sessionID: Session.ID.make(sessionId) })
3952 .pipe(
3953 Effect.timeoutOption(INTERRUPT_TIMEOUT),
3954 // A Stop that never reached the server stopped nothing.
3955 Effect.tapError(() =>
3956 Effect.sync(() => {
3957 turn.interrupted = false;
3958 }),
3959 ),
3960 );
3961 if (reply._tag === "None") {
3962 state.unsettled = true;
3963 return yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
3964 }
3965 // Between the executions a late steer spans nothing runs yet. The
3966 // steers are taken back, and a run they already started is skipped.
3967 if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) {
3968 state.unsettled = true;
3969 yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
3970 return yield* cancelStrandedSteers(state);
3971 }
3972 // Nothing was running because the turn has not sent its request yet
3973 // (it is reading the workspace's commands or skills): it ends here,
3974 // and never sends it.
3975 if (!reply.value.interrupted && state.active === turn && turn.unsent) {
3976 return yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
3977 }
3978 // Nothing was running. Unless the execution already ended (its event
3979 // is on the way), the turn is still open and nothing stopped.
3980 if (!reply.value.interrupted && state.active === turn) {
3981 turn.interrupted = false;
3982 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3983 driver: OPENCODE_PROVIDER,
3984 detail: `OpenCode session ${sessionId} had nothing running to stop`,
3985 });
3986 }
3987 }).pipe(
3988 Effect.mapError((cause) =>
3989 isProviderAdapterError(cause)
3990 ? cause
3991 : new ProviderAdapter.ProviderAdapterInterruptError({
3992 driver,
3993 providerThreadId: interruptInput.providerThread.id,
3994 providerTurnId: interruptInput.providerTurnId,
3995 cause,
3996 }),
3997 ),
3998 ),
3999 unloadThread: ({ providerThread }) =>
4000 Effect.gen(function* () {
4001 const nativeId = providerThread.nativeThreadRef?.nativeId;
4002 const state = nativeId == null ? undefined : threads.get(nativeId);
4003 if (
4004 nativeId == null ||
4005 state === undefined ||
4006 state.active !== undefined ||
4007 hasBackground(state)
4008 ) {
4009 return;
4010 }
4011 threads.delete(nativeId);
4012 for (const [child, owner] of childOwners) {
4013 if (owner !== state) continue;
4014 childOwners.delete(child);
4015 threads.delete(child);
4016 }
4017 if (state.mcp !== undefined) yield* removeMcp(state.mcp);
4018 }),
4019 respondToRuntimeRequest: (requestInput) =>
4020 Effect.gen(function* () {
4021 const entry = pending.get(requestInput.requestId);
4022 if (entry === undefined || entry.answering) {
4023 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4024 driver,
4025 detail: `No pending OpenCode request ${requestInput.requestId}`,
4026 });
4027 }
4028 const { decision, answers } = requestInput;
4029 const { native } = entry;
4030 if (native.type === "permission" && decision === undefined) {
4031 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4032 driver,
4033 detail: `OpenCode approval request ${requestInput.requestId} requires a decision`,
4034 });
4035 }
4036 entry.answering = true;
4037 // OpenCode's own "always" saves a grant for the whole project, so a
4038 // session-wide answer is a rule on this session instead. The grant
4039 // is best effort: this request is answered either way.
4040 // The session that asked waits on the answer and holds the grant: a
4041 // subagent's own, not its parent's.
4042 const asker = threads.get(entry.sessionId) ?? entry.state;
4043 if (
4044 native.type === "permission" &&
4045 (decision === "acceptForSession" || decision === "acceptAlways")
4046 ) {
4047 const state = asker;
4048 for (const resource of native.save) {
4049 if (
4050 !state.grants.some(
4051 (grant) => grant.action === native.action && grant.resource === resource,
4052 )
4053 ) {
4054 state.grants.push({ action: native.action, resource, effect: "allow" });
4055 }
4056 }
4057 // A subagent runs under its thread's mode.
4058 yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true }));
4059 }
4060 const sessionID = Session.ID.make(entry.sessionId);
4061 // Best effort: the decline stands without the note.
4062 if (native.type === "permission" && decision === "decline") {
4063 yield* client.session
4064 .synthetic({
4065 sessionID,
4066 text: declinedNote(native.action, native.resources),
4067 delivery: "steer",
4068 resume: false,
4069 })
4070 .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true }));
4071 }
4072 const delivered = yield* native.type === "permission"
4073 ? deliver(
4074 client.permission
4075 .reply({
4076 sessionID,
4077 requestID: Permission.ID.make(native.id),
4078 decision: decision !== undefined && approves(decision) ? "once" : "reject",
4079 // Without a message OpenCode ends the whole run, which is Cancel.
4080 ...(decision === "decline" ? { message: DECLINED } : {}),
4081 })
4082 .pipe(Effect.catchTags(permissionGone)),
4083 )
4084 : deliver(
4085 (answers === undefined || decision === "decline" || decision === "cancel"
4086 ? client.session.form.cancel({
4087 sessionID: entry.sessionId,
4088 formID: Form.ID.make(native.id),
4089 })
4090 : client.session.form.reply({
4091 sessionID: entry.sessionId,
4092 formID: Form.ID.make(native.id),
4093 answer: formAnswer(native.form, answers),
4094 })
4095 ).pipe(Effect.catchTags(formGone)),
4096 );
4097 yield* forgetRequest(entry);
4098 if (!delivered) yield* abandonRequest(asker, "answer not delivered");
4099 }).pipe(
4100 Effect.mapError((cause) =>
4101 isProviderAdapterError(cause)
4102 ? cause
4103 : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
4104 driver,
4105 requestId: requestInput.requestId,
4106 cause,
4107 }),
4108 ),
4109 ),
4110 readThreadSnapshot: ({ providerThread }) =>
4111 Effect.gen(function* () {
4112 const sessionId = yield* sessionIdOf(providerThread);
4113 return yield* snapshotOf(providerThread, sessionId);
4114 }).pipe(
4115 Effect.mapError((cause) =>
4116 isProviderAdapterError(cause)
4117 ? cause
4118 : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
4119 driver,
4120 providerThreadId: providerThread.id,
4121 cause,
4122 }),
4123 ),
4124 ),
4125 rollbackThread: (rollbackInput) =>
4126 Effect.gen(function* () {
4127 const { providerThread, target } = rollbackInput;
4128 const sessionId = yield* sessionIdOf(providerThread);
4129 // A stopped run's end may still be on the stream, so `unsettled`
4130 // stays for the next turn to skip it.
4131 yield* ensureQuiet(sessionId, "roll back");
4132 // A session this runtime has not loaded yet is loaded the way a turn
4133 // would load it, which stops anything it still waits on. Requests it
4134 // stops end their run, so the server is asked again after that.
4135 if (!threads.has(sessionId)) {
4136 yield* runtime.resumeThread({ providerThread });
4137 yield* ensureQuiet(sessionId, "roll back");
4138 }
4139 const boundary = yield* boundaryAfter(
4140 rollbackInput.providerThreadTurns,
4141 providerThread.id,
4142 target.type === "provider_turn" ? target.providerTurn : undefined,
4143 yield* userMessages(sessionId),
4144 );
4145 if (boundary !== null) {
4146 yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary));
4147 }
4148 // The turns after the target left the history with their messages.
4149 // The snapshot lists the ones kept as T3 recorded them, which a
4150 // runtime that loaded the session after they ran never saw.
4151 const keptOrdinal = target.type === "provider_turn" ? target.providerTurn.ordinal : 0;
4152 const loaded = threads.get(sessionId);
4153 for (const [id, turn] of loaded?.providerTurns ?? []) {
4154 if (turn.ordinal > keptOrdinal) loaded?.providerTurns.delete(id);
4155 }
4156 const snapshot = yield* snapshotOf(providerThread, sessionId);
4157 const kept = new Map(
4158 rollbackInput.providerThreadTurns
4159 .filter(
4160 (turn) =>
4161 turn.providerThreadId === providerThread.id && turn.ordinal <= keptOrdinal,
4162 )
4163 .map((turn) => [turn.id, turn] as const),
4164 );
4165 for (const turn of snapshot.providerTurns) kept.set(turn.id, turn);
4166 // The snapshot's head is the last user message OpenCode kept.
4167 return { ...snapshot, providerTurns: [...kept.values()] };
4168 }).pipe(
4169 exclusive(rollbackInput.providerThread),
4170 Effect.mapError((cause) =>
4171 isProviderAdapterError(cause)
4172 ? cause
4173 : new ProviderAdapter.ProviderAdapterRollbackThreadError({
4174 driver,
4175 providerThreadId: rollbackInput.providerThread.id,
4176 checkpointId: rollbackInput.target.checkpointId,
4177 cause,
4178 }),
4179 ),
4180 ),
4181 forkThread: (forkInput) =>
4182 Effect.gen(function* () {
4183 const source = forkInput.sourceProviderThread;
4184 const sourceId = yield* sessionIdOf(source);
4185 // OpenCode forks whatever history the source has, so a fork taken
4186 // while anything writes to it would copy a run half done.
4187 yield* ensureQuiet(sourceId, "fork");
4188 const selected =
4189 forkInput.providerTurnId === undefined
4190 ? undefined
4191 : forkInput.sourceProviderTurns?.find((turn) => turn.id === forkInput.providerTurnId);
4192 if (forkInput.providerTurnId !== undefined && selected === undefined) {
4193 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4194 driver: OPENCODE_PROVIDER,
4195 detail: `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`,
4196 });
4197 }
4198 const before =
4199 selected === undefined
4200 ? null
4201 : yield* boundaryAfter(
4202 forkInput.sourceProviderTurns ?? [],
4203 source.id,
4204 selected,
4205 yield* userMessages(sourceId),
4206 );
4207 // A fork copies the source's history, model, location and rules; its
4208 // message ids are new, so it has no turns T3 could cut at yet.
4209 const forked = yield* client.session.fork({
4210 sessionID: Session.ID.make(sourceId),
4211 ...(before === null ? {} : { before: SessionMessage.ID.make(before) }),
4212 });
4213 const createdAt = yield* DateTime.now;
4214 const providerThread: OrchestrationV2ProviderThread = {
4215 id: idAllocator.derive.providerThread({ driver, nativeThreadId: forked.id }),
4216 driver,
4217 providerInstanceId: instanceId,
4218 providerSessionId: input.providerSessionId,
4219 appThreadId: forkInput.targetThreadId,
4220 ownerNodeId: forkInput.ownerNodeId ?? null,
4221 nativeThreadRef: ref(forked.id),
4222 nativeConversationHeadRef: null,
4223 status: "idle",
4224 firstRunOrdinal: null,
4225 lastRunOrdinal: null,
4226 handoffIds: [],
4227 forkedFrom: {
4228 providerThreadId: source.id,
4229 ...(forkInput.providerTurnId === undefined
4230 ? {}
4231 : { providerTurnId: forkInput.providerTurnId }),
4232 },
4233 createdAt,
4234 updatedAt: createdAt,
4235 };
4236 // A fork keeps the source's location. The target thread usually
4237 // inherits that worktree too, but its first turn starts without a
4238 // resume, so a target in another worktree takes the session there now.
4239 const cwd = forkInput.runtimePolicy?.cwd;
4240 const state = register(providerThread, forked, cwd ?? forked.location.directory);
4241 // The fork runs the target thread's mode, not whatever the source ran.
4242 yield* writeRules(state, forkInput.runtimePolicy ?? state.policy);
4243 if (cwd != null && forked.location.directory !== cwd) {
4244 yield* client.session.move({
4245 sessionID: Session.ID.make(forked.id),
4246 directory: AbsolutePath.make(cwd),
4247 });
4248 }
4249 return state.providerThread;
4250 }).pipe(
4251 exclusive(forkInput.sourceProviderThread),
4252 Effect.mapError((cause) =>
4253 isProviderAdapterError(cause)
4254 ? cause
4255 : new ProviderAdapter.ProviderAdapterForkThreadError({
4256 driver,
4257 providerThreadId: forkInput.sourceProviderThread.id,
4258 cause,
4259 }),
4260 ),
4261 ),
4262 };
4263 return runtime;
4264 });
4265
4266 return ProviderAdapter.ProviderAdapterV2.of({
4267 instanceId,
4268 driver,
4269 getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilities),
4270 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
4271 // The session borrows the instance's server for as long as it is open, so a
4272 // spawned server is not idle-stopped under a long tool call.
4273 openSession: (input) =>
4274 Effect.gen(function* () {
4275 const scope = yield* Scope.make();
4276 yield* Effect.addFinalizer((exit) => Scope.close(scope, exit));
4277 const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope));
4278 return yield* openSession(input, { connection, scope });
4279 }).pipe(
4280 Effect.mapError(
4281 (cause) =>
4282 new ProviderAdapter.ProviderAdapterOpenSessionError({
4283 driver,
4284 providerSessionId: input.providerSessionId,
4285 cause,
4286 }),
4287 ),
4288 ),
4289 });
4290});
packages/provider-opencode/src/server/v2/textGeneration.ts created+204
...@@ -0,0 +1,204 @@
1/**
2 * Text generation on an OpenCode 2 server. Both of its `generate` endpoints
3 * answer 403 on OpenCode's free tier ("free tier can only be used from within
4 * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x
5 * does, and the session is removed afterwards.
6 *
7 * @module textGeneration/OpenCode2TextGeneration
8 */
9import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect";
10import { TextGenerationError } from "@t3tools/contracts";
11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16
17import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
18import * as OpenCode2Server from "./OpenCode2Server.ts";
19import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
20import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
21
22const isTextGenerationError = Schema.is(TextGenerationError);
23
24/** A generation that takes this long is stuck; its session is stopped and removed. */
25const GENERATION_TIMEOUT = "3 minutes";
26
27/**
28 * Nothing in a text generation needs a tool, so every tool asks and T3 is not
29 * there to answer. Denying `shell` or `read` outright gets the whole session
30 * refused on OpenCode's free models, and an ask that is never answered would
31 * hang, so asks are rejected as they arrive.
32 */
33const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const;
34
35/** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */
36type Outcome =
37 | { readonly _tag: "text"; readonly text: string }
38 | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown };
39
40const runOnServer = (
41 connection: OpenCode2Server.OpenCode2Connection,
42 input: TextGenerationOperations.Request<Schema.Top>,
43 resolveAttachmentPath: ProviderHost.ProviderHostShape["resolveAttachmentPath"],
44) =>
45 Effect.gen(function* () {
46 const { client } = connection;
47 const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model);
48 if (parsed === null) {
49 return yield* new TextGenerationError({
50 operation: input.operation,
51 detail: "OpenCode model selection must use the 'provider/model' format.",
52 });
53 }
54 const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant");
55 const outcome = yield* Deferred.make<Outcome>();
56 let sessionId: string | undefined;
57 const texts = new Map<string, string>();
58 // Subscribed first: the stream is volatile, and the reply arrives on it.
59 const events = yield* connection.events;
60 yield* events.pipe(
61 Stream.runForEach((event) => {
62 // An execution end a newer server sent in a shape this build cannot
63 // read still ends the generation, as it ends a turn in the adapter.
64 if (event.type === "unreadable.execution.ended") {
65 if (event.sessionID !== sessionId) return Effect.void;
66 return Deferred.succeed(
67 outcome,
68 event.executionType === "session.execution.succeeded"
69 ? { _tag: "text", text: [...texts.values()].join("\n").trim() }
70 : {
71 _tag: "failed",
72 detail: "OpenCode ended the generation in a way this version cannot read.",
73 cause: event,
74 },
75 );
76 }
77 if (
78 sessionId === undefined ||
79 !("data" in event) ||
80 !("sessionID" in event.data) ||
81 event.data.sessionID !== sessionId
82 ) {
83 return Effect.void;
84 }
85 switch (event.type) {
86 case "session.text.ended":
87 texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text);
88 return Effect.void;
89 case "permission.asked":
90 return client.permission
91 .reply({
92 sessionID: event.data.sessionID,
93 requestID: event.data.id,
94 decision: "reject",
95 message: "Tools are not available while generating text.",
96 })
97 .pipe(Effect.ignore({ log: true }));
98 case "session.execution.succeeded":
99 return Deferred.succeed(outcome, {
100 _tag: "text",
101 text: [...texts.values()].join("\n").trim(),
102 });
103 case "session.execution.failed":
104 return Deferred.succeed(outcome, {
105 _tag: "failed",
106 detail: "OpenCode could not generate the text.",
107 cause: event.data.error,
108 });
109 case "session.execution.interrupted":
110 return Deferred.succeed(outcome, {
111 _tag: "failed",
112 detail: "OpenCode stopped the generation.",
113 });
114 default:
115 return Effect.void;
116 }
117 }),
118 // The reply only arrives on this stream, so a lost stream ends the wait.
119 Effect.exit,
120 Effect.flatMap((exit) =>
121 Deferred.succeed(outcome, {
122 _tag: "failed",
123 detail: "The OpenCode event stream was lost.",
124 cause: exit,
125 }),
126 ),
127 Effect.forkScoped,
128 );
129 const session = yield* client.session.create({
130 title: `T3 Code ${input.operation}`,
131 location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }),
132 model: Model.Ref.make({
133 providerID: Provider.ID.make(parsed.providerID),
134 id: Model.ID.make(parsed.modelID),
135 ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
136 }),
137 permissions: GENERATION_PERMISSIONS,
138 });
139 sessionId = session.id;
140 yield* Effect.addFinalizer(() =>
141 client.session
142 .remove({ sessionID: session.id })
143 .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })),
144 );
145 const images = (input.attachments ?? []).flatMap((attachment) => {
146 if (attachment.type !== "image") return [];
147 const path = resolveAttachmentPath(attachment);
148 return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }];
149 });
150 yield* client.session.prompt({
151 sessionID: session.id,
152 text: input.prompt,
153 ...(images.length === 0 ? {} : { files: images }),
154 });
155 const result = yield* Deferred.await(outcome).pipe(
156 Effect.timeoutOrElse({
157 duration: GENERATION_TIMEOUT,
158 orElse: () =>
159 client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe(
160 Effect.ignore,
161 Effect.as<Outcome>({
162 _tag: "failed",
163 detail: "OpenCode did not finish generating in time.",
164 }),
165 ),
166 }),
167 );
168 if (result._tag === "failed") {
169 return yield* new TextGenerationError({
170 operation: input.operation,
171 detail: result.detail,
172 ...(result.cause === undefined ? {} : { cause: result.cause }),
173 });
174 }
175 if (result.text.length === 0) {
176 return yield* new TextGenerationError({
177 operation: input.operation,
178 detail: "OpenCode returned empty output.",
179 });
180 }
181 return result.text;
182 }).pipe(Effect.scoped);
183
184/** Text generation for an instance whose server is OpenCode 2. */
185export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () {
186 const server = yield* OpenCode2Server.OpenCode2Server;
187 const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost;
188 const run: TextGenerationOperations.Runner = (input) =>
189 server
190 .withConnection((connection) => runOnServer(connection, input, resolveAttachmentPath))
191 .pipe(
192 Effect.mapError((cause) =>
193 isTextGenerationError(cause)
194 ? cause
195 : new TextGenerationError({
196 operation: input.operation,
197 detail: "OpenCode text generation failed.",
198 cause,
199 }),
200 ),
201 Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)),
202 );
203 return TextGenerationOperations.fromRunner("OpenCode2TextGeneration", run);
204});
packages/provider-opencode/src/server/versionProbe.test.ts created+197
...@@ -0,0 +1,197 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
4
5import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
6import {
7 classifyOpenCodeCliVersion,
8 makeOpenCodeRuntimeProbe,
9 probeOpenCodeRuntime,
10} from "./versionProbe.ts";
11import {
12 OPENCODE_1_RESPONSES,
13 OPENCODE_2_RESPONSES,
14 replayOpenCodeServer,
15} from "./probeResponses.fixture.ts";
16
17const noBinary = {
18 runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"),
19} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape;
20
21const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) =>
22 probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe(
23 Effect.provideService(HttpClient.HttpClient, http),
24 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, noBinary),
25 );
26const SERVER_URL = "http://127.0.0.1:4096/";
27
28describe("OpenCode version probe", () => {
29 it("classifies the recorded `opencode --version` output of both versions", () => {
30 assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), {
31 generation: "v1",
32 version: "1.18.32",
33 });
34 assert.deepStrictEqual(classifyOpenCodeCliVersion("opencode v2.0.18\n"), {
35 generation: "v2",
36 version: "2.0.18",
37 });
38 assert.isUndefined(classifyOpenCodeCliVersion("opencode dev build\n"));
39 });
40
41 it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () =>
42 Effect.gen(function* () {
43 const paths: Array<string> = [];
44 const probed = yield* probeServer(
45 SERVER_URL,
46 "pw",
47 replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths),
48 );
49 assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" });
50 assert.deepStrictEqual(paths, ["/api/info"]);
51 }),
52 );
53
54 it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () =>
55 Effect.gen(function* () {
56 const paths: Array<string> = [];
57 const probed = yield* probeServer(
58 SERVER_URL,
59 "pw",
60 replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths),
61 );
62 assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" });
63 assert.deepStrictEqual(paths, ["/api/info", "/global/health"]);
64 }),
65 );
66
67 it.effect("keeps a server URL's path prefix and query when probing", () =>
68 Effect.gen(function* () {
69 const urls: Array<string> = [];
70 const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw");
71 // A reverse proxy mounting OpenCode under /opencode, reached with a routing query.
72 const proxied = HttpClient.make((request, url) => {
73 urls.push(url.href);
74 return replay.execute(
75 HttpClientRequest.setUrl(
76 request,
77 `http://127.0.0.1:4096${url.pathname.replace(/^\/opencode/, "")}`,
78 ),
79 );
80 });
81 const probed = yield* probeServer("http://proxy:8080/opencode/?route=oc", "pw", proxied);
82 assert.strictEqual(probed.generation, "v1");
83 assert.deepStrictEqual(urls, [
84 "http://proxy:8080/opencode/api/info?route=oc",
85 "http://proxy:8080/opencode/global/health?route=oc",
86 ]);
87 }),
88 );
89
90 it.effect("reports a rejected password instead of guessing a version", () =>
91 Effect.gen(function* () {
92 for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) {
93 const paths: Array<string> = [];
94 const error = yield* Effect.flip(
95 probeServer(SERVER_URL, "wrong", replayOpenCodeServer(responses, "pw", paths)),
96 );
97 assert.match(error.detail, /401 Unauthorized/);
98 assert.deepStrictEqual(paths, ["/api/info"]);
99 }
100 }),
101 );
102
103 it.effect("sends a non-ASCII password as UTF-8, as both versions expect", () =>
104 Effect.gen(function* () {
105 for (const [responses, password, generation] of [
106 [OPENCODE_1_RESPONSES, "pässwörd", "v1"],
107 [OPENCODE_2_RESPONSES, "pass€word", "v2"],
108 ] as const) {
109 const probed = yield* probeServer(
110 SERVER_URL,
111 password,
112 replayOpenCodeServer(responses, password),
113 );
114 assert.strictEqual(probed.generation, generation);
115 }
116 }),
117 );
118
119 it.effect("does not take other JSON at /api/info for OpenCode 2", () =>
120 Effect.gen(function* () {
121 const impostor = {
122 ...OPENCODE_1_RESPONSES,
123 "/api/info": { status: 200, contentType: "application/json", body: '{"version":"3.4.1"}' },
124 "/global/health": OPENCODE_1_RESPONSES["/api/info"],
125 };
126 const error = yield* Effect.flip(
127 probeServer(SERVER_URL, "pw", replayOpenCodeServer(impostor, "pw")),
128 );
129 assert.match(error.detail, /did not identify itself as OpenCode/);
130 }),
131 );
132
133 it.effect("never puts the configured URL or its credentials in an error detail", () =>
134 Effect.gen(function* () {
135 const secret = "userinfo-secret";
136 const unreachable = HttpClient.make((request) =>
137 Effect.die(`should not be reached for ${request.url}`),
138 );
139 // A fetch failure that echoes the full request URL, as Node's does.
140 const refused = HttpClient.make((request) =>
141 Effect.fail(
142 new HttpClientError.HttpClientError({
143 reason: new HttpClientError.TransportError({
144 request,
145 description: `connect ECONNREFUSED ${request.url}`,
146 }),
147 }),
148 ),
149 );
150 for (const [serverUrl, http] of [
151 [`127.0.0.1:4096?token=${secret}`, unreachable],
152 [`localhost:4096/${secret}`, unreachable],
153 [`http://user:${secret}@10.0.0.1:4096/?token=${secret}`, refused],
154 ] as const) {
155 const error = yield* Effect.flip(probeServer(serverUrl, secret, http));
156 assert.notInclude(error.detail, secret, serverUrl);
157 assert.notInclude(error.detail, "10.0.0.1", serverUrl);
158 }
159 }),
160 );
161
162 it.effect("remembers a probed runtime but never a failure", () =>
163 Effect.gen(function* () {
164 const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"];
165 let calls = 0;
166 const runtime = {
167 runOpenCodeCommand: () => {
168 const stdout = outputs[calls++];
169 return stdout
170 ? Effect.succeed({ stdout, stderr: "", code: 0 })
171 : Effect.fail(
172 new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }),
173 );
174 },
175 } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape;
176 const probe = yield* makeOpenCodeRuntimeProbe(
177 probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe(
178 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime),
179 Effect.provideService(
180 HttpClient.HttpClient,
181 HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")),
182 ),
183 ),
184 );
185
186 yield* Effect.flip(probe.get);
187 assert.isTrue((yield* probe.lastSuccess)._tag === "None");
188 assert.strictEqual((yield* probe.get).generation, "v2");
189 assert.strictEqual((yield* probe.get).generation, "v2");
190 assert.strictEqual(calls, 2);
191 // A status refresh re-probes, so an in-place downgrade or upgrade re-routes.
192 assert.strictEqual((yield* probe.refresh).generation, "v1");
193 assert.strictEqual((yield* probe.get).generation, "v1");
194 assert.strictEqual(calls, 3);
195 }),
196 );
197});
packages/provider-opencode/src/server/versionProbe.ts created+198
...@@ -0,0 +1,198 @@
1/** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */
2import { parseSemver } from "@t3tools/shared/semver";
3import * as Cache from "effect/Cache";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest } from "effect/http";
10
11import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
12import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe";
13
14export interface ProbedOpenCode {
15 readonly generation: "v1" | "v2";
16 readonly version: string;
17}
18
19const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds";
20const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds";
21// 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out.
22const decodeApiInfo = Schema.decodeUnknownOption(
23 Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })),
24);
25const decodeGlobalHealth = Schema.decodeUnknownOption(
26 Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })),
27);
28
29function probed(version: string | null | undefined): ProbedOpenCode | undefined {
30 const major = parseSemver(version ?? "")?.major;
31 if (!version || major === undefined) return undefined;
32 return { generation: major >= 2 ? "v2" : "v1", version };
33}
34
35/** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */
36export const classifyOpenCodeCliVersion = (output: string) =>
37 probed(parseGenericCliVersion(output));
38
39/**
40 * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a
41 * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with
42 * a 401 on either path, so a 401 says nothing about the version.
43 */
44function classifyOpenCodeProbeResponse(
45 path: "/api/info" | "/global/health",
46 response: {
47 readonly status: number;
48 readonly contentType: string | undefined;
49 readonly body: string;
50 },
51): ProbedOpenCode | "unauthorized" | undefined {
52 if (response.status === 401) return "unauthorized";
53 const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase();
54 if (response.status !== 200 || mediaType !== "application/json") return undefined;
55 const version: Option.Option<string> =
56 path === "/api/info"
57 ? Option.map(decodeApiInfo(response.body), (info) => info.version)
58 : Option.map(decodeGlobalHealth(response.body), (health) => health.version);
59 return probed(Option.getOrUndefined(version));
60}
61
62const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* (
63 binaryPath: string,
64 environment: NodeJS.ProcessEnv | undefined,
65) {
66 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
67 const { stdout } = yield* runtime
68 .runOpenCodeCommand({
69 binaryPath,
70 args: ["--version"],
71 ...(environment === undefined ? {} : { environment }),
72 })
73 .pipe(
74 Effect.timeoutOrElse({
75 duration: OPENCODE_VERSION_PROBE_TIMEOUT,
76 orElse: () =>
77 Effect.fail(
78 new OpenCodeRuntime.OpenCodeRuntimeError({
79 operation: "probeOpenCodeBinary",
80 detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`,
81 }),
82 ),
83 }),
84 );
85 const result = classifyOpenCodeCliVersion(stdout);
86 if (result) return result;
87 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
88 operation: "probeOpenCodeBinary",
89 detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
90 });
91});
92
93// Server failures reach clients through the provider status, so their details are fixed text:
94// the underlying error can carry the configured URL, its credentials, or the password header.
95const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* (
96 serverUrl: string,
97 serverPassword: string,
98) {
99 const client = yield* HttpClient.HttpClient;
100 const baseUrl = URL.parse(serverUrl.trim());
101 if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") {
102 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
103 operation: "probeOpenCodeServer",
104 detail: "The OpenCode server URL is not a valid http:// or https:// URL.",
105 });
106 }
107 // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`.
108 const authorization = serverPassword
109 ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}`
110 : undefined;
111 for (const path of ["/api/info", "/global/health"] as const) {
112 // A path prefix and query on the configured URL are kept: `/base/?x=1` → `/base/api/info?x=1`.
113 const url = new URL(baseUrl);
114 url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
115 const request = HttpClientRequest.get(url.href);
116 const result = yield* client
117 .execute(
118 authorization
119 ? HttpClientRequest.setHeader(request, "authorization", authorization)
120 : request,
121 )
122 .pipe(
123 Effect.flatMap((response) =>
124 Effect.map(response.text, (body) =>
125 classifyOpenCodeProbeResponse(path, {
126 status: response.status,
127 contentType: response.headers["content-type"],
128 body,
129 }),
130 ),
131 ),
132 Effect.mapError(
133 (cause) =>
134 new OpenCodeRuntime.OpenCodeRuntimeError({
135 operation: "probeOpenCodeServer",
136 detail: "Couldn't reach the OpenCode server.",
137 cause,
138 }),
139 ),
140 Effect.timeoutOrElse({
141 duration: OPENCODE_SERVER_PROBE_TIMEOUT,
142 orElse: () =>
143 Effect.fail(
144 new OpenCodeRuntime.OpenCodeRuntimeError({
145 operation: "probeOpenCodeServer",
146 detail: "Timed out while checking the OpenCode server version.",
147 }),
148 ),
149 }),
150 );
151 if (result === "unauthorized") {
152 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
153 operation: "probeOpenCodeServer",
154 detail: "401 Unauthorized: the OpenCode server rejected the password.",
155 });
156 }
157 if (result !== undefined) return result;
158 }
159 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
160 operation: "probeOpenCodeServer",
161 detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
162 });
163});
164
165/** Probes a configured server when `serverUrl` is set, otherwise the local binary. */
166export const probeOpenCodeRuntime = (
167 settings: {
168 readonly binaryPath: string;
169 readonly serverUrl: string;
170 readonly serverPassword: string;
171 },
172 environment?: NodeJS.ProcessEnv,
173): Effect.Effect<
174 ProbedOpenCode,
175 OpenCodeRuntime.OpenCodeRuntimeError,
176 HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime
177> =>
178 settings.serverUrl.trim().length > 0
179 ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword)
180 : probeOpenCodeBinary(settings.binaryPath, environment);
181
182/**
183 * One instance's runtime, remembered after the first successful probe. Settings changes rebuild
184 * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A
185 * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one.
186 */
187export const makeOpenCodeRuntimeProbe = <E>(probe: Effect.Effect<ProbedOpenCode, E>) =>
188 Effect.map(
189 Cache.makeWith(() => probe, {
190 capacity: 1,
191 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
192 }),
193 (cache) => ({
194 get: Cache.get(cache, undefined),
195 refresh: Cache.refresh(cache, undefined),
196 lastSuccess: Cache.getSuccess(cache, undefined),
197 }),
198 );
packages/provider-opencode/src/settings.ts created+66
...@@ -0,0 +1,66 @@
1/**
2 * OpenCode instance settings. Shared by the server driver and the client
3 * settings form, so it holds only browser-safe schema code.
4 *
5 * @module provider-opencode/settings
6 */
7import {
8 CustomModelSetting,
9 makeBinaryPathSetting,
10 makeProviderSettingsSchema,
11 TrimmedString,
12} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as Schema from "effect/Schema";
15
16export const OpenCodeSettings = makeProviderSettingsSchema(
17 {
18 // Off by default (like Cursor and Grok): the binding is not yet stable
19 // enough to probe on every install. Users opt in from Settings.
20 enabled: Schema.Boolean.pipe(
21 Schema.withDecodingDefault(Effect.succeed(false)),
22 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
23 ),
24 binaryPath: makeBinaryPathSetting("opencode").pipe(
25 Schema.annotateKey({
26 title: "Binary path",
27 description: "Path to the OpenCode binary.",
28 providerSettingsForm: {
29 placeholder: "opencode",
30 clearWhenEmpty: "omit",
31 },
32 }),
33 ),
34 serverUrl: TrimmedString.pipe(
35 Schema.withDecodingDefault(Effect.succeed("")),
36 Schema.annotateKey({
37 title: "Server URL",
38 description: "Leave blank to let T3 Code spawn the server when needed.",
39 providerSettingsForm: {
40 placeholder: "http://127.0.0.1:4096",
41 clearWhenEmpty: "omit",
42 },
43 }),
44 ),
45 serverPassword: TrimmedString.pipe(
46 Schema.withDecodingDefault(Effect.succeed("")),
47 Schema.annotateKey({
48 title: "Server password",
49 description: "Stored in plain text on disk.",
50 providerSettingsForm: {
51 control: "password",
52 placeholder: "Optional",
53 clearWhenEmpty: "omit",
54 },
55 }),
56 ),
57 customModels: Schema.Array(CustomModelSetting).pipe(
58 Schema.withDecodingDefault(Effect.succeed([])),
59 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
60 ),
61 },
62 {
63 order: ["binaryPath", "serverUrl", "serverPassword"],
64 },
65);
66export type OpenCodeSettings = typeof OpenCodeSettings.Type;
packages/provider-opencode/src/testing.ts created+13
...@@ -0,0 +1,13 @@
1/**
2 * OpenCode internals the server's replay testkits and live tests drive
3 * directly: the 2.x client, server, adapter, and text generation, plus the
4 * 1.x transcript identifiers.
5 *
6 * @module provider-opencode/testing
7 */
8export {
9 OPENCODE_DEFAULT_INSTANCE_ID,
10 OPENCODE_PROVIDER,
11 OPENCODE_SDK_PROTOCOL,
12} from "./server/adapter.ts";
13export { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./server/v2/adapter.ts";
packages/provider-opencode/tsconfig.json created+7
...@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
packages/shared/package.json+4
...@@ -462,6 +462,10 @@...@@ -462,6 +462,10 @@
462 "./mcpApp": {462 "./mcpApp": {
463 "types": "./src/mcpApp.ts",463 "types": "./src/mcpApp.ts",
464 "import": "./src/mcpApp.ts"464 "import": "./src/mcpApp.ts"
465 },
466 "./atomicWrite": {
467 "types": "./src/atomicWrite.ts",
468 "import": "./src/atomicWrite.ts"
465 }469 }
466 },470 },
467 "scripts": {471 "scripts": {
packages/shared/src/atomicWrite.test.ts created+185
...@@ -0,0 +1,185 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9
10import { writeFileStringAtomically } from "./atomicWrite.ts";
11
12it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => {
13 it.effect("keeps a symlinked file linked and rewrites its destination", () =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
18 const destination = path.join(root, "dotfiles", "settings.json");
19 const link = path.join(root, "home", "settings.json");
20 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
21 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
22 yield* fs.writeFileString(destination, "before");
23 yield* fs.symlink(destination, link);
24
25 yield* writeFileStringAtomically({ filePath: link, contents: "after" });
26
27 assert.strictEqual(yield* fs.readLink(link), destination);
28 assert.strictEqual(yield* fs.readFileString(destination), "after");
29 }),
30 );
31
32 it.effect("keeps a dangling symlink linked and creates its destination", () =>
33 Effect.gen(function* () {
34 const fs = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
37 const destination = path.join(root, "dotfiles", "settings.json");
38 const link = path.join(root, "home", "settings.json");
39 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
40 yield* fs.symlink(destination, link);
41
42 yield* writeFileStringAtomically({ filePath: link, contents: "fresh" });
43
44 assert.strictEqual(yield* fs.readLink(link), destination);
45 assert.strictEqual(yield* fs.readFileString(destination), "fresh");
46 }),
47 );
48
49 it.effect("fails on a symlink cycle without replacing either link", () =>
50 Effect.gen(function* () {
51 const fs = yield* FileSystem.FileSystem;
52 const path = yield* Path.Path;
53 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
54 const first = path.join(root, "first.json");
55 const second = path.join(root, "second.json");
56 yield* fs.symlink(second, first);
57 yield* fs.symlink(first, second);
58
59 const result = yield* Effect.exit(
60 writeFileStringAtomically({ filePath: first, contents: "after" }),
61 );
62
63 assert.isTrue(Exit.isFailure(result));
64 assert.strictEqual(yield* fs.readLink(first), second);
65 assert.strictEqual(yield* fs.readLink(second), first);
66 }),
67 );
68
69 it.effect("resolves a relative link through a symlinked parent directory", () =>
70 Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
74 const destination = path.join(root, "dotfiles", "config", "settings.json");
75 const linkedState = path.join(root, "dotfiles", "state");
76 const home = path.join(root, "home");
77 const link = path.join(home, "state", "settings.json");
78 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
79 yield* fs.makeDirectory(linkedState, { recursive: true });
80 yield* fs.makeDirectory(home, { recursive: true });
81 yield* fs.symlink(linkedState, path.join(home, "state"));
82 yield* fs.writeFileString(destination, "before");
83 yield* fs.symlink("../config/settings.json", link);
84
85 yield* writeFileStringAtomically({ filePath: link, contents: "after" });
86
87 assert.strictEqual(yield* fs.readLink(link), "../config/settings.json");
88 assert.strictEqual(yield* fs.readFileString(destination), "after");
89 }),
90 );
91
92 it.effect("creates a missing file and its directory", () =>
93 Effect.gen(function* () {
94 const fs = yield* FileSystem.FileSystem;
95 const path = yield* Path.Path;
96 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
97 const filePath = path.join(root, "nested", "settings.json");
98
99 yield* writeFileStringAtomically({ filePath, contents: "fresh" });
100
101 assert.strictEqual(yield* fs.readFileString(filePath), "fresh");
102 }),
103 );
104});
105
106it.effect("surfaces an unreadable link instead of writing over it", () =>
107 Effect.gen(function* () {
108 const readLinkFailure = PlatformError.systemError({
109 _tag: "Unknown",
110 module: "FileSystem",
111 method: "readLink",
112 pathOrDescriptor: "/home/settings.json",
113 });
114
115 const result = yield* Effect.exit(
116 writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }),
117 );
118
119 assert.deepStrictEqual(result, Exit.fail(readLinkFailure));
120 }).pipe(
121 Effect.provide(
122 Layer.mergeAll(
123 Path.layer,
124 FileSystem.layerNoop({
125 readLink: () =>
126 Effect.fail(
127 PlatformError.systemError({
128 _tag: "Unknown",
129 module: "FileSystem",
130 method: "readLink",
131 pathOrDescriptor: "/home/settings.json",
132 }),
133 ),
134 rename: () => Effect.die("an unreadable link must not be replaced"),
135 }),
136 ),
137 ),
138 ),
139);
140
141it.effect("succeeds when the write lands but its temp directory cannot be removed", () =>
142 Effect.gen(function* () {
143 const renamed: Array<string> = [];
144 const removed: Array<string> = [];
145 const fileSystem = FileSystem.layerNoop({
146 // The target does not exist yet, so it is written in place.
147 readLink: (path) =>
148 Effect.fail(
149 PlatformError.systemError({
150 _tag: "NotFound",
151 module: "FileSystem",
152 method: "readLink",
153 pathOrDescriptor: path,
154 }),
155 ),
156 makeDirectory: () => Effect.void,
157 makeTempDirectory: () => Effect.succeed("/home/settings.json.abc123"),
158 writeFileString: () => Effect.void,
159 rename: (_from, to) => Effect.sync(() => void renamed.push(to)),
160 remove: (path) =>
161 Effect.sync(() => void removed.push(path)).pipe(
162 Effect.andThen(
163 Effect.fail(
164 PlatformError.systemError({
165 _tag: "PermissionDenied",
166 module: "FileSystem",
167 method: "remove",
168 pathOrDescriptor: path,
169 }),
170 ),
171 ),
172 ),
173 });
174
175 const result = yield* Effect.exit(
176 writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }).pipe(
177 Effect.provide(Layer.mergeAll(Path.layer, fileSystem)),
178 ),
179 );
180
181 assert.deepStrictEqual(result, Exit.void);
182 assert.deepStrictEqual(renamed, ["/home/settings.json"]);
183 assert.deepStrictEqual(removed, ["/home/settings.json.abc123"]);
184 }),
185);
packages/shared/src/atomicWrite.ts created+38
...@@ -0,0 +1,38 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
5
6/**
7 * Replaces a file's contents via a sibling temp file and rename. A symlinked
8 * target is resolved first so the link survives and its destination is
9 * rewritten, since renaming over the link itself would swap it for a regular file.
10 */
11export const writeFileStringAtomically = (input: {
12 readonly filePath: string;
13 readonly contents: string;
14}) =>
15 Effect.scoped(
16 Effect.gen(function* () {
17 const fs = yield* FileSystem.FileSystem;
18 const path = yield* Path.Path;
19 const targetPath = yield* resolveSymlinkTarget(input.filePath);
20 const targetDirectory = path.dirname(targetPath);
21
22 yield* fs.makeDirectory(targetDirectory, { recursive: true });
23 // The temp directory is cleanup, not part of the write: failing to remove
24 // it (a virus scanner holding it on Windows) must not fail a write that
25 // already landed.
26 const tempDirectory = yield* Effect.acquireRelease(
27 fs.makeTempDirectory({
28 directory: targetDirectory,
29 prefix: `${path.basename(targetPath)}.`,
30 }),
31 (directory) => fs.remove(directory, { recursive: true }).pipe(Effect.ignore({ log: true })),
32 );
33 const tempPath = path.join(tempDirectory, "contents.tmp");
34
35 yield* fs.writeFileString(tempPath, input.contents);
36 yield* fs.rename(tempPath, targetPath);
37 }),
38 );
pnpm-lock.yaml+52-9
...@@ -359,6 +359,9 @@ importers:...@@ -359,6 +359,9 @@ importers:
359 '@t3tools/provider-muse':359 '@t3tools/provider-muse':
360 specifier: workspace:*360 specifier: workspace:*
361 version: link:../../packages/provider-muse361 version: link:../../packages/provider-muse
362 '@t3tools/provider-opencode':
363 specifier: workspace:*
364 version: link:../../packages/provider-opencode
362 '@t3tools/provider-pi':365 '@t3tools/provider-pi':
363 specifier: workspace:*366 specifier: workspace:*
364 version: link:../../packages/provider-pi367 version: link:../../packages/provider-pi
...@@ -594,21 +597,15 @@ importers:...@@ -594,21 +597,15 @@ importers:
594 '@opencode-ai/sdk':597 '@opencode-ai/sdk':
595 specifier: ^1.3.15598 specifier: ^1.3.15
596 version: 1.15.13599 version: 1.15.13
597 '@opencode/client':
598 specifier: 2.0.23
599 version: 2.0.23(patch_hash=f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
600 '@opencode/protocol':
601 specifier: 2.0.23
602 version: 2.0.23(patch_hash=17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685)
603 '@opencode/schema':
604 specifier: 2.0.23
605 version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59)
606 '@t3tools/provider-core':600 '@t3tools/provider-core':
607 specifier: workspace:*601 specifier: workspace:*
608 version: link:../../packages/provider-core602 version: link:../../packages/provider-core
609 '@t3tools/provider-muse':603 '@t3tools/provider-muse':
610 specifier: workspace:*604 specifier: workspace:*
611 version: link:../../packages/provider-muse605 version: link:../../packages/provider-muse
606 '@t3tools/provider-opencode':
607 specifier: workspace:*
608 version: link:../../packages/provider-opencode
612 '@t3tools/provider-pi':609 '@t3tools/provider-pi':
613 specifier: workspace:*610 specifier: workspace:*
614 version: link:../../packages/provider-pi611 version: link:../../packages/provider-pi
...@@ -742,6 +739,9 @@ importers:...@@ -742,6 +739,9 @@ importers:
742 '@t3tools/provider-muse':739 '@t3tools/provider-muse':
743 specifier: workspace:*740 specifier: workspace:*
744 version: link:../../packages/provider-muse741 version: link:../../packages/provider-muse
742 '@t3tools/provider-opencode':
743 specifier: workspace:*
744 version: link:../../packages/provider-opencode
745 '@t3tools/provider-pi':745 '@t3tools/provider-pi':
746 specifier: workspace:*746 specifier: workspace:*
747 version: link:../../packages/provider-pi747 version: link:../../packages/provider-pi
...@@ -1142,6 +1142,49 @@ importers:...@@ -1142,6 +1142,49 @@ importers:
1142 specifier: 1.0.01142 specifier: 1.0.0
1143 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)1143 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
11441144
1145 packages/provider-opencode:
1146 dependencies:
1147 '@opencode-ai/sdk':
1148 specifier: ^1.3.15
1149 version: 1.15.13
1150 '@opencode/client':
1151 specifier: 2.0.23
1152 version: 2.0.23(patch_hash=f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
1153 '@opencode/protocol':
1154 specifier: 2.0.23
1155 version: 2.0.23(patch_hash=17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685)
1156 '@opencode/schema':
1157 specifier: 2.0.23
1158 version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59)
1159 '@t3tools/contracts':
1160 specifier: workspace:*
1161 version: link:../contracts
1162 '@t3tools/provider-core':
1163 specifier: workspace:*
1164 version: link:../provider-core
1165 '@t3tools/shared':
1166 specifier: workspace:*
1167 version: link:../shared
1168 effect:
1169 specifier: 4.0.1
1170 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1171 devDependencies:
1172 '@effect/platform-node':
1173 specifier: 4.0.1
1174 version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6)
1175 '@effect/vitest':
1176 specifier: 4.0.1
1177 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1178 '@t3tools/provider-testing':
1179 specifier: workspace:*
1180 version: link:../provider-testing
1181 '@types/node':
1182 specifier: 24.12.4
1183 version: 24.12.4
1184 vite-plus:
1185 specifier: 1.0.0
1186 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1187
1145 packages/provider-pi:1188 packages/provider-pi:
1146 dependencies:1189 dependencies:
1147 '@t3tools/contracts':1190 '@t3tools/contracts':
scripts/release-smoke.ts+1
...@@ -32,6 +32,7 @@ const workspaceFiles = [...@@ -32,6 +32,7 @@ const workspaceFiles = [
32 "packages/provider-testing/package.json",32 "packages/provider-testing/package.json",
33 "packages/provider-pi/package.json",33 "packages/provider-pi/package.json",
34 "packages/provider-muse/package.json",34 "packages/provider-muse/package.json",
35 "packages/provider-opencode/package.json",
35 "packages/effect-acp/package.json",36 "packages/effect-acp/package.json",
36 "packages/effect-codex-app-server/package.json",37 "packages/effect-codex-app-server/package.json",
37 "scripts/package.json",38 "scripts/package.json",