Merge branch 'dev' into nxl/improve-compaction-strategy

This commit is contained in:
Brendan Allan
2026-04-17 11:53:17 +08:00
234 changed files with 21770 additions and 20859 deletions
+39 -39
View File
@@ -18,14 +18,14 @@ const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
it.live("list returns empty when no accounts exist", () =>
Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list())
const accounts = yield* AccountRepo.Service.use((r) => r.list())
expect(accounts).toEqual([])
}),
)
it.live("active returns none when no accounts exist", () =>
Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active())
const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
@@ -33,7 +33,7 @@ it.live("active returns none when no accounts exist", () =>
it.live("persistAccount inserts and getRow retrieves", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -45,13 +45,13 @@ it.live("persistAccount inserts and getRow retrieves", () =>
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
expect(Option.isSome(row)).toBe(true)
const value = Option.getOrThrow(row)
expect(value.id).toBe(AccountID.make("user-1"))
expect(value.email).toBe("test@example.com")
const active = yield* AccountRepo.use((r) => r.active())
const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
}),
)
@@ -60,7 +60,7 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -72,9 +72,9 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const active = yield* AccountRepo.use((r) => r.active())
const list = yield* AccountRepo.use((r) => r.list())
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const active = yield* AccountRepo.Service.use((r) => r.active())
const list = yield* AccountRepo.Service.use((r) => r.list())
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
@@ -87,7 +87,7 @@ it.live("persistAccount sets the active account and org", () =>
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
@@ -99,7 +99,7 @@ it.live("persistAccount sets the active account and org", () =>
}),
)
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
@@ -112,7 +112,7 @@ it.live("persistAccount sets the active account and org", () =>
)
// Last persisted account is active with its org
const active = yield* AccountRepo.use((r) => r.active())
const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.isSome(active)).toBe(true)
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
@@ -124,7 +124,7 @@ it.live("list returns all accounts", () =>
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id1,
email: "a@example.com",
@@ -136,7 +136,7 @@ it.live("list returns all accounts", () =>
}),
)
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id2,
email: "b@example.com",
@@ -148,7 +148,7 @@ it.live("list returns all accounts", () =>
}),
)
const accounts = yield* AccountRepo.use((r) => r.list())
const accounts = yield* AccountRepo.Service.use((r) => r.list())
expect(accounts.length).toBe(2)
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
}),
@@ -158,7 +158,7 @@ it.live("remove deletes an account", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -170,9 +170,9 @@ it.live("remove deletes an account", () =>
}),
)
yield* AccountRepo.use((r) => r.remove(id))
yield* AccountRepo.Service.use((r) => r.remove(id))
const row = yield* AccountRepo.use((r) => r.getRow(id))
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
expect(Option.isNone(row)).toBe(true)
}),
)
@@ -182,7 +182,7 @@ it.live("use stores the selected org and marks the account active", () =>
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
@@ -194,7 +194,7 @@ it.live("use stores the selected org and marks the account active", () =>
}),
)
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
@@ -206,13 +206,13 @@ it.live("use stores the selected org and marks the account active", () =>
}),
)
yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
const active1 = yield* AccountRepo.use((r) => r.active())
yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
const active1 = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active1).id).toBe(id1)
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
yield* AccountRepo.use((r) => r.use(id1, Option.none()))
const active2 = yield* AccountRepo.use((r) => r.active())
yield* AccountRepo.Service.use((r) => r.use(id1, Option.none()))
const active2 = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
}),
)
@@ -221,7 +221,7 @@ it.live("persistToken updates token fields", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -234,7 +234,7 @@ it.live("persistToken updates token fields", () =>
)
const expiry = Date.now() + 7200_000
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistToken({
accountID: id,
accessToken: AccessToken.make("new_token"),
@@ -243,7 +243,7 @@ it.live("persistToken updates token fields", () =>
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("new_token"))
expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
@@ -255,7 +255,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -267,7 +267,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
}),
)
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistToken({
accountID: id,
accessToken: AccessToken.make("new_token"),
@@ -276,7 +276,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
expect(Option.getOrThrow(row).token_expiry).toBeNull()
}),
)
@@ -285,7 +285,7 @@ it.live("persistAccount upserts on conflict", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -297,7 +297,7 @@ it.live("persistAccount upserts on conflict", () =>
}),
)
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -309,14 +309,14 @@ it.live("persistAccount upserts on conflict", () =>
}),
)
const accounts = yield* AccountRepo.use((r) => r.list())
const accounts = yield* AccountRepo.Service.use((r) => r.list())
expect(accounts.length).toBe(1)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_v2"))
const active = yield* AccountRepo.use((r) => r.active())
const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
}),
)
@@ -325,7 +325,7 @@ it.live("remove clears active state when deleting the active account", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -337,16 +337,16 @@ it.live("remove clears active state when deleting the active account", () =>
}),
)
yield* AccountRepo.use((r) => r.remove(id))
yield* AccountRepo.Service.use((r) => r.remove(id))
const active = yield* AccountRepo.use((r) => r.active())
const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
it.live("getRow returns none for nonexistent account", () =>
Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
const row = yield* AccountRepo.Service.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true)
}),
)
+11 -11
View File
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { Account } from "../../src/account"
import { Account } from "../../src/account/account"
import {
AccessToken,
AccountID,
@@ -122,7 +122,7 @@ it.live("login maps transport failures to account transport errors", () =>
it.live("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: AccountID.make("user-1"),
email: "one@example.com",
@@ -134,7 +134,7 @@ it.live("orgsByAccount groups orgs per account", () =>
}),
)
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: AccountID.make("user-2"),
email: "two@example.com",
@@ -177,7 +177,7 @@ it.live("token refresh persists the new token", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
@@ -206,7 +206,7 @@ it.live("token refresh persists the new token", () =>
expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new")
const row = yield* AccountRepo.use((r) => r.getRow(id))
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
@@ -218,7 +218,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
@@ -251,7 +251,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
expect(String(Option.getOrThrow(token))).toBe("at_new")
expect(refreshCalls).toBe(1)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
@@ -262,7 +262,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
@@ -315,7 +315,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
expect(String(Option.getOrThrow(token))).toBe("at_new")
expect(refreshCalls).toBe(1)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
@@ -326,7 +326,7 @@ it.live("config sends the selected org header", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
@@ -388,7 +388,7 @@ it.live("poll stores the account and first org on success", () =>
expect(res.email).toBe("user@example.com")
}
const active = yield* AccountRepo.use((r) => r.active())
const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active)).toEqual(
expect.objectContaining({
id: "user-1",
@@ -331,7 +331,7 @@ export default {
const localOpts = {
fn_marker: tmp.extra.fnMarker,
marker: tmp.extra.localMarker,
source: tmp.extra.localDest.replace(".opencode/themes/", ""),
source: path.join(tmp.path, tmp.extra.localThemeFile),
dest: tmp.extra.localDest,
theme_path: `./${tmp.extra.localThemeFile}`,
theme_name: tmp.extra.localThemeName,
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import { getRevertDiffFiles } from "../../../src/cli/cmd/tui/util/revert-diff"
describe("revert diff", () => {
test("prefers the actual file path over /dev/null for added and deleted files", () => {
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/new.txt
@@ -0,0 +1 @@
+new content
diff --git a/old.txt b/old.txt
deleted file mode 100644
index 3b18e51..0000000
--- a/old.txt
+++ /dev/null
@@ -1 +0,0 @@
-old content
`)
expect(files).toEqual([
{
filename: "new.txt",
additions: 1,
deletions: 0,
},
{
filename: "old.txt",
additions: 0,
deletions: 1,
},
])
})
})
@@ -264,27 +264,15 @@ describe("SyncProvider", () => {
log.length = 0
await sync.session.sync("ses_1")
expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
log.length = 0
project.workspace.set("ws_b")
await waitBoot(log, "ws_b")
expect(project.workspace.current()).toBe("ws_b")
log.length = 0
await sync.session.sync("ses_1")
await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
} finally {
app.renderer.destroy()
}
+117 -49
View File
@@ -1,16 +1,18 @@
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Option } from "effect"
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config"
import { Config, ConfigManaged } from "../../src/config"
import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Env } from "../../src/env"
import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
@@ -24,7 +26,6 @@ import { pathToFileURL } from "url"
import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util"
import * as Network from "../../src/util/network"
import { ConfigPlugin } from "@/config/plugin"
import { Npm } from "@opencode-ai/shared/npm"
@@ -141,6 +142,42 @@ test("loads JSON config file", async () => {
})
})
test("loads formatter boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
formatter: true,
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.formatter).toBe(true)
},
})
})
test("loads lsp boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
lsp: true,
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.lsp).toBe(true)
},
})
})
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
await check((dir) => {
@@ -757,7 +794,7 @@ test("updates config and writes to file", async () => {
const newConfig = { model: "updated/model" }
await save(newConfig as any)
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model")
},
})
@@ -846,6 +883,9 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
},
})
// TODO: this is a hack to wait for backgruounded gitignore
await new Promise((resolve) => setTimeout(resolve, 1000))
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
} finally {
@@ -1860,14 +1900,14 @@ describe("resolvePluginSpec", () => {
})
const file = path.join(tmp.path, "opencode.json")
const hit = await Config.resolvePluginSpec("./plugin", file)
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
})
})
describe("deduplicatePluginOrigins", () => {
const dedupe = (plugins: Config.PluginSpec[]) =>
Config.deduplicatePluginOrigins(
const dedupe = (plugins: ConfigPlugin.Spec[]) =>
ConfigPlugin.deduplicatePluginOrigins(
plugins.map((spec) => ({
spec,
source: "",
@@ -1937,8 +1977,8 @@ describe("deduplicatePluginOrigins", () => {
const config = await load()
const plugins = config.plugin ?? []
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true)
},
})
})
@@ -2209,17 +2249,23 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
// parseManagedPlist unit tests — pure function, no OS interaction
test("parseManagedPlist strips MDM metadata keys", async () => {
const config = await Config.parseManagedPlist(
JSON.stringify({
PayloadDisplayName: "OpenCode Managed",
PayloadIdentifier: "ai.opencode.managed.test",
PayloadType: "ai.opencode.managed",
PayloadUUID: "AAAA-BBBB-CCCC",
PayloadVersion: 1,
_manualProfile: true,
share: "disabled",
model: "mdm/model",
}),
const config = ConfigParse.schema(
Config.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
PayloadDisplayName: "OpenCode Managed",
PayloadIdentifier: "ai.opencode.managed.test",
PayloadType: "ai.opencode.managed",
PayloadUUID: "AAAA-BBBB-CCCC",
PayloadVersion: 1,
_manualProfile: true,
share: "disabled",
model: "mdm/model",
}),
),
"test:mobileconfig",
),
"test:mobileconfig",
)
expect(config.share).toBe("disabled")
@@ -2231,12 +2277,18 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
})
test("parseManagedPlist parses server settings", async () => {
const config = await Config.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
server: { hostname: "127.0.0.1", mdns: false },
autoupdate: true,
}),
const config = ConfigParse.schema(
Config.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
server: { hostname: "127.0.0.1", mdns: false },
autoupdate: true,
}),
),
"test:mobileconfig",
),
"test:mobileconfig",
)
expect(config.server?.hostname).toBe("127.0.0.1")
@@ -2245,18 +2297,24 @@ test("parseManagedPlist parses server settings", async () => {
})
test("parseManagedPlist parses permission rules", async () => {
const config = await Config.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
"*": "ask",
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
grep: "allow",
glob: "allow",
webfetch: "ask",
"~/.ssh/*": "deny",
},
}),
const config = ConfigParse.schema(
Config.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
"*": "ask",
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
grep: "allow",
glob: "allow",
webfetch: "ask",
"~/.ssh/*": "deny",
},
}),
),
"test:mobileconfig",
),
"test:mobileconfig",
)
expect(config.permission?.["*"]).toBe("ask")
@@ -2269,19 +2327,29 @@ test("parseManagedPlist parses permission rules", async () => {
})
test("parseManagedPlist parses enabled_providers", async () => {
const config = await Config.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic", "google"],
}),
const config = ConfigParse.schema(
Config.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic", "google"],
}),
),
"test:mobileconfig",
),
"test:mobileconfig",
)
expect(config.enabled_providers).toEqual(["anthropic", "google"])
})
test("parseManagedPlist handles empty config", async () => {
const config = await Config.parseManagedPlist(
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
const config = ConfigParse.schema(
Config.Info,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
"test:mobileconfig",
),
"test:mobileconfig",
)
expect(config.$schema).toBe("https://opencode.ai/config.json")
+100 -67
View File
@@ -10,37 +10,55 @@ import * as Formatter from "../../src/format/formatter"
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
describe("Format", () => {
it.live("status() returns built-in formatters when no config overrides", () =>
it.live("status() returns empty list when no formatters are configured", () =>
provideTmpdirInstance(() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(Array.isArray(statuses)).toBe(true)
expect(statuses.length).toBeGreaterThan(0)
for (const item of statuses) {
expect(typeof item.name).toBe("string")
expect(Array.isArray(item.extensions)).toBe(true)
expect(typeof item.enabled).toBe("boolean")
}
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
expect(yield* fmt.status()).toEqual([])
}),
),
),
)
it.live("status() returns empty list when formatter is disabled", () =>
it.live("status() returns built-in formatters when formatter is true", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
expect(yield* fmt.status()).toEqual([])
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
}),
),
{ config: { formatter: false } },
{
config: {
formatter: true,
},
},
),
)
it.live("status() keeps built-in formatters when config object is provided", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
const mix = statuses.find((item) => item.name === "mix")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
expect(mix).toBeDefined()
}),
),
{
config: {
formatter: {
gofmt: {},
},
},
},
),
)
@@ -51,7 +69,9 @@ describe("Format", () => {
Effect.gen(function* () {
const statuses = yield* fmt.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
const mix = statuses.find((item) => item.name === "mix")
expect(gofmt).toBeUndefined()
expect(mix).toBeDefined()
}),
),
{
@@ -111,68 +131,81 @@ describe("Format", () => {
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
config: { formatter: false },
})
const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()))
const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
config: {
formatter: true,
},
})
expect(a).toEqual([])
expect(b.length).toBeGreaterThan(0)
expect(b.find((item) => item.name === "gofmt")).toBeDefined()
}),
)
it.live("runs enabled checks for matching formatters in parallel", () =>
provideTmpdirInstance((path) =>
Effect.gen(function* () {
const file = `${path}/test.parallel`
yield* Effect.promise(() => Bun.write(file, "x"))
provideTmpdirInstance(
(path) =>
Effect.gen(function* () {
const file = `${path}/test.parallel`
yield* Effect.promise(() => Bun.write(file, "x"))
const one = {
extensions: Formatter.gofmt.extensions,
enabled: Formatter.gofmt.enabled,
}
const two = {
extensions: Formatter.mix.extensions,
enabled: Formatter.mix.enabled,
}
const one = {
extensions: Formatter.gofmt.extensions,
enabled: Formatter.gofmt.enabled,
}
const two = {
extensions: Formatter.mix.extensions,
enabled: Formatter.mix.enabled,
}
let active = 0
let max = 0
let active = 0
let max = 0
yield* Effect.acquireUseRelease(
Effect.sync(() => {
Formatter.gofmt.extensions = [".parallel"]
Formatter.mix.extensions = [".parallel"]
Formatter.gofmt.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return ["sh", "-c", "true"]
}
Formatter.mix.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return ["sh", "-c", "true"]
}
}),
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
yield* fmt.file(file)
}),
),
() =>
yield* Effect.acquireUseRelease(
Effect.sync(() => {
Formatter.gofmt.extensions = one.extensions
Formatter.gofmt.enabled = one.enabled
Formatter.mix.extensions = two.extensions
Formatter.mix.enabled = two.enabled
Formatter.gofmt.extensions = [".parallel"]
Formatter.mix.extensions = [".parallel"]
Formatter.gofmt.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return ["sh", "-c", "true"]
}
Formatter.mix.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return ["sh", "-c", "true"]
}
}),
)
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
yield* fmt.file(file)
}),
),
() =>
Effect.sync(() => {
Formatter.gofmt.extensions = one.extensions
Formatter.gofmt.enabled = one.enabled
Formatter.mix.extensions = two.extensions
Formatter.mix.enabled = two.enabled
}),
)
expect(max).toBe(2)
}),
expect(max).toBe(2)
}),
{
config: {
formatter: {
gofmt: {},
mix: {},
},
},
},
),
)
+73 -19
View File
@@ -11,15 +11,38 @@ const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.default
describe("lsp.spawn", () => {
it.live("does not spawn builtin LSP for files outside instance", () =>
provideTmpdirInstance(
(dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
yield* lsp.hover({
file: path.join(dir, "..", "hover.ts"),
line: 0,
character: 0,
})
expect(spy).toHaveBeenCalledTimes(0)
} finally {
spy.mockRestore()
}
}),
),
{ config: { lsp: true } },
),
)
it.live("does not spawn builtin LSP for files inside instance when LSP is unset", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
yield* lsp.hover({
file: path.join(dir, "..", "hover.ts"),
file: path.join(dir, "src", "inside.ts"),
line: 0,
character: 0,
})
@@ -32,24 +55,55 @@ describe("lsp.spawn", () => {
),
)
it.live("would spawn builtin LSP for files inside instance", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
it.live("would spawn builtin LSP for files inside instance when lsp is true", () =>
provideTmpdirInstance(
(dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
yield* lsp.hover({
file: path.join(dir, "src", "inside.ts"),
line: 0,
character: 0,
})
expect(spy).toHaveBeenCalledTimes(1)
} finally {
spy.mockRestore()
}
}),
),
try {
yield* lsp.hover({
file: path.join(dir, "src", "inside.ts"),
line: 0,
character: 0,
})
expect(spy).toHaveBeenCalledTimes(1)
} finally {
spy.mockRestore()
}
}),
),
{ config: { lsp: true } },
),
)
it.live("would spawn builtin LSP for files inside instance when config object is provided", () =>
provideTmpdirInstance(
(dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
yield* lsp.hover({
file: path.join(dir, "src", "inside.ts"),
line: 0,
character: 0,
})
expect(spy).toHaveBeenCalledTimes(1)
} finally {
spy.mockRestore()
}
}),
),
{
config: {
lsp: {
eslint: { disabled: true },
},
},
},
),
)
})
+34 -2
View File
@@ -46,17 +46,49 @@ describe("LSP service lifecycle", () => {
),
)
it.live("hasClients() returns true for .ts files in instance", () =>
it.live("hasClients() returns false for .ts files in instance when LSP is unset", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
expect(result).toBe(true)
expect(result).toBe(false)
}),
),
),
)
it.live("hasClients() returns true for .ts files in instance when lsp is true", () =>
provideTmpdirInstance(
(dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
expect(result).toBe(true)
}),
),
{ config: { lsp: true } },
),
)
it.live("hasClients() keeps built-in LSPs when config object is provided", () =>
provideTmpdirInstance(
(dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
expect(result).toBe(true)
}),
),
{
config: {
lsp: {
eslint: { disabled: true },
},
},
},
),
)
it.live("hasClients() returns false for files outside instance", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
@@ -63,7 +63,7 @@ describe("plugin.auth-override", () => {
}, 30000) // Increased timeout for plugin installation
})
const file = path.join(import.meta.dir, "../../src/plugin/plugin.ts")
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
describe("plugin.config-hook-error-isolation", () => {
test("config hooks are individually error-isolated in the layer factory", async () => {
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test"
import { CloudflareAIGatewayAuthPlugin } from "@/plugin/cloudflare"
const pluginInput = {
client: {} as never,
project: {} as never,
directory: "",
worktree: "",
experimental_workspace: {
register() {},
},
serverUrl: new URL("https://example.com"),
$: {} as never,
}
function makeHookInput(overrides: { providerID?: string; apiId?: string; reasoning?: boolean }) {
return {
sessionID: "s",
agent: "a",
provider: {} as never,
message: {} as never,
model: {
providerID: overrides.providerID ?? "cloudflare-ai-gateway",
api: { id: overrides.apiId ?? "openai/gpt-5.2-codex", url: "", npm: "ai-gateway-provider" },
capabilities: {
reasoning: overrides.reasoning ?? true,
temperature: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
} as never,
}
}
function makeHookOutput() {
return { temperature: 0, topP: 1, topK: 0, maxOutputTokens: 32_000 as number | undefined, options: {} }
}
test("omits maxOutputTokens for openai reasoning models on cloudflare-ai-gateway", async () => {
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
const out = makeHookOutput()
await hooks["chat.params"]!(makeHookInput({ apiId: "openai/gpt-5.2-codex", reasoning: true }), out)
expect(out.maxOutputTokens).toBeUndefined()
})
test("keeps maxOutputTokens for openai non-reasoning models", async () => {
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
const out = makeHookOutput()
await hooks["chat.params"]!(makeHookInput({ apiId: "openai/gpt-4-turbo", reasoning: false }), out)
expect(out.maxOutputTokens).toBe(32_000)
})
test("keeps maxOutputTokens for non-openai reasoning models on cloudflare-ai-gateway", async () => {
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
const out = makeHookOutput()
await hooks["chat.params"]!(makeHookInput({ apiId: "anthropic/claude-sonnet-4-5", reasoning: true }), out)
expect(out.maxOutputTokens).toBe(32_000)
})
test("ignores non-cloudflare-ai-gateway providers", async () => {
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
const out = makeHookOutput()
await hooks["chat.params"]!(makeHookInput({ providerID: "openai", apiId: "gpt-5.2-codex", reasoning: true }), out)
expect(out.maxOutputTokens).toBe(32_000)
})
@@ -14,7 +14,6 @@ const { Instance } = await import("../../src/project/instance")
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
// @ts-expect-error tests override the flag directly
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
afterEach(async () => {
@@ -28,7 +27,6 @@ afterAll(() => {
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
}
// @ts-expect-error restore original test flag value
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental
})
@@ -100,6 +100,24 @@ describe("ProviderTransform.options - setCacheKey", () => {
})
expect(result.store).toBe(false)
})
test("should set store=true for azure provider by default", () => {
const azureModel = {
...mockModel,
providerID: "azure",
api: {
id: "gpt-4",
url: "https://azure.com",
npm: "@ai-sdk/azure",
},
}
const result = ProviderTransform.options({
model: azureModel,
sessionID,
providerOptions: {},
})
expect(result.store).toBe(true)
})
})
describe("ProviderTransform.options - zai/zhipuai thinking", () => {
@@ -2246,6 +2264,46 @@ describe("ProviderTransform.variants", () => {
})
})
test("anthropic opus 4.7 models return adaptive thinking options with xhigh", () => {
const model = createMockModel({
id: "anthropic/claude-opus-4-7",
providerID: "gateway",
api: {
id: "anthropic/claude-opus-4-7",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.xhigh).toEqual({
thinking: {
type: "adaptive",
},
effort: "xhigh",
})
expect(result.max).toEqual({
thinking: {
type: "adaptive",
},
effort: "max",
})
})
test("anthropic opus 4.7 dot-format models return adaptive thinking options with xhigh", () => {
const model = createMockModel({
id: "anthropic/claude-opus-4-7",
providerID: "gateway",
api: {
id: "anthropic/claude-opus-4.7",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
})
test("anthropic models return anthropic thinking options", () => {
const model = createMockModel({
id: "anthropic/claude-sonnet-4",
@@ -2654,6 +2712,34 @@ describe("ProviderTransform.variants", () => {
})
})
test("opus 4.7 returns adaptive thinking options with xhigh", () => {
const model = createMockModel({
id: "anthropic/claude-opus-4-7",
providerID: "anthropic",
api: {
id: "claude-opus-4-7",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.xhigh).toEqual({
thinking: {
type: "adaptive",
display: "summarized",
},
effort: "xhigh",
})
expect(result.max).toEqual({
thinking: {
type: "adaptive",
display: "summarized",
},
effort: "max",
})
})
test("returns high and max with thinking config", () => {
const model = createMockModel({
id: "anthropic/claude-4",
@@ -2702,6 +2788,32 @@ describe("ProviderTransform.variants", () => {
})
})
test("anthropic opus 4.7 returns adaptive reasoning options with xhigh", () => {
const model = createMockModel({
id: "bedrock/anthropic-claude-opus-4-7",
providerID: "bedrock",
api: {
id: "anthropic.claude-opus-4-7",
url: "https://bedrock.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.xhigh).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "xhigh",
},
})
expect(result.max).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "max",
},
})
})
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
const model = createMockModel({
id: "bedrock/llama-4",
@@ -157,16 +157,6 @@ describe("structured-output.AssistantMessage", () => {
})
describe("structured-output.createStructuredOutputTool", () => {
test("creates tool with correct id", () => {
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object", properties: { name: { type: "string" } } },
onSuccess: () => {},
})
// AI SDK tool type doesn't expose id, but we set it internally
expect((tool as any).id).toBe("StructuredOutput")
})
test("creates tool with description", () => {
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object" },
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Layer, Option } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account"
import { Account } from "../../src/account"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
import { Account } from "../../src/account/account"
import { AccountRepo } from "../../src/account/repo"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Bus } from "../../src/bus"
@@ -72,7 +72,7 @@ const share = (id: SessionID) =>
Database.use((db) => db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get())
const seed = (url: string, org?: string) =>
AccountRepo.use((repo) =>
AccountRepo.Service.use((repo) =>
repo.persistAccount({
id: AccountID.make("account-1"),
email: "user@example.com",
+48 -2
View File
@@ -15,12 +15,10 @@ const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
beforeEach(() => {
Database.close()
// @ts-expect-error don't do this normally, but it works
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
})
afterEach(() => {
// @ts-expect-error don't do this normally, but it works
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
})
@@ -187,5 +185,53 @@ describe("SyncEvent", () => {
).toThrow(/Unknown event type/)
}),
)
test(
"replayAll accepts later chunks after the first batch",
withInstance(() => {
const { Created } = setup()
const id = Identifier.descending("message")
const one = SyncEvent.replayAll([
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "second" },
},
])
const two = SyncEvent.replayAll([
{
id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 2,
aggregateID: id,
data: { id, name: "third" },
},
{
id: "evt_4",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 3,
aggregateID: id,
data: { id, name: "fourth" },
},
])
expect(one).toBe(id)
expect(two).toBe(id)
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
)
})
})
@@ -0,0 +1,278 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { GlobalBus } from "../../src/bus/global"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Flag } from "../../src/flag/flag"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Database, asc, eq } from "../../src/storage"
import { SyncEvent } from "../../src/sync"
import { EventTable } from "../../src/sync/event.sql"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
beforeEach(() => {
Database.close()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
})
afterEach(async () => {
mock.restore()
await Instance.disposeAll()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
await resetDatabase()
})
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
}
async function user(sessionID: SessionID, text: string) {
const msg = await updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
await updatePart({
id: PartID.ascending(),
sessionID,
messageID: msg.id,
type: "text",
text,
})
}
function remote(dir: string, url: string): WorkspaceAdaptor {
return {
name: "remote",
description: "remote",
configure(info) {
return {
...info,
directory: dir,
}
},
async create() {
await fs.mkdir(dir, { recursive: true })
},
async remove() {},
target() {
return {
type: "remote" as const,
url,
}
},
}
}
function local(dir: string): WorkspaceAdaptor {
return {
name: "local",
description: "local",
configure(info) {
return {
...info,
directory: dir,
}
},
async create() {
await fs.mkdir(dir, { recursive: true })
},
async remove() {},
target() {
return {
type: "local" as const,
directory: dir,
}
},
}
}
function eventStreamResponse() {
return new Response(new ReadableStream({ start() {} }), {
status: 200,
headers: {
"content-type": "text/event-stream",
},
})
}
describe("Workspace.sessionRestore", () => {
test("replays session events in batches of 10 and emits progress", async () => {
await using tmp = await tmpdir({ git: true })
const dir = path.join(tmp.path, ".restore")
const seen: any[] = []
const posts: Array<{
path: string
body: { directory: string; events: Array<{ seq: number; aggregateID: string }> }
}> = []
const on = (evt: any) => seen.push(evt)
GlobalBus.on("event", on)
const raw = globalThis.fetch
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
if (url.pathname !== "/base/sync/replay") {
return eventStreamResponse()
}
const body = JSON.parse(String(init?.body))
posts.push({
path: url.pathname,
body,
})
return Response.json({ sessionID: body.events[0].aggregateID })
},
{
preconnect: raw.preconnect?.bind(raw),
},
) as typeof globalThis.fetch,
)
try {
const setup = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base"))
const space = await Workspace.create({
type: "worktree",
branch: null,
extra: null,
projectID: Instance.project.id,
})
const session = await create({})
for (let i = 0; i < 6; i++) {
await user(session.id, `msg ${i}`)
}
const rows = Database.use((db) =>
db
.select({ seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.aggregate_id, session.id))
.orderBy(asc(EventTable.seq))
.all(),
)
const result = await Workspace.sessionRestore({
workspaceID: space.id,
sessionID: session.id,
})
return { space, session, rows, result }
},
})
expect(setup.rows).toHaveLength(13)
expect(setup.result).toEqual({ total: 2 })
expect(posts).toHaveLength(2)
expect(posts[0]?.path).toBe("/base/sync/replay")
expect(posts[1]?.path).toBe("/base/sync/replay")
expect(posts[0]?.body.directory).toBe(dir)
expect(posts[1]?.body.directory).toBe(dir)
expect(posts[0]?.body.events).toHaveLength(10)
expect(posts[1]?.body.events).toHaveLength(4)
expect(posts.flatMap((item) => item.body.events.map((event) => event.seq))).toEqual([
...setup.rows.map((row) => row.seq),
setup.rows.at(-1)!.seq + 1,
])
expect(posts[1]?.body.events.at(-1)).toMatchObject({
aggregateID: setup.session.id,
seq: setup.rows.at(-1)!.seq + 1,
type: SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version),
data: {
sessionID: setup.session.id,
info: {
workspaceID: setup.space.id,
},
},
})
const restore = seen.filter(
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
)
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
expect(restore.map((evt) => evt.payload.properties.total)).toEqual([2, 2, 2])
expect(restore.map((evt) => evt.payload.properties.sessionID)).toEqual([
setup.session.id,
setup.session.id,
setup.session.id,
])
} finally {
GlobalBus.off("event", on)
}
})
test("replays locally without posting to a server", async () => {
await using tmp = await tmpdir({ git: true })
const dir = path.join(tmp.path, ".restore-local")
const seen: any[] = []
const on = (evt: any) => seen.push(evt)
GlobalBus.on("event", on)
const fetch = spyOn(globalThis, "fetch")
const replayAll = spyOn(SyncEvent, "replayAll")
try {
const setup = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "local-restore", local(dir))
const space = await Workspace.create({
type: "local-restore",
branch: null,
extra: null,
projectID: Instance.project.id,
})
const session = await create({})
for (let i = 0; i < 6; i++) {
await user(session.id, `msg ${i}`)
}
const result = await Workspace.sessionRestore({
workspaceID: space.id,
sessionID: session.id,
})
const updated = await get(session.id)
return { space, session, result, updated }
},
})
expect(setup.result).toEqual({ total: 2 })
expect(fetch).not.toHaveBeenCalled()
expect(replayAll).toHaveBeenCalledTimes(2)
expect(setup.updated.workspaceID).toBe(setup.space.id)
const restore = seen.filter(
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
)
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
} finally {
GlobalBus.off("event", on)
}
})
})