Compare commits

...
Author SHA1 Message Date
Ryan Vogel 9b659ca45c test: trim redundant webhook assertions 2026-09-01 19:37:01 -04:00
Ryan Vogel 02c5f32593 feat(core): add async session webhooks 2026-09-01 19:24:54 -04:00
Filip c806503694 fix generated docs (#46678) 2026-09-01 22:37:37 +02:00
Filip fd73a85de4 remove azure discovery (#46672) 2026-09-01 22:13:08 +02:00
opencode-agent[bot]andAiden e2e82f18e2 docs: clarify branch targeting guidance (#46645)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-09-01 13:21:13 -05:00
opencode-agent[bot]andjlongster 91cdb182f0 test(tui): remove flaky jump-to-latest test (#46640)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-01 13:54:56 -04:00
James Long 316f7925d3 refactor(client): centralize service handoff in stop (#46637) 2026-09-01 13:49:45 -04:00
Shoubhit Dash 9f01e2b548 fix(plugin): make load failures easier to diagnose (#46594) 2026-09-01 22:29:35 +05:30
Shoubhit Dash 7856515140 feat(tui): add shareable stats poster (#46563) 2026-09-01 22:21:52 +05:30
Dax Raad 818804e181 chore: refresh bun lockfile 2026-09-01 12:19:40 -04:00
Aiden Cline ce6247bd2f fix(core): estimate context growth before compaction (#46543) 2026-09-01 10:48:34 -05:00
opencode-agent[bot]andrekram1-node 0465328297 test(tui): use real session id in home fixture (#46618)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-01 10:39:04 -05:00
Dax Raad 43d09b9d75 fix(server): await plugin activation when checking updates 2026-09-01 11:16:02 -04:00
Dax Raad 7730cec123 fix(tui): limit cached non-tab session families 2026-09-01 11:14:01 -04:00
opencode-agent[bot]andJames Long 367ee47d7e fix(tui): tone down patch failure details (#46470)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-09-01 11:07:03 -04:00
David Hillandjlongster c596a8f11d fix(tui): simplify narrow interrupt footer (#46533)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-01 11:06:25 -04:00
Kit Langton 30f998c5b9 feat(tui): make session preview tabs the default (#46497) 2026-09-01 11:02:11 -04:00
Kit Langton d4b4dd17cc refactor(core): rename plugin flush to awaitActivation 2026-09-01 10:48:08 -04:00
Dax Raad f330f3e02b feat(cli): check and update plugins with flat inventory 2026-09-01 10:46:47 -04:00
opencode-agent[bot]andnexxeln a9c27209bb fix(tui): open rename dialog from tab menu (#46603)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-01 19:58:36 +05:30
109 changed files with 3140 additions and 892 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
+1 -13
View File
@@ -3213,8 +3213,6 @@
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -5453,8 +5451,6 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@@ -5669,8 +5665,6 @@
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -5819,9 +5813,7 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -5853,8 +5845,6 @@
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
@@ -6609,8 +6599,6 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
+18
View File
@@ -199,6 +199,24 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
},
}),
Spec.make("check", {
description: "Check package plugins for updates",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Configured package target"),
Argument.optional,
),
},
}),
Spec.make("update", {
description: "Update package plugins",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
Argument.optional,
),
},
}),
Spec.make("remove", {
description: "Remove a plugin from global configuration",
params: {
@@ -0,0 +1,14 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { format, inspect } from "./inventory"
export default Runtime.handler(
Commands.commands.plugin.commands.check,
Effect.fn("cli.plugin.check")(function* (input) {
const result = yield* inspect(Option.getOrUndefined(input.target))
process.stdout.write((format(result.items) || "No package plugins found") + EOL)
if (result.items.some((item) => item.error)) process.exitCode = 1
}),
)
@@ -0,0 +1,101 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit } from "effect"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Npm } from "@opencode-ai/util/npm"
import { Config } from "../../../config"
import { ServiceConfig } from "../../../services/service-config"
export interface Item {
readonly runtime: "Server" | "TUI"
readonly target: string
readonly name: string
readonly version?: string
readonly outdated: boolean
readonly error?: string
}
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const location = { directory: process.cwd() }
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
const serverTargets = new Set(
listed.data.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
)
const server =
selected === undefined || serverTargets.has(selected)
? yield* Effect.promise(() => client.plugin.check({ location, ...(selected ? { target: selected } : {}) }))
: listed
const serverItems = server.data.flatMap((plugin): Item[] => {
if (plugin.source.type !== "package") return []
if (selected !== undefined && plugin.source.target !== selected) return []
return [
{
runtime: "Server",
target: plugin.source.target,
name: plugin.id ?? plugin.source.target,
...(plugin.source.version ? { version: displayVersion(plugin.source.version) } : {}),
outdated: plugin.source.outdated === true,
...(plugin.state.status === "failed" ? { error: plugin.state.error } : {}),
},
]
})
const config = yield* Config.Service
const info = yield* config.get()
const configured = [
...new Set(
(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) return []
return [target]
}),
),
]
const tuiTargets = yield* Effect.promise(async () => {
const installable = await Promise.all(configured.map((target) => Npm.isInstallablePackage(target)))
return configured.filter((target, index) => installable[index] && (selected === undefined || target === selected))
})
const npm = yield* Npm.Service
const tuiItems = yield* Effect.forEach(
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
target,
name: target,
...(installed.version ? { version: displayVersion(installed.version) } : {}),
outdated: Exit.isSuccess(outdated) && outdated.value,
...(Exit.isFailure(outdated) ? { error: Cause.pretty(outdated.cause) } : {}),
}
}),
{ concurrency: "unbounded" },
)
const items = [...serverItems, ...tuiItems]
if (selected !== undefined && !items.length) return yield* Effect.fail(new Error(`Plugin is not configured: ${selected}`))
return { client, location, items }
})
export function displayVersion(version: string) {
return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(version) ? version.slice(0, 7) : version
}
export function format(items: readonly Item[]) {
return ["Server", "TUI"]
.flatMap((runtime) => {
const rows = items.filter((item) => item.runtime === runtime)
if (!rows.length) return []
return [
runtime,
...rows.map(
(item) =>
` ${item.name}${item.version ? ` ${item.version}` : ""} (${item.error ? "check failed" : item.outdated ? "update available" : "current"})`,
),
]
})
.join(EOL)
}
@@ -8,7 +8,9 @@ import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
import { Npm } from "@opencode-ai/util/npm"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -22,15 +24,23 @@ export default Runtime.handler(
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
return [{ target, version: installed.version }]
}),
)
const output = format(
response.data,
[
...(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
}),
...discovered.map((target) => ({ target, source: "discovered" as const })),
],
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
if (!output) {
@@ -43,37 +53,50 @@ export default Runtime.handler(
export function format(
plugins: readonly PluginInfo[],
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
tui: ReadonlyArray<{ readonly target: string; readonly version?: string }>,
builtin = false,
) {
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.target, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.map((plugin) => ({
id: plugin.id ?? "-",
version:
plugin.source.type === "package"
? (plugin.source.version ?? "-")
: plugin.source.type === "local"
? "local"
: "-",
target:
plugin.source.type === "package"
? plugin.source.target
: plugin.source.type === "local"
? plugin.source.path
: plugin.source.type,
}))
const targets = tui
.filter(
(item) =>
!plugins.some((plugin) =>
plugin.source.type === "package"
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
.toSorted((a, b) => a.target.localeCompare(b.target))
.map((plugin) => `${plugin.target} (${plugin.source})`)
return [
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
server.length ? ["Server", ...server].join(EOL) : undefined,
]
.filter((section) => section !== undefined)
.join(EOL + EOL)
}
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
.map((plugin) => ({ id: "-", version: plugin.version ?? "-", target: plugin.target }))
const rows = [...server, ...targets]
.toSorted((a, b) => a.id.localeCompare(b.id) || a.target.localeCompare(b.target))
.map((item) => [
item.id,
/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(item.version) ? item.version.slice(0, 7) : item.version,
item.target,
])
if (!rows.length) return ""
const table = [["ID", "VERSION", "SOURCE"], ...rows]
const widths = [0, 1].map((index) => Math.max(...table.map((row) => row[index].length)))
return table.map((row) => `${row[0].padEnd(widths[0])} ${row[1].padEnd(widths[1])} ${row[2]}`).join(EOL)
}
@@ -0,0 +1,44 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit, Option } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { inspect } from "./inventory"
export default Runtime.handler(
Commands.commands.plugin.commands.update,
Effect.fn("cli.plugin.update")(function* (input) {
const result = yield* inspect(Option.getOrUndefined(input.target))
for (const item of result.items) {
if (!item.error) continue
process.stderr.write(`Failed to check ${item.runtime} plugin "${item.name}": ${item.error}${EOL}`)
}
const selected = result.items.filter((item) => item.outdated)
if (!selected.length) {
process.stdout.write("No plugin updates available" + EOL)
if (result.items.some((item) => item.error)) process.exitCode = 1
return
}
const npm = yield* Npm.Service
const updated = yield* Effect.forEach(
selected,
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
{ concurrency: "unbounded" },
)
for (const item of updated) {
if (Exit.isSuccess(item.result)) {
process.stdout.write(`Updated ${item.item.runtime} plugin "${item.item.name}"${EOL}`)
continue
}
process.stderr.write(
`Failed to update ${item.item.runtime} plugin "${item.item.name}": ${Cause.pretty(item.result.cause)}${EOL}`,
)
process.exitCode = 1
}
}),
)
+19 -84
View File
@@ -1,5 +1,7 @@
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { TokenUsage } from "@opencode-ai/schema/token-usage"
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../commands"
@@ -91,7 +93,7 @@ type RenderOptions = {
const colors = terminalPalette()
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
const totalTokens = tokenTotal(stats.tokens)
const totalTokens = TokenUsage.total(stats.tokens)
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
@@ -167,43 +169,23 @@ function renderActivity(
color: boolean,
width: number,
) {
const values = new Map(activity.map((day) => [day.date, day.steps]))
const rangeStart = dateOrdinal(new Date(from))
const rangeEnd = dateOrdinal(new Date(to - 1))
const end = new Date(to - 1)
end.setHours(12, 0, 0, 0)
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
const start = new Date(from)
start.setHours(12, 0, 0, 0)
start.setDate(start.getDate() - mondayIndex(start))
const maxWeeks = Math.max(1, Math.min(53, width - 4))
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
const latest = new Date(end)
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
if (start < latest) start.setTime(latest.getTime())
const active = [...values.values()].filter((value) => value > 0)
const levels = [...new Set(active)].sort((a, b) => a - b)
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
const date = new Date(start)
date.setDate(date.getDate() + week * 7)
return date
})
const weeks = weekStarts.map((week) =>
Array.from({ length: 7 }, (_, day) => {
const date = new Date(week)
date.setDate(date.getDate() + day)
const ordinal = dateOrdinal(date)
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
}),
)
const calendar = activityCalendar({ activity, from, to, maxWeeks: width - 4 })
const months = calendar.months
.map((month, index) =>
(index === calendar.months.length - 1 || month.label.length <= month.span ? month.label : "").padEnd(month.span),
)
.join("")
.trimEnd()
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
return [
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
` ${style(monthLabels(weekStarts), "2", color)}`,
style(
calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity",
`1;${colors.primary}`,
color,
),
` ${style(months, "2", color)}`,
...weekdays.flatMap((label, day) => [
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
`${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`,
...(day === weekdays.length - 1 ? [] : [""]),
]),
"",
@@ -238,7 +220,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
width,
),
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
]),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
@@ -248,7 +230,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
...models.map((item) =>
tableRow(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
formatNumber(tokenTotal(item.tokens)),
formatNumber(TokenUsage.total(item.tokens)),
formatNumber(item.steps),
`$${item.cost.toFixed(2)}`,
),
@@ -310,10 +292,6 @@ function truncate(value: string, width: number) {
return value.length <= width ? value : value.slice(0, width - 1) + "…"
}
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function formatNumber(value: number) {
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
@@ -342,13 +320,6 @@ function style(value: string, code: string, color: boolean) {
return color ? `\x1b[${code}m${value}\x1b[0m` : value
}
function activityGlyph(value: number, levels: number[], color: boolean) {
if (value === 0) return paintActivity(0, color)
const index = levels.indexOf(value)
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
return paintActivity(level, color)
}
function paintActivity(level: number, color: boolean) {
const glyph = ["·", "░", "▒", "▓", "█"][level]
if (!color) return glyph
@@ -370,39 +341,3 @@ function terminalPalette() {
}
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
}
function monthLabels(weeks: Date[]) {
const line: string[] = []
weeks.reduce((previous, week, index) => {
const middle = new Date(week)
middle.setDate(middle.getDate() + 3)
const month = middle.getMonth()
if (month === previous) return previous
Intl.DateTimeFormat("en-US", { month: "short" })
.format(middle)
.split("")
.forEach((character, offset) => {
line[index + offset] = character
})
return month
}, -1)
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
.join("")
.trimEnd()
}
function mondayIndex(date: Date) {
return (date.getDay() + 6) % 7
}
function dateKey(date: Date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-")
}
function dateOrdinal(date: Date) {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
}
+2
View File
@@ -42,6 +42,8 @@ const Handlers = Runtime.handlers(Commands, {
plugin: {
list: () => import("./commands/handlers/plugin/list"),
add: () => import("./commands/handlers/plugin/add"),
check: () => import("./commands/handlers/plugin/check"),
update: () => import("./commands/handlers/plugin/update"),
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
+18
View File
@@ -0,0 +1,18 @@
import { EOL } from "node:os"
import { expect, test } from "bun:test"
import { displayVersion, format, type Item } from "../src/commands/handlers/plugin/inventory"
test("formats server and TUI package update status", () => {
const items: Item[] = [
{ runtime: "Server", target: "server", name: "server.plugin", version: "1.2.3", outdated: true },
{ runtime: "TUI", target: "tui", name: "tui", version: "2.0.0", outdated: false },
]
expect(format(items)).toBe(
["Server", " server.plugin 1.2.3 (update available)", "TUI", " tui 2.0.0 (current)"].join(EOL),
)
})
test("shortens Git revisions", () => {
expect(displayVersion("dadba138b7088d61f937869bbc1ef34b1f91188d")).toBe("dadba13")
})
+42 -21
View File
@@ -2,14 +2,14 @@ import { expect, test } from "bun:test"
import { EOL } from "node:os"
import { format } from "../src/commands/handlers/plugin/list"
test("formats server and TUI plugins in sections without builtins", () => {
test("lists plugin IDs, installed versions, and sources without runtime sections", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", target: "acme-plugin@1.0.0" },
source: { type: "package", target: "acme-plugin@1.0.0", version: "1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
@@ -26,24 +26,22 @@ test("formats server and TUI plugins in sections without builtins", () => {
},
],
[
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
{ target: "tui-only", version: "2.0.0" },
{ target: "/tmp/local.ts", version: "local" },
{ target: "acme-plugin@1.0.0", version: "1.0.0" },
{ target: "/tmp/local/tui.ts", version: "local" },
],
),
).toBe(
[
"TUI",
"/tmp/local (advertised)",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
"",
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
"local.dual (active)",
].join(EOL),
)
)
.split(EOL)
.map((line) => line.split(/\s{2,}/)),
).toEqual([
["ID", "VERSION", "SOURCE"],
["-", "local", "/tmp/local.ts"],
["-", "-", "broken-plugin"],
["-", "2.0.0", "tui-only"],
["acme.dual", "1.0.0", "acme-plugin@1.0.0"],
["local.dual", "local", "/tmp/local/index.ts"],
])
})
test("includes builtins when requested", () => {
@@ -59,6 +57,29 @@ test("includes builtins when requested", () => {
],
[],
true,
),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
)
.split(EOL)
.map((line) => line.split(/\s{2,}/)),
).toEqual([
["ID", "VERSION", "SOURCE"],
["opencode.agent", "-", "builtin"],
])
})
test("shortens Git commits and leaves absent IDs unknown", () => {
expect(
format(
[
{
source: { type: "package", target: "github:acme/plugin", version: "a".repeat(40) },
state: { status: "active" },
features: { server: true },
},
],
[],
)
.split(EOL)[1]
.split(/\s{2,}/),
).toEqual(["-", "aaaaaaa", "github:acme/plugin"])
expect(format([], [])).toBe("")
})
+14
View File
@@ -48,6 +48,20 @@ describe("stats rendering", () => {
expect(output).not.toContain("$12.34")
})
test("renders shared intensity levels and partial month labels", () => {
const output = renderStats(
{
...stats,
range: { from: new Date(2026, 3, 29).getTime(), to: new Date(2026, 4, 20).getTime() },
activity: [{ date: "2026-04-29", steps: 1 }],
},
options(),
)
expect(output).toContain(" May")
expect(output).not.toContain("Apr")
expect(output.split(/\r?\n/).find((line) => line.startsWith("We "))).toContain("\u2588")
})
test("renders only requested detail tables", () => {
const output = renderStats(stats, options({ tools: true, cost: true }))
expect(output).toContain("COST & TOKENS")
+9
View File
@@ -88,6 +88,13 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target?: string | undefined
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
@@ -97,6 +104,7 @@ export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Eff
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
}
@@ -258,6 +266,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
readonly callbackUrl?: string | undefined
}
export type SessionPromptOutput = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => Effect.Effect<SessionPromptOutput, E>
@@ -15,6 +15,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
@@ -324,6 +326,13 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
@@ -333,6 +342,7 @@ const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: Plugin
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
})
@@ -475,6 +485,7 @@ const EndpointSessionPrompt = (raw: RawClient["server.session"]) => (input: Sess
metadata: input["metadata"],
delivery: input["delivery"],
resume: input["resume"],
callbackUrl: input["callbackUrl"],
},
}).pipe(
Effect.mapError(mapClientError),
+11 -11
View File
@@ -103,16 +103,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
if (!service.legacy && service.state === "ready")
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
)
else {
if (!service.legacy)
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
}
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
if (!service.legacy && service.state !== "ready")
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* stop({
file: options.file,
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -145,8 +141,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
const info = yield* read(options.file)
if (options.pty === "handoff" && info !== undefined)
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout),
)
else yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
@@ -9,6 +9,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
@@ -468,6 +470,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input?.["location"] },
body: { target: input?.["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
@@ -673,6 +688,7 @@ export function make(options: ClientOptions) {
metadata: input["metadata"],
delivery: input["delivery"],
resume: input["resume"],
callbackUrl: input["callbackUrl"],
},
successStatus: 200,
declaredStatuses: [409, 400, 404, 401],
+43 -1
View File
@@ -18,7 +18,7 @@ export type PluginSource =
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
export type PluginState = { status: "active" } | { status: "failed"; error: string }
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -2585,6 +2585,18 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target?: { readonly target?: string | undefined }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -3719,6 +3731,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["id"]
readonly text: {
readonly id?: string | null
@@ -3740,6 +3753,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["text"]
readonly files?: {
readonly id?: string | null
@@ -3761,6 +3775,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["files"]
readonly agents?: {
readonly id?: string | null
@@ -3782,6 +3797,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["agents"]
readonly skills?: {
readonly id?: string | null
@@ -3803,6 +3819,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["skills"]
readonly metadata?: {
readonly id?: string | null
@@ -3824,6 +3841,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["metadata"]
readonly delivery?: {
readonly id?: string | null
@@ -3845,6 +3863,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["delivery"]
readonly resume?: {
readonly id?: string | null
@@ -3866,7 +3885,30 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["resume"]
readonly callbackUrl?: {
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["callbackUrl"]
}
export type SessionPromptOutput = { data: SessionInboxUser }["data"]
+9 -9
View File
@@ -84,14 +84,12 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
if (!service.legacy && service.state === "ready")
await PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
else {
if (!service.legacy)
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
}
await terminate(service.info, options, timing).catch(() => undefined)
if (!service.legacy && service.state !== "ready")
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await stop({
file: options.file,
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
lastSpawn = 0
}
} else {
@@ -119,8 +117,10 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
await PtyHandoff.clear(options.file ?? fallback())
const info = await read(options.file)
if (options.pty === "handoff" && info !== undefined)
await PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
else await PtyHandoff.clear(options.file ?? fallback())
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
+2
View File
@@ -38,6 +38,8 @@ export type EnsureOptions = DiscoverOptions & {
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** How to handle persistent terminals before stopping the service. */
readonly pty?: "clear" | "handoff"
}
/** Contents of the local service registration file. */
+53 -15
View File
@@ -147,7 +147,7 @@ function createSync() {
state.set(key, entry)
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
.then(() => {
if (state.get(key) === entry) state.set(key, true)
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
})
.finally(() => {
if (state.get(key) === entry) state.delete(key)
@@ -493,6 +493,33 @@ export function createData(config: CreateDataInput) {
)
}
function evictSession(sessionID: string) {
if (sessionOutbox.has(sessionID)) return
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
messageLoads.delete(sessionID)
// Keep unacknowledged submissions until their echo or rollback settles them.
const pending = store.session.pending[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
const messages = store.session.message[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
messageIndex.delete(sessionID)
if (messages.length) messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
setStore(
"session",
produce((draft) => {
delete draft.message[sessionID]
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
if (messages.length) draft.message[sessionID] = messages
if (pending.length) {
draft.pending[sessionID] = pending
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
}
}),
)
}
function removeSession(sessionID: string) {
activeUpdates?.set(sessionID, undefined)
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
@@ -1260,9 +1287,11 @@ export function createData(config: CreateDataInput) {
return creating.has(sessionID)
},
remember(info: SessionInfo) {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
batch(() => {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
})
},
setStatus(sessionID: string, status: DataSessionStatus) {
setSessionActive(sessionID, status)
@@ -1273,6 +1302,13 @@ export function createData(config: CreateDataInput) {
family(sessionID: string) {
return store.session.family[resolveRoot(sessionID)] ?? []
},
/** Clear heavy cached data for the root and all known descendants. */
evict(sessionID: string) {
const root = resolveRoot(sessionID)
batch(() => {
for (const id of new Set([root, sessionID, ...(store.session.family[root] ?? [])])) evictSession(id)
})
},
cost(sessionID: string) {
const session = store.session.info[sessionID]
if (!session) return 0
@@ -1484,17 +1520,19 @@ export function createData(config: CreateDataInput) {
: [],
])
const sessions = [info, ...children]
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
batch(() => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
})
},
invalidate(sessionID: string) {
+2
View File
@@ -804,6 +804,7 @@ test("session methods use the public HTTP contract", async () => {
sessionID: "ses_test",
text: "Hello",
resume: false,
callbackUrl: "https://notify.example.test/opencode",
})
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Summarize this session" })
const synthetic = await client.session.synthetic({
@@ -856,6 +857,7 @@ test("session methods use the public HTTP contract", async () => {
expect(JSON.parse(body)).toEqual({
text: "Hello",
resume: false,
callbackUrl: "https://notify.example.test/opencode",
})
const syntheticBody = requests.find((request) => request.url.endsWith("/synthetic"))?.init?.body
if (typeof syntheticBody !== "string") throw new Error("Expected JSON synthetic request body")
+177
View File
@@ -0,0 +1,177 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("evicts a whole family while preserving metadata and attention", async () => {
const setup = fixture()
try {
for (const id of ["ses_parent", "ses_child", "ses_grandchild", "ses_other"]) {
await setup.data.session.message.sync(id)
await setup.data.session.pending.sync(id)
}
setup.data.session.setStatus("ses_child", "running")
setup.emit({
id: "evt_permission",
created: 1,
type: "permission.asked",
data: { id: "per_test", sessionID: "ses_child", action: "bash", resources: ["bun test"] },
})
setup.data.session.evict("ses_parent")
for (const id of ["ses_parent", "ses_child", "ses_grandchild"]) {
expect(setup.data.session.message.list(id)).toEqual([])
expect(setup.data.session.message.get(id, "msg_page")).toBeUndefined()
expect(setup.data.session.message.more(id)).toBe(false)
expect(setup.data.session.message.loading(id)).toBe(false)
expect(setup.data.session.pending.list(id)).toEqual([])
expect(setup.data.session.input.list(id)).toEqual([])
expect(setup.data.session.get(id)?.id).toBe(id)
}
expect(setup.data.session.family("ses_parent")).toEqual(["ses_parent", "ses_child", "ses_grandchild"])
expect(setup.data.session.status("ses_child")).toBe("running")
expect(setup.data.session.permission.list("ses_child")?.[0]?.id).toBe("per_test")
expect(setup.data.session.message.list("ses_other")).toHaveLength(2)
setup.emit({
id: "evt_agent",
created: 1,
type: "session.agent.selected",
durable: { aggregateID: "ses_parent", seq: 1, version: 1 },
data: { sessionID: "ses_parent", agent: "build" },
})
expect(setup.data.session.message.list("ses_parent")).toMatchObject([{ type: "agent-switched" }])
await setup.data.session.message.sync("ses_parent")
expect(setup.data.session.message.list("ses_parent").map((item) => item.id)).toEqual(["msg_page"])
await setup.data.session.message.sync("ses_grandchild")
await setup.data.session.pending.sync("ses_grandchild")
expect(setup.data.session.message.list("ses_grandchild")).toHaveLength(2)
expect(setup.data.session.message.list("ses_child")).toEqual([])
} finally {
setup.dispose()
}
})
test("evicting an in-flight read leaves the next sync invalidated", async () => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let requests = 0
const setup = fixture(async () => {
if (++requests !== 1) return
requested.resolve()
await release.promise
})
try {
const initial = setup.data.session.message.sync("ses_child")
await requested.promise
setup.data.session.evict("ses_parent")
release.resolve()
await initial
await setup.data.session.message.sync("ses_child")
expect(requests).toBe(2)
expect(setup.data.session.message.list("ses_child")).toHaveLength(1)
} finally {
release.resolve()
setup.dispose()
}
})
test.each(["echo", "reject"] as const)("eviction preserves optimistic submissions until %s", async (mode) => {
const release = Promise.withResolvers<void>()
const setup = fixture(async (url) => {
if (!url.pathname.endsWith("/prompt")) return undefined
await release.promise
return mode === "reject"
? Response.json({ message: "rejected" }, { status: 400 })
: Response.json({ id: "msg_local" })
})
try {
const request = setup.data.session.prompt({ sessionID: "ses_child", id: "msg_local", text: "local" })
const settled = Promise.allSettled([request])
setup.data.session.evict("ses_parent")
expect(setup.data.session.message.list("ses_child")).toMatchObject([{ id: "msg_local", text: "local" }])
if (mode === "echo") setup.emit(enqueued("ses_child", "msg_local"))
release.resolve()
await settled
expect(setup.data.session.message.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
expect(setup.data.session.pending.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
} finally {
release.resolve()
setup.dispose()
}
})
function info(id: string, parentID?: string): SessionInfo {
return {
id,
parentID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
location: { directory: "/project" },
}
}
function enqueued(sessionID: string, inboxID = "msg_pending"): OpenCodeEvent {
return {
id: `evt_${inboxID}`,
created: 1,
type: "session.inbox.enqueued",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, inboxID, item: { type: "user", delivery: "steer", payload: { text: "pending" } } },
}
}
function fixture(read?: (url: URL) => Promise<Response | void>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input) => {
const url = new URL(input instanceof Request ? input.url : String(input))
const response = await read?.(url)
if (response) return response
const sessionID = url.pathname.split("/")[3]
if (url.pathname.endsWith("/inbox"))
return Response.json({
data: [
{
id: "msg_pending",
sessionID,
type: "user",
delivery: "steer",
payload: { text: "pending" },
timeCreated: 1,
},
],
})
return Response.json({
data: [{ id: "msg_page", type: "user", text: "page", time: { created: 0 } }],
cursor: url.searchParams.has("cursor") ? {} : { next: "older" },
})
},
})
return createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
})
data.session.remember(info("ses_parent"))
data.session.remember(info("ses_child", "ses_parent"))
data.session.remember(info("ses_grandchild", "ses_child"))
data.session.remember(info("ses_other"))
return {
data,
dispose,
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
}
})
}
@@ -93,7 +93,7 @@ const layer = Layer.effect(
)
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* plugins.flush
yield* plugins.awaitActivation
yield* reconcile(policy.current())
}).pipe(
Effect.catchCauseIf(
+19 -6
View File
@@ -26,6 +26,11 @@ const Definition = Schema.Struct({
]),
})
export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.LoadError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
@@ -39,13 +44,21 @@ export const load = Effect.fn("PluginModule.load")(function* (
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod).pipe(
Effect.mapError(
(cause) =>
new LoadError({
message: "Plugin must export a default definition with an id and an effect or setup function.",
cause,
}),
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
@@ -77,11 +90,11 @@ function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name))
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
)
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) =>
names.has(`${name}.${extension}`),
)
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
+3 -118
View File
@@ -1,6 +1,3 @@
import { readFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode-ai/plugin/effect/plugin"
@@ -20,9 +17,6 @@ const cognitiveScope = "https://cognitiveservices.azure.com/.default"
const foundryScope = "https://ai.azure.com/.default"
const methodID = Integration.MethodID.make("azure-cli")
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
const decodeProfile = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.Array(Schema.Unknown) })),
)
const decodeToken = Schema.decodeUnknownEffect(
Schema.Struct({
accessToken: Schema.NonEmptyString,
@@ -30,20 +24,6 @@ const decodeToken = Schema.decodeUnknownEffect(
expiresOn: Schema.optional(Schema.NonEmptyString),
}),
)
const decodeAccounts = Schema.decodeUnknownEffect(
Schema.Array(Schema.Struct({ name: Schema.NonEmptyString, resourceGroup: Schema.NonEmptyString })),
)
const Deployments = Schema.Array(
Schema.Struct({
name: Schema.NonEmptyString,
properties: Schema.Struct({
model: Schema.Struct({ name: Schema.NonEmptyString }),
provisioningState: Schema.NonEmptyString,
}),
}),
)
const decodeDeployments = Schema.decodeUnknownEffect(Deployments)
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
if (useChat && sdk.chat) return sdk.chat(modelID)
if (sdk.responses) return sdk.responses(modelID)
@@ -60,7 +40,7 @@ export const AzurePlugin = define({
const bus = yield* Bus.Service
const tokens = new Map<string, { access: string; expires: number }>()
const loading = Semaphore.makeUnsafe(1)
const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {}
const loaded: { resource?: string } = {}
const command = (args: string[]) =>
processes
@@ -86,28 +66,7 @@ export const AzurePlugin = define({
})
const available = Boolean(which("az"))
// Installing Azure CLI does not mean the user has signed in. Avoid spawning it for unrelated CLI commands.
const signedIn = available
? yield* Effect.tryPromise(() =>
readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8"),
).pipe(
Effect.flatMap((text) => decodeProfile(text.replace(/^\uFEFF/, ""))),
Effect.map((profile) => profile.subscriptions.length > 0),
Effect.orElseSucceed(() => false),
)
: false
const accounts =
!resolveResourceName(configured) &&
typeof configured?.baseURL !== "string" &&
!process.env.AZURE_RESOURCE_GROUP &&
signedIn
? yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
Effect.flatMap(decodeAccounts),
Effect.orElseSucceed(() => []),
)
: []
const form = (select = false) =>
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
@@ -117,16 +76,6 @@ export const AzurePlugin = define({
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
...(select && accounts.length > 0
? {
options: accounts.map((account) => ({
value: account.name,
label: account.name,
description: account.resourceGroup,
})),
custom: true,
}
: {}),
},
])
})
@@ -143,7 +92,7 @@ export const AzurePlugin = define({
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(true),
form: form(),
},
authorize: (answer) =>
Effect.succeed({
@@ -156,7 +105,6 @@ export const AzurePlugin = define({
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
loaded.resource = resourceName
loaded.deployments = yield* discover(resourceName)
yield* ctx.catalog.reload()
return Credential.OAuth.make({
type: "oauth",
@@ -177,42 +125,6 @@ export const AzurePlugin = define({
})
})
const discover = Effect.fn("AzurePlugin.discover")(function* (resource: string) {
return yield* Effect.gen(function* () {
const group = process.env.AZURE_RESOURCE_GROUP
const account = group
? { name: resource, resourceGroup: group }
: (accounts.length > 0
? accounts
: yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
Effect.flatMap(decodeAccounts),
)
).find((item) => item.name.toLowerCase() === resource.toLowerCase())
if (!account)
return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found in the active subscription`))
return yield* command([
"cognitiveservices",
"account",
"deployment",
"list",
"--name",
account.name,
"--resource-group",
account.resourceGroup,
"--output",
"json",
"--only-show-errors",
]).pipe(Effect.flatMap(decodeDeployments))
}).pipe(
Effect.catch((error) =>
Effect.logWarning("Azure model discovery failed", {
resource,
error: error instanceof Error ? error.message : String(error),
}).pipe(Effect.as(undefined)),
),
)
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
@@ -220,13 +132,11 @@ export const AzurePlugin = define({
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
loaded.resource = undefined
loaded.deployments = undefined
return
}
const resource =
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
loaded.resource = resource
loaded.deployments = resource ? yield* discover(resource) : undefined
})
yield* load()
@@ -245,31 +155,6 @@ export const AzurePlugin = define({
: {}),
}
})
if (item.provider.id === Provider.ID.azure && loaded.deployments) {
// Startup batches catalog transforms, so match against the current draft rather than a pre-startup snapshot.
const existing = Array.from(item.models.values())
const found = new Map<Model.ID, Model.Info>()
loaded.deployments.forEach((deployment) => {
if (deployment.properties.provisioningState !== "Succeeded") return
const model = existing.find(
(model) => model.id.toLowerCase() === deployment.properties.model.name.toLowerCase(),
)
if (!model) return
const id = found.has(model.id) ? Model.ID.make(deployment.name) : model.id
found.set(id, {
...model,
id,
name: id === model.id ? model.name : `${model.name} (${deployment.name})`,
modelID: Model.ID.make(deployment.name),
})
})
for (const id of Array.from(item.models.keys())) {
if (!found.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of found) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
}
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
@@ -8,10 +8,12 @@ import { Context, Effect } from "effect"
*/
export interface Interface {
/**
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
* UI startup, or other unrelated work on plugin boot should be avoided.
* Wait for configured plugin activation to settle, including missing-package installs.
* Completion does not imply every plugin succeeded.
* Interrupting this wait does not cancel activation. Use rarely: avoid blocking reads,
* UI startup, or unrelated work on plugin boot.
*/
readonly flush: Effect.Effect<void>
readonly awaitActivation: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+15 -13
View File
@@ -55,11 +55,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
}
const plugin = yield* PluginModule.load(operation, { install }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
Effect.catchCause((cause) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
const error = Cause.squash(cause)
return Effect.logWarning("failed to load plugin", { target: operation.target, ref, cause }).pipe(
Effect.as({ error: error instanceof PluginModule.LoadError ? error.message : "Plugin failed to load", ref }),
)
}),
)
if ("pending" in plugin) {
pending.add(operation.target)
@@ -68,7 +70,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
state: { status: "failed", error: plugin.error },
state: { status: "failed", error: plugin.error, ref: plugin.ref },
features: { server: true },
})
continue
@@ -127,9 +129,7 @@ export const layer = Layer.effect(
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const source = (source: Plugin.Source) =>
source.type === "package" && outdated.has(source.target)
? { ...source, outdated: true as const }
: source
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
const apply = (resolved: typeof immediate) =>
registry.activate(
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
@@ -162,12 +162,14 @@ export const layer = Layer.effect(
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
updates.changes().pipe(
Stream.filter((target) => packages.has(target)),
Stream.tap((target) => Effect.sync(() => outdated.delete(target))),
Stream.filter((update) => packages.has(update.target)),
Stream.tap((update) =>
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
),
Stream.map(() => undefined),
),
).pipe(
// Make accepted work visible to flush before coalescing the burst.
// Make accepted work visible to awaitActivation before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
@@ -189,7 +191,7 @@ export const layer = Layer.effect(
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
return Service.of({ flush: ready.await })
return Service.of({ awaitActivation: ready.await })
}),
)
+7 -6
View File
@@ -9,9 +9,9 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
const interval = 24 * 60 * 60 * 1_000
export interface Interface {
readonly check: (target: string) => Effect.Effect<boolean>
readonly check: (target: string, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
readonly changes: () => Stream.Stream<string>
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
@@ -22,28 +22,29 @@ const layer = Layer.effect(
const npm = yield* Npm.Service
const locks = KeyedMutex.makeUnsafe<string>()
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
const changes = yield* PubSub.unbounded<string>()
const changes = yield* PubSub.unbounded<{ readonly target: string; readonly outdated: boolean }>()
return Service.of({
check: (target) =>
check: (target, options) =>
locks.withLock(target)(
Effect.gen(function* () {
const checkedAt = yield* Clock.currentTimeMillis
const current = status.get(target)
if (current && checkedAt - current.checkedAt < interval) return current.outdated
if (!options?.refresh && current && checkedAt - current.checkedAt < interval) return current.outdated
const outdated = yield* npm.check(target).pipe(
Effect.tapCause((cause) => Effect.logWarning("failed to check plugin update", { target, cause })),
Effect.option,
)
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
status.set(target, { outdated: value, checkedAt })
if ((current?.outdated ?? false) !== value) yield* PubSub.publish(changes, { target, outdated: value })
return value
}),
),
update: (target) =>
npm.update(target).pipe(
Effect.tap(() => Effect.sync(() => status.delete(target))),
Effect.tap(() => PubSub.publish(changes, target)),
Effect.tap(() => PubSub.publish(changes, { target, outdated: false })),
Effect.asVoid,
),
changes: () => Stream.fromPubSub(changes),
+3 -1
View File
@@ -41,6 +41,7 @@ import {
SyntheticConflictError,
} from "./session/error.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionInbox } from "./session/inbox.js"
@@ -407,7 +408,7 @@ const layer = Layer.effect(
const session = yield* result.get(input.sessionID)
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
return yield* Command.Service
}).pipe(instances.provide(session))
const delivery = input.delivery ?? "steer"
@@ -525,6 +526,7 @@ export const node = makeGlobalNode({
service: Service,
layer,
deps: [
httpClient,
Job.node,
SessionEnvironment.node,
Database.node,
+73 -16
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -10,18 +10,21 @@ import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import type { SessionMessage } from "./message.js"
import type { SessionModelRequest } from "./model-request.js"
import { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { State } from "../state.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const IMAGE_TOKEN_ESTIMATE = 1_500
const PDF_TOKEN_ESTIMATE = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Objective
@@ -71,7 +74,9 @@ export type AutoInput = {
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
readonly context: SessionContext.Loaded
}
export type ManualInput = {
readonly session: SessionSchema.Info
@@ -107,6 +112,67 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const estimateTokens = (input: RequiredInput) => {
const index = input.messages.findLastIndex(
(message) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
)
const last = input.messages[index]
// Keep the anchor's local tool results: they are not covered by its provider usage.
const added = SessionModelRequest.unsupportedParts(
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref),
input.resolved.capabilities,
)
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
.reduce((sum, message) => sum + message.content.reduce((sum, part) => sum + estimatePart(part), 0), 0)
if (last?.type === "assistant" && last.tokens)
return (
added +
last.tokens.input +
last.tokens.cache.read +
last.tokens.cache.write +
last.tokens.output +
last.tokens.reasoning
)
const transcript = SessionModelRequest.baseTranscript({
agent: input.context.agent.info,
model: input.resolved,
tools: input.context.tools,
initial: input.context.initial,
messages: [],
})
return (
added +
transcript.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
input.context.tools.definitions.reduce(
(sum, tool) => sum + Token.estimate(tool.name + tool.description + JSON.stringify(tool.inputSchema)),
0,
)
)
}
const estimateMedia = (mime: string) => {
const type = mime.toLowerCase()
return type.startsWith("image/") ? IMAGE_TOKEN_ESTIMATE : type === "application/pdf" ? PDF_TOKEN_ESTIMATE : 0
}
const estimatePart = (part: ContentPart): number => {
if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text)
if (part.type === "media") return estimateMedia(part.mediaType)
if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? ""))
if (part.result.type === "content")
return part.result.value.reduce(
(sum, content) => sum + (content.type === "text" ? Token.estimate(content.text) : estimateMedia(content.mime)),
0,
)
return Token.estimate(
typeof part.result.value === "string" ? part.result.value : (JSON.stringify(part.result.value) ?? ""),
)
}
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
let end = 0
@@ -364,27 +430,18 @@ export const layer = Layer.effect(
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
// Run the completed checkpoint before considering another automatic compaction.
const last = input.messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
const last = input.messages.findLast(
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const used =
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
+1 -1
View File
@@ -125,7 +125,7 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
yield* plugins.flush
yield* plugins.awaitActivation
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
+1 -1
View File
@@ -37,7 +37,7 @@ export const make = Effect.fn("SessionPrompt.make")(function* () {
messageID: SessionMessage.ID
input: Input
}) {
yield* plugins.flush
yield* plugins.awaitActivation
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID: request.messageID,
+2 -2
View File
@@ -38,7 +38,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
const snapshot = yield* Snapshot.Service
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
yield* plugins.flush
yield* plugins.awaitActivation
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
@@ -66,7 +66,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
})
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
yield* plugins.flush
yield* plugins.awaitActivation
if (!session.revert) return
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
+2 -2
View File
@@ -57,7 +57,7 @@ const layer = Layer.effect(
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* plugins.awaitActivation
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
@@ -185,7 +185,7 @@ const layer = Layer.effect(
resolved: loaded.model,
prepare: context.prepare,
}
if (compaction.required(compactionInput)) {
if (compaction.required({ ...compactionInput, context: loaded })) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
+5 -1
View File
@@ -32,10 +32,12 @@ import { SessionPrompt } from "./prompt.js"
import { SessionRevert } from "./revert.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { SessionWebhook } from "./webhook.js"
type PromptRequest = SessionPrompt.Input & {
id?: SessionMessage.ID
resume?: boolean
callbackUrl?: string
}
/**
@@ -49,6 +51,7 @@ export const make = Effect.fn("Session.make")(function* () {
const execution = yield* SessionExecution.Service
const admission = yield* SessionInbox.Service
const scope = yield* Scope.Scope
const webhook = yield* SessionWebhook.make()
const get = Effect.fn("Session.get")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
@@ -181,6 +184,7 @@ export const make = Effect.fn("Session.make")(function* () {
}).pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })),
)
if (input.callbackUrl) yield* webhook(sessionID, input.callbackUrl)
if (input.resume !== false) yield* execution.wake(sessionID)
return admitted
}),
@@ -196,7 +200,7 @@ export const make = Effect.fn("Session.make")(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
return yield* Shell.Service
}).pipe(instances.provide(session))
const started = yield* shell
+2 -5
View File
@@ -8,6 +8,7 @@ import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { ToolMode } from "@opencode-ai/schema/session-stats"
import { TokenUsage } from "@opencode-ai/schema/token-usage"
import { Database } from "../database/database.js"
import { EventTable } from "../event/sql.js"
import { SessionMessageTable, SessionTable } from "./sql.js"
@@ -341,7 +342,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
streak: longestStreak(days.map(([date]) => date)),
activity: days.map(([date, steps]) => ({ date, steps })),
models: [...models.values()]
.sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens))
.sort((a, b) => TokenUsage.total(b.tokens) - TokenUsage.total(a.tokens))
.map((model) => ({ ...model, cost: Money.USD.make(model.cost) })),
}
})
@@ -378,10 +379,6 @@ function addTokens(target: Tokens, source: Tokens) {
target.cache.write += source.cache.write
}
function tokenTotal(tokens: Tokens) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function addToolStatus(
target: { calls: number; succeeded: number; failed: number; unfinished: number },
status: string | null,
+6 -3
View File
@@ -41,6 +41,7 @@ export type ListInput = typeof ListInput.Type
export type MessagesInput = {
sessionID: Session.ID
type?: SessionMessage.Type
limit?: number
order?: "asc" | "desc"
cursor?: {
@@ -156,9 +157,11 @@ const layer = Layer.effect(
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const where = and(
eq(SessionMessageTable.session_id, input.sessionID),
input.type === undefined ? undefined : eq(SessionMessageTable.type, input.type),
boundary,
)
const query = db
.select()
.from(SessionMessageTable)
+77
View File
@@ -0,0 +1,77 @@
export * as SessionWebhook from "./webhook.js"
import { Effect, FiberMap, Schema, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Form } from "@opencode-ai/schema/form"
import { Permission } from "@opencode-ai/schema/permission"
import { Bus } from "../bus.js"
import { SessionEvent } from "./event.js"
import { SessionSchema } from "./schema.js"
import { SessionMessage } from "./message.js"
import { SessionStore } from "./store.js"
export const make = Effect.fn("SessionWebhook.make")(function* () {
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const http = HttpClient.withScope(HttpClient.filterStatusOk(yield* HttpClient.HttpClient))
// Capture the host context, not the submitting request's Location scope.
const run = yield* FiberMap.makeRuntime<never, SessionSchema.ID, never, void>()
return Effect.fn("SessionWebhook.subscribe")((sessionID: SessionSchema.ID, url: string) =>
Effect.sync(() => {
run(
sessionID,
bus
.subscribe([
SessionEvent.Execution.Started,
SessionEvent.Execution.Succeeded,
SessionEvent.Execution.Failed,
SessionEvent.Execution.Interrupted,
SessionEvent.Renamed,
SessionEvent.Deleted,
...Permission.Event.Definitions,
...Form.Event.Definitions,
])
.pipe(
Stream.filter(
(event) =>
(event.type === "form.created" ? event.data.form.sessionID : event.data.sessionID) === sessionID,
),
Stream.takeUntil(
(event) =>
event.type === "session.execution.succeeded" ||
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted" ||
event.type === "session.deleted",
),
Stream.runForEach((event) =>
Effect.gen(function* () {
const session = yield* store.get(sessionID)
const response = (yield* store.messages({ sessionID, type: "assistant", limit: 1 }))[0]
yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.bodyJson({
...event,
session: session ? Schema.encodeSync(SessionSchema.Info)(session) : null,
response: response ? Schema.encodeSync(SessionMessage.Info)(response) : null,
}),
Effect.flatMap(http.execute),
)
}).pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.scoped,
Effect.timeout("5 seconds"),
Effect.catch((error) =>
Effect.logWarning("Session webhook delivery failed", {
sessionID,
eventID: event.id,
eventType: event.type,
reason: error._tag,
}),
),
),
),
),
)
}),
)
})
+17 -6
View File
@@ -145,10 +145,8 @@ const session = Session.Info.make({
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number) => ({
session,
resolved,
messages: [
const input = (tokens: number) => {
const messages = [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_compaction_config"),
type: "assistant",
@@ -158,7 +156,20 @@ const input = (tokens: number) => ({
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
]
return {
session,
resolved,
messages,
context: {
session,
model: resolved,
messages,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
initial: "",
tools: { definitions: [], execute: () => Effect.die("unused") },
},
}
}
const bufferedInput = input(85_000)
const nearInput = input(95_000)
+34 -19
View File
@@ -20,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Cause, Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -217,12 +217,15 @@ describe("PluginSupervisor config", () => {
)
it.live("logs invalid packages and continues loading", () => {
const output: string[] = []
const output: Array<{ target: string; ref: string; diagnostic: string }> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
const details = entry.message[1]
if (typeof details !== "object" || details === null || !("target" in details)) return
if (typeof details.target === "string") output.push(details.target)
if (typeof details !== "object" || details === null) return
if (!("target" in details) || typeof details.target !== "string") return
if (!("ref" in details) || typeof details.ref !== "string") return
if (!("cause" in details) || !Cause.isCause(details.cause)) return
output.push({ target: details.target, ref: details.ref, diagnostic: Cause.pretty(details.cause) })
})
return withLocation(
{
@@ -230,6 +233,7 @@ describe("PluginSupervisor config", () => {
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid"),
path.join(import.meta.dir, "../plugin/fixtures/load-failure"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
options: { description: "Loaded after invalid plugins" },
@@ -243,16 +247,30 @@ describe("PluginSupervisor config", () => {
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
expect(output).toEqual([
expect(output.map((entry) => entry.target)).toEqual([
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"),
path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.state.status === "failed").map((plugin) => plugin.source),
).toEqual([
const failed = (yield* plugins.list()).filter((plugin) => plugin.state.status === "failed")
expect(failed.map((plugin) => plugin.source)).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts") },
])
expect(failed.map((plugin) => plugin.state)).toEqual([
{ status: "failed", error: "Plugin failed to load", ref: output[0]?.ref },
{
status: "failed",
error: "Plugin must export a default definition with an id and an effect or setup function.",
ref: output[1]?.ref,
},
{ status: "failed", error: "Plugin failed to load", ref: output[2]?.ref },
])
output.forEach((entry) => expect(entry.ref).toMatch(/^err_[0-9a-f]{8}$/))
expect(new Set(output.map((entry) => entry.ref)).size).toBe(3)
expect(output[2]?.diagnostic).toContain("private plugin loader details")
expect(JSON.stringify(failed)).not.toContain("private plugin loader details")
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -490,7 +508,7 @@ describe("PluginSupervisor config", () => {
),
)
it.live("unblocks flush when plugin activation fails", () =>
it.live("unblocks awaitActivation when plugin activation fails", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
@@ -530,19 +548,18 @@ describe("PluginSupervisor config", () => {
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
const supervisor = yield* PluginSupervisor.Service
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
expect(Option.isNone(yield* supervisor.awaitActivation.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
yield* supervisor.flush.pipe(Effect.timeout("2 seconds"))
yield* supervisor.awaitActivation.pipe(Effect.timeout("2 seconds"))
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
}),
),
)
})
const ready = Effect.fnUntraced(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
})
const waitForFile = (file: string) =>
@@ -610,12 +627,10 @@ function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
async function writeDiscoveredPackage(
directory: string,
name: string,
files: Record<string, string>,
) {
async function writeDiscoveredPackage(directory: string, name: string, files: Record<string, string>) {
const plugin = path.join(directory, ".opencode", "plugins", name)
await fs.mkdir(plugin, { recursive: true })
await Promise.all(Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))))
await Promise.all(
Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
)
}
@@ -31,7 +31,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
deps: [],
})
@@ -313,7 +313,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void })
return PluginSupervisor.Service.of({ awaitActivation: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
@@ -31,7 +31,7 @@ export const promptLocationNode = makeGlobalNode({
}),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
awaitActivation: Effect.void,
}),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
+1 -1
View File
@@ -44,7 +44,7 @@ function withFormatter<A, E, R>(
Effect.andThen(
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
return yield* body(yield* Formatter.Service, directory)
}).pipe(
Effect.scoped,
+1 -1
View File
@@ -69,7 +69,7 @@ describe("InstancePlugins", () => {
const agents = (ref: Location.Ref) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const service = yield* Agent.Service
return {
bound: yield* service.get(Agent.ID.make("thread-a-agent")),
+2 -2
View File
@@ -77,7 +77,7 @@ describe("Instance vanilla", () => {
const read = (ref: Location.Ref) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const config = yield* Config.Service
const discovery = yield* InstructionDiscovery.Service
const tools = yield* Tool.Service
@@ -145,7 +145,7 @@ describe("Instance vanilla", () => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const config = yield* Config.Service
// Only the pathless host-injected document; nothing file-backed.
const entries = yield* config.entries()
+29 -29
View File
@@ -199,7 +199,7 @@ describe("LocationServiceMap", () => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const read = Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const agents = yield* Agent.Service
return yield* agents.get(id)
})
@@ -233,14 +233,14 @@ describe("LocationServiceMap", () => {
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(started)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild,
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(flushFiber)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
yield* Fiber.join(activationFiber)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.timeout("1 second"),
)
@@ -281,7 +281,7 @@ describe("LocationServiceMap", () => {
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(firstStarted)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
@@ -296,10 +296,10 @@ describe("LocationServiceMap", () => {
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(flushFiber.pollUnsafe()).toBeUndefined()
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(flushFiber)
yield* Fiber.join(activationFiber)
}),
),
),
@@ -352,22 +352,22 @@ describe("LocationServiceMap", () => {
)
yield* Fiber.join(updated)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild,
)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(flushFiber.pollUnsafe()).toBeUndefined()
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(flushFiber)
yield* Fiber.join(activationFiber)
expect(activations.count).toBe(2)
}),
),
),
)
itWithSdk.live("keeps flush pending while startup updates continue", () =>
itWithSdk.live("keeps awaitActivation pending while startup updates continue", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -376,7 +376,7 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
@@ -387,8 +387,8 @@ describe("LocationServiceMap", () => {
() => bus.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
{ discard: true },
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Fiber.join(flushFiber)
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Fiber.join(activationFiber)
}),
),
),
@@ -412,7 +412,7 @@ describe("LocationServiceMap", () => {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
expect(activations.count).toBe(1)
yield* Bus.Service.use((bus) => bus.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
@@ -424,7 +424,7 @@ describe("LocationServiceMap", () => {
),
)
itWithSdk.live("keeps flush open while later hot reload runs", () =>
itWithSdk.live("keeps awaitActivation pending while later hot reload runs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -433,7 +433,7 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
@@ -451,20 +451,20 @@ describe("LocationServiceMap", () => {
)
yield* Deferred.await(started)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(flushFiber)
yield* Fiber.join(activationFiber)
yield* Deferred.await(completed)
}),
),
),
)
itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
itWithSdk.live("does not cancel activation when an awaitActivation waiter is interrupted", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -489,15 +489,15 @@ describe("LocationServiceMap", () => {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(started)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
yield* Fiber.interrupt(flushFiber)
yield* Fiber.interrupt(activationFiber)
yield* Deferred.succeed(release, undefined)
yield* Deferred.await(completed)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.timeout("500 millis"),
)
@@ -519,7 +519,7 @@ describe("LocationServiceMap", () => {
const plugins = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
return yield* plugins.list()
}).pipe(
Effect.scoped,
@@ -546,7 +546,7 @@ describe("LocationServiceMap", () => {
yield* Effect.gen(function* () {
const registry = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
@@ -710,7 +710,7 @@ describe("LocationServiceMap", () => {
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const registry = yield* Tool.Service
return {
providers: yield* catalog.provider.all(),
@@ -992,7 +992,7 @@ describe("LocationServiceMap", () => {
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const mcp = yield* Mcp.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
type: "remote",
@@ -0,0 +1 @@
throw new Error("private plugin loader details")
+34 -207
View File
@@ -14,7 +14,6 @@ import { Provider } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Session } from "@opencode-ai/core/session"
import { State } from "@opencode-ai/core/state"
import { AppProcess } from "@opencode-ai/util/process"
import { fakeSelectorSdk } from "../fixture/selector"
import { testEffect } from "../lib/effect"
@@ -54,12 +53,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Ef
)
}
function withAzureCommands<A, E, R>(
run: (args: readonly string[]) => unknown,
fx: () => Effect.Effect<A, E, R>,
deploymentDelay = 0,
signedIn = true,
) {
function withAzureCommands<A, E, R>(run: (args: readonly string[]) => unknown, fx: () => Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const processes = yield* AppProcess.Service
const directory = (yield* Location.Service).directory
@@ -68,9 +62,6 @@ function withAzureCommands<A, E, R>(
Bun.write(executable, process.platform === "win32" ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"),
)
yield* Effect.promise(() => chmod(executable, 0o755))
if (signedIn) {
yield* Effect.promise(() => Bun.write(`${directory}/azure-cli/azureProfile.json`, '{"subscriptions":[{}]}'))
}
const fake = AppProcess.Service.of({
...processes,
run: (command) => {
@@ -79,7 +70,7 @@ function withAzureCommands<A, E, R>(
if (value instanceof Error) {
return Effect.fail(new AppProcess.AppProcessError({ command: "az", cause: value }))
}
const result = Effect.succeed({
return Effect.succeed({
command: `az ${command.args.join(" ")}`,
exitCode: 0,
stdout: Buffer.from(JSON.stringify(value)),
@@ -87,16 +78,11 @@ function withAzureCommands<A, E, R>(
stdoutTruncated: false,
stderrTruncated: false,
})
if (deploymentDelay > 0 && command.args.includes("deployment")) {
return Effect.sleep(`${deploymentDelay} millis`).pipe(Effect.andThen(result))
}
return result
},
})
return yield* withEnv(
{
PATH: `${directory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`,
AZURE_CONFIG_DIR: `${directory}/azure-cli`,
},
() => fx().pipe(Effect.provideService(AppProcess.Service, fake)),
)
@@ -177,13 +163,12 @@ describe("AzurePlugin", () => {
),
)
it.live("does not invoke Azure CLI at startup without a cached Azure login", () => {
it.live("does not invoke Azure CLI at startup without an Azure connection", () => {
const commands: string[] = []
return withEnv(
{
AZURE_RESOURCE_NAME: undefined,
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined,
AZURE_RESOURCE_GROUP: undefined,
},
() =>
withAzureCommands(
@@ -198,48 +183,20 @@ describe("AzurePlugin", () => {
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
expect(integration?.methods.some((method) => method.type === "oauth")).toBe(true)
}),
0,
false,
),
)
})
it.live("lists Azure CLI resources and keeps manual resource entry available", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
() => [
{ name: "first-resource", resourceGroup: "first-group" },
{ name: "second-resource", resourceGroup: "second-group" },
],
() =>
Effect.gen(function* () {
yield* addPlugin()
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
const method = integration?.methods.find((item) => item.type === "oauth")
expect(method?.form?.[0]).toMatchObject({
title: "Enter Azure Resource Name",
options: [
{ value: "first-resource", label: "first-resource", description: "first-group" },
{ value: "second-resource", label: "second-resource", description: "second-group" },
],
custom: true,
})
}),
),
),
)
it.live("connects with the Azure CLI and accepts legacy token expiration", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
it.live("connects with the Azure CLI and accepts legacy token expiration", () => {
const commands: string[][] = []
return withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("get-access-token")) {
return {
accessToken: "legacy-cli-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}
commands.push([...args])
return {
accessToken: "legacy-cli-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}
return []
},
() =>
Effect.gen(function* () {
@@ -262,130 +219,28 @@ describe("AzurePlugin", () => {
access: "legacy-cli-token",
metadata: { resourceName: "test-resource" },
})
expect(commands).toEqual([
[
"account",
"get-access-token",
"--scope",
"https://cognitiveservices.azure.com/.default",
"--output",
"json",
],
])
}),
),
),
)
it.live("finishes deployment discovery before Azure authorization completes", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("get-access-token")) {
return {
accessToken: "azure-token",
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
}
}
if (args.includes("deployment")) {
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
]
}
return [{ name: "test-resource", resourceGroup: "test-group" }]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* addPlugin()
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("azure")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("azure-cli"),
answer: { resourceName: "test-resource" },
})
yield* Effect.gen(function* () {
const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })
if (status.status !== "complete") {
return yield* Effect.fail(
new Error(
`Azure CLI authorization ${status.status}${"message" in status ? `: ${status.message}` : ""}`,
),
)
}
}).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }))
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
}),
50,
),
),
)
for (const batched of [false, true]) {
it.effect(
`discovers deployments with an existing connection (${batched ? "batched startup" : "ready catalog"})`,
() =>
withEnv({ AZURE_RESOURCE_GROUP: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("deployment")) {
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
{
name: "gpt-staging",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
{
name: "gpt-pending",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Creating" },
},
]
}
return [{ name: "test-resource", resourceGroup: "test-group" }]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* azureCredential
const startup = Effect.gen(function* () {
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), (model) => {
model.name = "GPT-5 Mini"
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* addPlugin()
})
yield* batched ? State.batch(startup) : startup
expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-staging")))?.modelID).toBe(
Model.ID.make("gpt-staging"),
)
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
}),
),
),
)
}
})
it.effect("keeps existing models when Azure deployment discovery is unavailable", () =>
withAzureCommands(
() => new Error("management access denied"),
it.live("does not invoke Azure CLI at startup with an existing connection", () => {
const commands: string[][] = []
return withAzureCommands(
(args) => {
commands.push([...args])
return []
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -394,46 +249,18 @@ describe("AzurePlugin", () => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* azureCredential
yield* addPlugin()
expect(commands).toEqual([])
expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeDefined()
}),
),
)
it.effect("skips subscription discovery when the resource group is configured", () =>
withEnv({ AZURE_RESOURCE_GROUP: "restricted-group" }, () =>
withAzureCommands(
(args) => {
expect(args).toContain("deployment")
expect(args).toContain("restricted-group")
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
})
yield* azureCredential
yield* addPlugin()
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
}),
),
),
)
)
})
it.effect("uses the correct bearer token audience for Azure and Foundry requests", () =>
withAzureCommands(
+1 -1
View File
@@ -46,6 +46,6 @@ it.effect("publishes successful package updates", () =>
yield* updates.update("fixture")
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toBe("fixture")
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toEqual({ target: "fixture", outdated: false })
}),
)
+94 -11
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -150,7 +150,7 @@ test("compaction points an existing summary to the following history", () => {
expect(prompt).not.toContain("conversation history above")
})
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
@@ -161,25 +161,45 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => ({
session,
resolved: SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => {
const resolved = SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image", "pdf"], output: ["text"] },
cost: [],
limit,
}),
messages: [
})
const messages = [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
content: [{ type: "text", text: "Done" }],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
]
return {
session,
resolved,
messages,
context: {
session,
model: resolved,
messages,
agent: {
id: Agent.defaultID,
info: { ...Agent.Info.default(Agent.defaultID), system: "You are a helpful assistant." },
},
initial: "Project instructions.",
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read files", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("unused"),
},
},
}
}
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
@@ -192,6 +212,69 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
const outputLimited = { context: 100_000, output: 30_000 }
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
const assistant = input(79_000, contextLimited).messages[0]
const tool = SessionMessage.AssistantTool.make({
type: "tool",
id: "call_read",
name: "read",
state: { status: "completed", input: {}, content: [{ type: "text", text: "x".repeat(4_000) }] },
time: { created: DateTime.makeUnsafe(0) },
})
const grown = { ...input(79_000, contextLimited), messages: [{ ...assistant, content: [tool] }] }
expect(SessionCompaction.estimateTokens(grown)).toBe(80_000)
expect(compaction.required(grown)).toBe(true)
const interrupted = { ...assistant, id: SessionMessage.ID.create(), tokens: undefined }
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...grown.messages, interrupted] })).toBe(80_001)
// Without provider usage, include 20 tokens for the system prompt, instructions, and tool definition.
expect(SessionCompaction.estimateTokens({ ...grown, messages: [interrupted] })).toBe(21)
expect(
SessionCompaction.estimateTokens({
...grown,
messages: [{ ...interrupted, tokens: input(0, contextLimited).messages[0].tokens }],
}),
).toBe(21)
const media = [
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${"a".repeat(100_000)}` },
{ type: "file", mime: "application/pdf", uri: `data:application/pdf;base64,${"a".repeat(100_000)}` },
] as const
const messages = [
{ ...assistant, content: [{ ...tool, state: { status: "completed" as const, input: {}, content: media } }] },
]
expect(SessionCompaction.estimateTokens({ ...grown, messages })).toBe(82_500)
const user = Schema.decodeUnknownSync(SessionMessage.User)({
id: SessionMessage.ID.create(),
type: "user",
text: "",
files: media.map((file) => ({ mime: file.mime, data: "a".repeat(100_000), source: { type: "inline" } })),
time: { created: 0 },
})
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...messages, user] })).toBe(86_000)
for (const [modalities, tokens, fallback] of [
[["text", "image"], 82_040, 1_520],
[["text", "pdf"], 83_042, 2_021],
[["text"], 79_082, 41],
] as const) {
const selected = {
...grown,
resolved: { ...grown.resolved, capabilities: { ...grown.resolved.capabilities, input: modalities } },
}
expect(SessionCompaction.estimateTokens({ ...selected, messages: [...messages, user] })).toBe(tokens)
expect(SessionCompaction.estimateTokens({ ...selected, messages: [user] })).toBe(fallback + 20)
}
const checkpoint = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)({
id: SessionMessage.ID.create(),
type: "compaction",
status: "completed",
reason: "auto",
summary: "x".repeat(400_000),
recent: "",
time: { created: 0, completed: 0 },
})
expect(compaction.required({ ...grown, messages: [checkpoint] })).toBe(false)
}),
)
+1 -1
View File
@@ -111,7 +111,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const plugins = Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
+183 -16
View File
@@ -1,14 +1,18 @@
import { describe, expect } from "bun:test"
import { and, eq } from "drizzle-orm"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Queue, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "@opencode-ai/schema/agent"
import { Event } from "@opencode-ai/schema/event"
import { Form } from "@opencode-ai/schema/form"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Permission } from "@opencode-ai/schema/permission"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { ID, Info, Output } from "@opencode-ai/schema/shell"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "../src/bus.js"
@@ -51,6 +55,7 @@ const it = testEffect(
SessionStore.node,
SessionInbox.node,
FSUtil.node,
httpClient,
]),
{
replacements: [Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer)],
@@ -95,7 +100,7 @@ const setup = Effect.fnUntraced(function* (options?: {
)
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
const locations: Location.Ref[] = []
const flushes: Location.Ref[] = []
const activationWaits: Location.Ref[] = []
const resumes: SessionSchema.ID[] = []
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
const execution = SessionExecution.Service.of({
@@ -143,8 +148,8 @@ const setup = Effect.fnUntraced(function* (options?: {
}),
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
flushes.push(ref)
awaitActivation: Effect.sync(() => {
activationWaits.push(ref)
}),
}),
),
@@ -166,6 +171,7 @@ const setup = Effect.fnUntraced(function* (options?: {
| Instance.Service
| SessionExecution.Service
| SessionInbox.Service
| HttpClient.HttpClient
| Scope.Scope
>(),
Effect.provideService(Instance.Service, {
@@ -175,7 +181,7 @@ const setup = Effect.fnUntraced(function* (options?: {
}),
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
)
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
return { sessions, hooks, locations, activationWaits, resumes, wakes, db: database.db, bus, store }
})
describe("Session-owned handles", () => {
@@ -252,7 +258,7 @@ describe("Session-owned handles", () => {
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
expect(fixture.flushes).toEqual([source])
expect(fixture.activationWaits).toEqual([source])
calls.push(event.prompt.text)
event.prompt.text += " prepared"
}),
@@ -277,7 +283,7 @@ describe("Session-owned handles", () => {
)
expect(calls).toEqual(["Original"])
expect(fixture.locations).toEqual([source])
expect(fixture.flushes).toEqual([source])
expect(fixture.activationWaits).toEqual([source])
expect(fixture.wakes).toEqual([
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
@@ -287,6 +293,159 @@ describe("Session-owned handles", () => {
}),
)
it.live("forwards enriched session events across scopes and moves without blocking publication", () =>
Effect.gen(function* () {
const received = yield* Queue.unbounded<unknown>()
const response = Promise.withResolvers<void>()
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
expect(request.method).toBe("POST")
expect(request.headers.get("content-type")).toContain("application/json")
Queue.offerUnsafe(received, await request.json())
await response.promise
return new Response(null, { status: 204 })
},
}),
),
(server) => Effect.sync(() => response.resolve()).pipe(Effect.andThen(Effect.promise(() => server.stop(true)))),
)
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const callbackUrl = server.url.href
yield* handle
.prompt({ text: "Notify me", callbackUrl })
.pipe(Effect.provideService(Location.Service, location(source)), Effect.scoped)
expect(JSON.stringify(yield* handle.inbox())).not.toContain(callbackUrl)
const started = yield* fixture.bus.publish(SessionEvent.Execution.Started, { sessionID })
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
...started,
response: null,
})
yield* fixture.bus.publish(SessionEvent.Moved, {
sessionID,
projectID: Project.ID.global,
location: Location.Ref.make({ directory: AbsolutePath.make("/project/moved") }),
subpath: RelativePath.make("moved"),
})
yield* fixture.bus.publish(SessionEvent.Renamed, { sessionID: otherID, title: "Unrelated" })
const responses = yield* Effect.forEach(["Earlier answer", "The review is ready."], (text) =>
Effect.gen(function* () {
const assistantMessageID = SessionMessage.ID.create()
yield* fixture.bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* fixture.bus.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, ordinal: 0 })
yield* fixture.bus.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, ordinal: 0, text })
yield* fixture.bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
return assistantMessageID
}),
)
yield* fixture.bus.publish(SessionEvent.Synthetic, { sessionID, text: "Trailing non-assistant message" })
const expected = [
yield* fixture.bus.publish(SessionEvent.Renamed, { sessionID, title: "Working" }),
yield* fixture.bus.publish(Permission.Event.Asked, {
sessionID,
id: Permission.ID.create(),
action: "shell",
resources: ["pwd"],
}),
yield* fixture.bus.publish(Form.Event.Created, {
form: Form.Info.make({
id: Form.ID.create(),
sessionID,
title: "Continue?",
fields: [{ key: "continue", type: "boolean" }],
}),
}),
yield* fixture.bus.publish(SessionEvent.Execution.Succeeded, { sessionID }),
]
// Session work settles while the receiver still has not acknowledged the first POST.
expect((yield* handle.get()).outcome).toBe("succeeded")
response.resolve()
const delivered = yield* Effect.forEach(expected, () => Queue.take(received)).pipe(Effect.timeout("2 seconds"))
expect(delivered).toMatchObject(expected)
expect(delivered.at(-1)).toMatchObject({
session: {
id: sessionID,
title: "Working",
outcome: "succeeded",
location: { directory: "/project/moved" },
time: { created: expect.any(Number), updated: expect.any(Number), idle: expect.any(Number) },
},
response: {
id: responses.at(-1),
type: "assistant",
content: [{ type: "text", text: "The review is ready." }],
time: { created: expect.any(Number), completed: expect.any(Number) },
},
})
yield* handle.rename({ title: "After completion" })
expect(Option.isNone(yield* Queue.take(received).pipe(Effect.timeoutOption("50 millis")))).toBe(true)
}),
)
it.live("replaces callbacks and isolates HTTP failures without following redirects", () =>
Effect.gen(function* () {
const received = yield* Queue.unbounded<{ path: string; body: unknown }>()
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const path = new URL(request.url).pathname
Queue.offerUnsafe(received, { path, body: await request.json() })
return new Response(null, {
status: path === "/redirect" ? 307 : 503,
headers: { location: new URL("/unexpected", request.url).href },
})
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const prompt = yield* handle.prompt({ text: "First", resume: false, callbackUrl: `${server.url}old` })
yield* handle.prompt({ id: prompt.id, text: "Retry", resume: false, callbackUrl: `${server.url}failed` })
const started = yield* fixture.bus.publish(SessionEvent.Execution.Started, { sessionID })
const failed = yield* fixture.bus.publish(SessionEvent.Execution.Failed, {
sessionID,
error: { type: "unknown", message: "Run failed" },
})
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
path: "/failed",
body: started,
})
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
path: "/failed",
body: failed,
})
yield* handle.prompt({ text: "Redirect", resume: false, callbackUrl: `${server.url}redirect` })
const deleted = yield* fixture.bus.publish(SessionEvent.Deleted, { sessionID })
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toEqual({
path: "/redirect",
body: { ...deleted, session: null, response: null },
})
expect(Option.isNone(yield* Queue.take(received).pipe(Effect.timeoutOption("50 millis")))).toBe(true)
}),
)
it.live("keeps the first admission across handles, including delivered retries and identity conflicts", () =>
Effect.gen(function* () {
const fixture = yield* setup()
@@ -363,7 +522,7 @@ describe("Session-owned handles", () => {
expect(fixture.locations).toEqual([source])
yield* prompt
expect(fixture.locations).toEqual([source, destination])
expect(fixture.flushes).toEqual([source, destination])
expect(fixture.activationWaits).toEqual([source, destination])
expect((yield* fixture.sessions.forSession(otherID).get()).location).toEqual(source)
}),
)
@@ -408,7 +567,7 @@ describe("Session-owned handles", () => {
expect(
events.filter((event) => event.type === SessionEvent.Skill.Activated.type).map((event) => event.location),
).toEqual([undefined, undefined, source])
expect(fixture.flushes).toEqual([])
expect(fixture.activationWaits).toEqual([])
expect(fixture.resumes).toEqual([])
expect(fixture.wakes).toEqual([])
}),
@@ -442,7 +601,7 @@ describe("Session-owned handles", () => {
expect(yield* handle.get()).toEqual(before)
expect(yield* handle.inbox()).toEqual([])
expect(yield* fixture.store.context(sessionID)).toEqual([])
expect(fixture.flushes).toEqual([])
expect(fixture.activationWaits).toEqual([])
expect(fixture.resumes).toEqual([])
expect(fixture.wakes).toEqual([])
}),
@@ -776,7 +935,7 @@ describe("Session-owned handles", () => {
expect(captures).toEqual([source, destination])
expect(fixture.locations).toEqual([source, destination, destination])
expect(fixture.flushes).toEqual([source, destination, destination])
expect(fixture.activationWaits).toEqual([source, destination, destination])
expect((yield* handle.get()).revert).toBeUndefined()
}),
)
@@ -800,7 +959,7 @@ describe("SessionPrompt construction", () => {
Layer.mergeAll(
Layer.succeed(PluginHooks.Service, fixture.hooks),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
awaitActivation: Effect.sync(() => {
calls.push("ready")
}),
}),
@@ -842,8 +1001,8 @@ describe("SessionRevert construction", () => {
Effect.provide(
Layer.merge(
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
calls.push("flush")
awaitActivation: Effect.sync(() => {
calls.push("awaitActivation")
}),
}),
Layer.mock(Snapshot.Service, {
@@ -871,7 +1030,7 @@ describe("SessionRevert construction", () => {
yield* revert
.stage({ session, messageID: boundary.id, files: false })
.pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["flush", "capture", "capture", "diff"])
expect(calls).toEqual(["awaitActivation", "capture", "capture", "diff"])
const staged = yield* handle.get()
expect(staged.revert?.snapshot).toBe(Snapshot.ID.make("captured-tree"))
@@ -879,7 +1038,15 @@ describe("SessionRevert construction", () => {
const cleared = yield* handle.get()
expect(cleared.revert).toBeUndefined()
yield* revert.clear(cleared).pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["flush", "capture", "capture", "diff", "flush", "restore", "flush"])
expect(calls).toEqual([
"awaitActivation",
"capture",
"capture",
"diff",
"awaitActivation",
"restore",
"awaitActivation",
])
}),
)
})
@@ -60,7 +60,7 @@ const setup = Effect.gen(function* () {
const services = locations.get(session.location)
const hooks = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
return yield* PluginHooks.Service
}).pipe(Effect.provide(services))
return { sessions, session, hooks, services }
+1 -1
View File
@@ -115,7 +115,7 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.sync(() => (ready = true)),
awaitActivation: Effect.sync(() => (ready = true)),
}),
),
),
+1 -1
View File
@@ -67,7 +67,7 @@ describe("Session.revert files", () => {
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
@@ -85,7 +85,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ awaitActivation: Effect.void }),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
+6 -7
View File
@@ -121,7 +121,7 @@ const compactModel = testModel("compact", { context: 4_000, output: 50 })
const fullOutputModel = testModel("full-output", { context: 262_144, output: 262_144 })
const unknownContextModel = testModel("unknown-context", { context: 0, output: 32_000 })
const undersizedContextModel = testModel("undersized-context", { context: 1, output: 1_000 })
const recoveryModel = testModel("recovery", { context: 20_000, output: 1_000 })
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
test("calculates step cost using the matching context tier", () => {
expect(
@@ -206,7 +206,7 @@ const makeRunnerState = () => {
systemUnavailable: false,
systemLoadHook: Effect.void,
skillBaselines: new Map<Agent.ID, string>(),
pluginFlushHook: Effect.void,
pluginActivationHook: Effect.void,
authorizations: new Array<Tool.Context>(),
executions: new Array<string>(),
closedTransports: new Array<Session.ID>(),
@@ -390,7 +390,7 @@ const layer = Layer.unwrap(
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.suspend(() => state.pluginFlushHook),
awaitActivation: Effect.suspend(() => state.pluginActivationHook),
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
@@ -1840,7 +1840,7 @@ describe("SessionRunnerLLM", () => {
scenario("waits for initial plugin readiness before constructing the model request", function* (s) {
const release = yield* Deferred.make<void>()
s.pluginFlushHook = Deferred.await(release)
s.pluginActivationHook = Deferred.await(release)
yield* s.session.prompt({ sessionID, text: "Wait for plugins", resume: false })
s.requests.length = 0
@@ -2540,17 +2540,16 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("recovers from provider context overflow despite an undersized configured context limit", function* (s) {
scenario("compacts before requesting an undersized configured context limit", function* (s) {
yield* setupOverflowRecovery(s)
s.currentModel = undersizedContextModel
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
TestLLM.text("Recovered", "text-final-undersized-limit"),
)
yield* s.runPrompt("Continue")
expect(s.requests).toHaveLength(3)
expect(s.requests).toHaveLength(2)
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover undersized limit" },
{ type: "assistant", finish: "stop" },
+1 -1
View File
@@ -53,7 +53,7 @@ const locations = makeGlobalNode({
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
awaitActivation: Effect.void,
}),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
+1 -1
View File
@@ -130,7 +130,7 @@ const it = testEffect(
Catalog.node.replace(catalog),
SessionRunnerModel.node.replace(models),
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { flush: Effect.void })),
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })),
],
),
)
+2 -2
View File
@@ -129,7 +129,7 @@ const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void }))),
),
deps: [
Config.node,
@@ -220,7 +220,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
const locationLayer = locations.get(location)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
const registry = yield* Tool.Service
return yield* body(registry)
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
+6 -2
View File
@@ -107,7 +107,9 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(SubagentTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
@@ -155,7 +157,9 @@ const completionIt = testEffect(
const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location)))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(locations.get(location)),
)
yield* Agent.Service.use((agents) =>
agents.transform((draft) => {
// The caller identity used by executeTool; subagent permission asserts against it.
+5 -1
View File
@@ -156,7 +156,11 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
}
type PromptFooterInput = { readonly sessionID?: string; readonly mode: "normal" | "shell" }
type PromptFooterInput = {
readonly sessionID?: string
readonly mode: "normal" | "shell"
readonly showDetails: boolean
}
/**
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
+132
View File
@@ -486,6 +486,132 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check one or all package plugins for available updates.",
"summary": "Check plugin updates",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
@@ -2017,6 +2143,9 @@
"type": "null"
}
]
},
"callbackUrl": {
"type": "string"
}
},
"required": ["text"],
@@ -16772,6 +16901,9 @@
},
"error": {
"type": "string"
},
"ref": {
"type": "string"
}
},
"required": ["status", "error"],
+16
View File
@@ -20,6 +20,22 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String.pipe(Schema.optional) }),
success: Location.response(Schema.Array(Plugin.Info)),
error: InvalidRequestError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
summary: "Check plugin updates",
description: "Check one or all package plugins for available updates.",
}),
),
)
.add(
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
query: LocationQuery,
+5 -1
View File
@@ -6,7 +6,7 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionStats } from "@opencode-ai/schema/session-stats"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, optional, statics } from "@opencode-ai/schema/schema"
import { Event } from "@opencode-ai/schema/event"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
@@ -343,6 +343,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
metadata: SessionInbox.UserPayload.fields.metadata,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
callbackUrl: Schema.String.pipe(optional).annotate({
description:
"HTTP(S) URL for best-effort JSON POSTs of execution, rename, permission and form events until execution settles or the session is deleted. Each event includes session and response fields containing the current Session.Info and latest assistant message in HTTP API JSON format, or null when unavailable. Replaces the active callback for this session. Process-local, without retries; the URL is not stored in prompt history.",
}),
}),
success: Schema.Struct({ data: SessionInbox.User }),
error: [ConflictError, InvalidRequestError, SessionNotFoundError],
+1 -1
View File
@@ -29,7 +29,7 @@ export type Features = typeof Features.Type
export const State = Schema.Union([
Schema.Struct({ status: Schema.Literal("active") }),
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }),
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String, ref: Schema.String.pipe(optional) }),
]).annotate({ identifier: "Plugin.State" })
export type State = typeof State.Type
+4
View File
@@ -12,3 +12,7 @@ export const Info = Schema.Struct({
write: Schema.Finite,
}),
}).annotate({ identifier: "TokenUsage.Info" })
export function total(tokens: Info) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
+14
View File
@@ -23,3 +23,17 @@ test("embeds plugin state with a status discriminator", () => {
outdated: true,
})
})
test("plugin failure references round trip and absent references stay omitted", () => {
const codec = Schema.fromJsonString(Plugin.State)
const failed = { status: "failed", error: "Plugin failed to load", ref: "err_a1b2c3d4" } as const
expect(Schema.decodeUnknownSync(codec)(Schema.encodeSync(codec)(failed))).toEqual(failed)
expect(Schema.encodeSync(Plugin.State)({ ...failed, ref: undefined })).toEqual({
status: "failed",
error: failed.error,
})
expect(Schema.decodeUnknownSync(Plugin.State)({ status: "failed", error: failed.error })).toMatchObject({
status: "failed",
error: failed.error,
})
})
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { TokenUsage } from "../src/token-usage.js"
test("totals every token category", () => {
expect(TokenUsage.total({ input: 1, output: 2, reasoning: 4, cache: { read: 8, write: 16 } })).toBe(31)
})
test("supports zero and large usage totals without rounding", () => {
expect(TokenUsage.total({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } })).toBe(0)
expect(
TokenUsage.total({
input: 1_000_000_000,
output: 2_000_000_000,
reasoning: 3,
cache: { read: 4_000_000_000, write: 5 },
}),
).toBe(7_000_000_008)
})
+1 -1
View File
@@ -48,7 +48,7 @@ for (const selection of ["explicit", "default"] as const) {
PluginSupervisor.Service,
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
return { awaitActivation: release.open.pipe(Effect.andThen(plugins.awaitActivation)) }
}),
).pipe(Layer.provide(layer)),
)
+43 -1
View File
@@ -14,10 +14,52 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.check", (ctx) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
const plugins = yield* Plugin.Service
const inventory = yield* plugins.list()
const targets = [
...new Set(
inventory.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
),
].filter((target) => ctx.payload.target === undefined || target === ctx.payload.target)
if (ctx.payload.target !== undefined && !targets.length)
return yield* new InvalidRequestError({
message: `Plugin package is not in the current server inventory: ${ctx.payload.target}`,
field: "target",
})
const updates = yield* PluginUpdate.Service
const outdated = new Map(
yield* Effect.forEach(
targets,
(target) => updates.check(target, { refresh: true }).pipe(Effect.map((value) => [target, value] as const)),
{ concurrency: "unbounded" },
),
)
return yield* response(
Effect.succeed(
inventory.map((plugin) => {
if (plugin.source.type !== "package" || !outdated.has(plugin.source.target)) return plugin
return {
...plugin,
source: {
type: "package" as const,
target: plugin.source.target,
...(plugin.source.version ? { version: plugin.source.version } : {}),
...(outdated.get(plugin.source.target) ? { outdated: true as const } : {}),
},
}
}),
),
)
}),
)
.handle("plugin.update", (ctx) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const plugins = yield* Plugin.Service
if (
!(yield* plugins.list()).some(
+1 -1
View File
@@ -9,7 +9,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
handlers.handle("rpc.call", ({ params, payload }) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const rpc = yield* Rpc.Service
const output = yield* rpc.call(params.rpcID, params.method, payload.input)
return output === undefined ? {} : { output }
+9
View File
@@ -289,6 +289,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.prompt",
Effect.fn(function* (ctx) {
if (ctx.payload.callbackUrl !== undefined) {
const url = URL.parse(ctx.payload.callbackUrl)
if (!url || !["http:", "https:"].includes(url.protocol) || url.username || url.password)
return yield* new InvalidRequestError({
message: "Expected an HTTP(S) callback URL without credentials",
field: "callbackUrl",
})
}
return {
data: yield* session
.prompt({
@@ -301,6 +309,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
metadata: ctx.payload.metadata,
delivery: ctx.payload.delivery,
resume: ctx.payload.resume,
callbackUrl: ctx.payload.callbackUrl,
})
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
+68 -1
View File
@@ -5,7 +5,7 @@ import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
import { Effect, Schedule, Schema } from "effect"
import { Effect, Queue, Schedule, Schema } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -118,6 +118,73 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
}),
)
it.live("validates prompt callback URLs and delivers real execution events over HTTP", () =>
Effect.gen(function* () {
const received = yield* Queue.unbounded<unknown>()
const receiver = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
Queue.offerUnsafe(received, await request.json())
return new Response(null, { status: 204 })
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const handler = yield* ServerFetch.make(options)
const created = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: { id: "missing-model", providerID: "missing-provider" } }),
}),
).then((response) => response.json()),
)
const endpoint = `http://opencode.local/api/session/${created.data.id}`
yield* Effect.forEach(["not a URL", "ftp://example.test", "https://user:password@example.test"], (callbackUrl) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() =>
handler(
new Request(`${endpoint}/prompt`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "Invalid callback", callbackUrl }),
}),
),
)
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "InvalidRequestError",
field: "callbackUrl",
})
}),
)
const submitted = yield* Effect.promise(() =>
handler(
new Request(`${endpoint}/prompt`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "Notify me", callbackUrl: receiver.url.href }),
}),
),
)
expect(submitted.status).toBe(200)
expect(yield* Queue.take(received).pipe(Effect.timeout("5 seconds"))).toMatchObject({
type: "session.execution.started",
data: { sessionID: created.data.id },
})
expect(yield* Queue.take(received).pipe(Effect.timeout("5 seconds"))).toMatchObject({
type: "session.execution.failed",
data: { sessionID: created.data.id },
session: { id: created.data.id, outcome: "failed" },
})
}),
)
it.live("activates credentials through the HttpApi", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
+18 -1
View File
@@ -37,7 +37,7 @@ it.live("updates package plugins in the requested Location", () =>
Npm.Service.of({
add: () => Effect.sync(entry),
resolve: () => Effect.sync(entry),
check: () => Effect.succeed(false),
check: () => Effect.sync(() => version === "1.0.0"),
update: () => {
if (fail) return Effect.fail(new Npm.InstallFailedError({ dir: plugin }))
return Effect.sync(() => (version = "2.0.0")).pipe(Effect.map(entry))
@@ -60,6 +60,23 @@ it.live("updates package plugins in the requested Location", () =>
),
)
const checked = yield* Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local/api/plugin/check?location[directory]=${encodeURIComponent(project)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
)
return response.json() as Promise<{
data: Array<{ id?: string; source: { target?: string; outdated?: true } }>
}>
})
expect(checked.data.find((item) => item.id === "fixture.plugin")?.source).toMatchObject({
target: "fixture-plugin",
outdated: true,
})
expect((yield* update("fixture-plugin")).status).toBe(204)
const listedVersion = yield* Effect.promise(async () => {
const response = await handler(
+1 -1
View File
@@ -48,7 +48,7 @@ const fixture = Effect.fn(function* (plugins: readonly Plugin.Plugin[]) {
boot: (directory: string) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })))),
call: (
route: string,
@@ -250,7 +250,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(secondDirectory) }))),
])
yield* Effect.forEach([primary, secondary], (context) =>
PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)),
PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context)),
)
expect(activations).toBe(2)
+6
View File
@@ -0,0 +1,6 @@
# TUI Experiments
- Temporary, opt-in UI experiments belong in the `experiments` registry in `src/component/dialog-experiments.tsx`, with behavior gated by the matching `config.experimental` key.
- The Experiments framework is permanent infrastructure. Keep the registry, dialog, settings persistence, toggle handling, and UI entry point even when there are no active experiments.
- Graduating an experiment means removing only its registry entry and feature-specific gate, making that feature's behavior unconditional. Abandoning an experiment means removing its entry, gate, and experimental behavior.
- Do not remove or simplify away the reusable Experiments infrastructure because the registry is empty. An empty registry is intentional and ready for future experiments, not dead code.
+1 -1
View File
@@ -1321,7 +1321,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<PaneResizeHandle resize={tabsResize} left={tabsResize.size() - 1} />
</Show>
</box>
<Show when={devtools()}>
<Show when={devtools() && !(route.data.type === "plugin" && route.data.id === "opencode.stats")}>
<DevToolsBar />
</Show>
<Show when={!startup.skipInitialLoading}>
@@ -1,6 +1,6 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -9,17 +9,25 @@ import { useRoute } from "../context/route"
import { getScrollAcceleration } from "../util/scroll"
import { useTheme } from "../context/theme"
import { emptyPrompt } from "../prompt/history"
import { useDialog } from "../ui/dialog"
import { dialogWidth, useDialog } from "../ui/dialog"
import { FilePath } from "../ui/file-path"
import { useToast } from "../ui/toast"
import { errorDetails } from "../util/error-details"
export function DialogErrorDetails(props: { title: string; error: string; context?: string; onBack: () => void }) {
export function DialogErrorDetails(props: {
title: string
source?: string
error: string
context?: string
diagnosticRef?: string
onBack: () => void
}) {
const clipboard = useClipboard()
const dialog = useDialog()
const location = useLocation()
const route = useRoute()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
@@ -51,7 +59,7 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
const copy = () => {
void clipboard
.write(props.error)
.write(errorDetails(props).text)
.then(() => setCopied(true))
.catch(toast.error)
}
@@ -62,7 +70,7 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
location: location.ref,
prompt: {
...emptyPrompt(),
text: `Investigate why this OpenCode component failed in the current project.\n\n${props.title}${props.context ? `\n${props.context}` : ""}\nError: ${props.error}\n\nInspect the relevant project and global OpenCode configuration, startup or loading behavior, required environment variables or credentials, dependencies, and logs. Identify the root cause and recommend a fix.`,
text: errorDetails(props).prompt,
},
})
dialog.clear()
@@ -88,40 +96,49 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
})
return (
<box paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc
</text>
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box>
<box flexDirection="row" gap={2}>
<text
attributes={TextAttributes.BOLD}
fg={theme.text.default}
flexGrow={1}
minWidth={0}
wrapMode="none"
truncate
>
{props.title}
</text>
<text fg={theme.text.subdued} flexShrink={0} onMouseUp={props.onBack}>
esc
</text>
</box>
<Show when={props.source}>
{(source) => (
<FilePath
value={source()}
maxWidth={Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 4}
fg={theme.text.subdued}
/>
)}
</Show>
</box>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
{props.error}
</text>
</scrollbox>
<Show when={props.diagnosticRef}>
<text fg={theme.text.subdued}>Reference: {props.diagnosticRef}</text>
</Show>
</box>
<box flexDirection="row" gap={3} paddingLeft={2} paddingRight={2}>
<text flexGrow={1}>
<span style={{ fg: theme.text.default }}>
<b>{scrollable() ? "↑/↓" : ""}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{scrollable() ? " scroll" : ""}</span>
</text>
<box flexDirection="row" gap={3} flexWrap="wrap">
<text onMouseUp={investigate}>
<span style={{ fg: theme.text.default }}>
<b>i</b>
@@ -134,6 +151,9 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
<Show when={scrollable()}>
<text fg={theme.text.subdued}>/ scroll</text>
</Show>
</box>
</box>
)
@@ -13,13 +13,7 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = [
{
id: "session-preview-tabs",
title: "Session preview tabs",
description: "Preview sessions in temporary tabs before keeping them open",
},
]
export const experiments: Experiment[] = []
export function DialogExperiments() {
const config = useConfig()
+5 -1
View File
@@ -1595,7 +1595,11 @@ export function Prompt(props: PromptProps) {
if (agentLabel()) revealedPromptMetadata.add(local)
})
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
const footerInput = () => ({
sessionID: props.sessionID,
mode: store.mode,
showDetails: store.interrupt === 0 || dimensions().width >= 80,
})
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
+2 -1
View File
@@ -374,6 +374,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
}))
const actions = createMemo(() => {
const sessionID = props.state.sessionID
const title = props.state.title
return [
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(sessionID
@@ -383,7 +384,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
: []),
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
run: () => DialogSessionRename.show(dialog, sessionID, title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
@@ -0,0 +1,41 @@
import { createEffect, untrack, type Accessor } from "solid-js"
import type { Data } from "@opencode-ai/client/solid"
export function createSessionRetention(input: {
session: Pick<Data["session"], "list" | "root" | "evict">
current: Accessor<string | undefined>
keep: Accessor<readonly string[]>
limit: number
}) {
let previous: string | undefined
let recent: string[] = []
let evicted = new Map<string, string>()
createEffect(() => {
const viewed = input.current()
const current = viewed === undefined ? undefined : input.session.root(viewed)
const keep = new Set(input.keep().map((id) => input.session.root(id)))
// Resolve again when metadata arrives, but only navigation advances recency.
recent = [
...new Set([
...(viewed !== previous && current !== undefined ? [current] : []),
...recent.map((id) => input.session.root(id)),
]),
]
previous = viewed
const retained = new Set([...keep, ...recent.filter((id) => !keep.has(id)).slice(0, input.limit)])
if (current !== undefined) retained.add(current)
recent = recent.filter((id) => retained.has(id))
const excluded = new Map(
input.session.list().flatMap((session) => {
const root = input.session.root(session.id)
return retained.has(root) ? [] : [[session.id, root] as const]
}),
)
// New descendants need eviction even when their root was already evicted.
const roots = new Set([...excluded].filter(([id, root]) => evicted.get(id) !== root).map(([, root]) => root))
evicted = excluded
untrack(() => roots.forEach((root) => input.session.evict(root)))
})
}
+18 -26
View File
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
@@ -12,6 +13,7 @@ import { useLocation } from "./location"
import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import { newSessionLocation } from "../config/new-session-location"
import { createSessionRetention } from "./session-retention"
import {
closeSessionTab,
cycleSessionTab,
@@ -63,7 +65,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const renderer = useRenderer()
const storage = useStorage()
const enabled = () => config.tabs.enabled
const previews = () => config.experimental?.["session-preview-tabs"] === true
const [focused, setFocused] = createSignal<boolean>()
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
@@ -74,9 +75,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
key: "sessionID",
})
const [preview, updatePreview] = storage.memory<{ global?: string; cwd?: string }>("session-tab-preview", {
initial: {},
})
const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
@@ -108,16 +107,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const previewID = () => preview[config.tabs.scope]
const setPreview = (sessionID: string | undefined) => {
const scope = config.tabs.scope
updatePreview((draft) => {
if (sessionID === undefined) {
delete draft[scope]
return
}
draft[scope] = sessionID
})
}
const setPreview = (sessionID: string | undefined) => updatePreview(config.tabs.scope, sessionID)
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs.scope
@@ -149,6 +139,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
unread: {},
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
createSessionRetention({
session: data.session,
current: () =>
route.data.type === "session" && route.data.sessionID !== "dummy" ? route.data.sessionID : undefined,
keep: () => (enabled() ? state().tabs.map((tab) => tab.sessionID) : []),
limit: 3,
})
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
if (!open) return false
@@ -178,13 +175,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
createEffect(() => {
if (enabled() && previews()) return
if (enabled()) return
promotedSession = undefined
if (!preview.global && !preview.cwd) return
updatePreview((draft) => {
delete draft.global
delete draft.cwd
})
updatePreview({ global: undefined, cwd: undefined })
})
// Shared storage updates must not re-admit a tab unless this client changes route or scope.
@@ -203,10 +196,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
history = recordSessionTabHistory(history, sessionID)
if (state().tabs.some((tab) => tab.sessionID === sessionID)) return
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const temporary = previews() && !permanent
const replaced = temporary ? previewID() : undefined
const replaced = permanent ? undefined : previewID()
if (replaced) scrollAnchors.delete(replaced)
if (temporary) setPreview(sessionID)
if (!permanent) setPreview(sessionID)
update((draft) => {
if (cancelledTabs.has(sessionID)) return
const tab = {
@@ -377,7 +369,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return state().tabs
},
isPreview(sessionID: string) {
return enabled() && previews() && previewID() === root(sessionID)
return enabled() && previewID() === root(sessionID)
},
newTab() {
return newTab()
@@ -404,7 +396,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
route.navigate({ type: "session", sessionID: root(sessionID) })
},
promote(sessionID: string) {
if (!enabled() || !previews()) return
if (!enabled()) return
const session = root(sessionID)
if (previewID() === session) {
setPreview(undefined)
@@ -448,7 +440,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const tabs = result.tabs
if (!tabs || !result.sessionID) return
cancelledTabs.delete(result.sessionID)
if (previews()) promotedSession = result.sessionID
promotedSession = result.sessionID
update((draft) => {
draft.tabs = tabs
})
@@ -8,7 +8,12 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
export function PromptFooter(props: {
context: Plugin.Context
sessionID?: string
mode: "normal" | "shell"
showDetails: boolean
}) {
const dimensions = useTerminalDimensions()
const [liveHovered, setLiveHovered] = createSignal(false)
const subagents = createMemo(() => {
@@ -69,7 +74,7 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
</text>
</box>
</Show>
<Show when={status().length > 0}>
<Show when={props.showDetails && status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live()}> · </Show>
{status().join(" · ")}
@@ -77,14 +82,14 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
</Show>
</box>
</Match>
<Match when={dimensions().width >= 44}>
<Match when={props.showDetails && dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
<Show when={props.showDetails}>
<text fg={props.context.theme.text.default} wrapMode="none" flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Show>
@@ -106,7 +111,14 @@ export default Plugin.define({
setup(context) {
context.ui.slot({
append: "prompt.footer",
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
render: (props) => (
<PromptFooter
context={context}
sessionID={props.sessionID}
mode={props.mode}
showDetails={props.showDetails}
/>
),
})
},
})
@@ -186,9 +186,11 @@ export function PluginsDialog(props: {
>
{(entry) => (
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin error`}
source={pluginSource(entry(), props.context)}
error={pluginError(entry()) ?? "Unknown plugin error"}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
diagnosticRef={pluginErrorRef(entry())}
context={`Plugin: ${label(entry(), props.context)}\nStatus: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -241,11 +243,14 @@ function displayVersion(version: string) {
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server")
return entry.plugin.state.status === "failed" ? entry.plugin.state.error : undefined
if (entry?.runtime === "server") return entry.plugin.state.status === "failed" ? entry.plugin.state.error : undefined
return entry?.error
}
function pluginErrorRef(entry: Entry) {
if (entry.runtime === "server" && entry.plugin.state.status === "failed") return entry.plugin.state.ref
}
function Commands(props: { context: Plugin.Context }) {
const plugins = usePlugin()
props.context.keymap.layer(() => ({
@@ -0,0 +1,18 @@
import type { SessionStatsInfo } from "@opencode-ai/client"
import { TokenUsage } from "@opencode-ai/schema/token-usage"
export function statsMetrics(stats: SessionStatsInfo) {
return [
{
label: "tokens",
value: TokenUsage.total(stats.tokens),
},
{ label: "best streak", value: stats.streak },
{ label: "active days", value: stats.activeDays },
{ label: "sessions", value: stats.sessions },
]
}
export function statsNumber(value: number) {
return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value)
}
@@ -0,0 +1,218 @@
import type { SessionStatsInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { Logo } from "../../component/logo"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
import { statsMetrics, statsNumber } from "./stats-data"
const digits: Record<string, string[]> = {
"0": ["111", "101", "101", "101", "111"],
"1": ["010", "110", "010", "010", "111"],
"2": ["111", "001", "111", "100", "111"],
"3": ["111", "001", "111", "001", "111"],
"4": ["101", "101", "111", "001", "001"],
"5": ["111", "100", "111", "001", "111"],
"6": ["111", "100", "111", "101", "111"],
"7": ["111", "001", "010", "010", "010"],
"8": ["111", "101", "111", "101", "111"],
"9": ["111", "101", "111", "001", "111"],
".": ["0", "0", "0", "0", "1"],
K: ["101", "110", "100", "110", "101"],
M: ["10001", "11011", "10101", "10001", "10001"],
B: ["110", "101", "110", "101", "110"],
T: ["111", "010", "010", "010", "010"],
}
export function StatsPoster(props: { stats: SessionStatsInfo }) {
const dimensions = useTerminalDimensions()
const theme = useTheme()
const themes = useThemes()
const width = () => Math.max(12, Math.min(110, dimensions().width - 8))
const compact = () => dimensions().height < 38
const metrics = createMemo(() => statsMetrics(props.stats))
const number = () => statsNumber(metrics()[0].value)
const dates = createMemo(() =>
new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric" }).formatRange(
new Date(props.stats.range.from),
new Date(Math.max(props.stats.range.from, props.stats.range.to - 1)),
),
)
const letters = createMemo(() => Array.from(number()).map((char) => digits[char]))
const large = () =>
letters().every(Boolean) && letters().reduce((sum, char) => sum + char[0].length * 2 + 2, -2) <= width()
const calendar = createMemo(() =>
activityCalendar({
activity: props.stats.activity,
from: props.stats.range.from,
to: props.stats.range.to,
maxWeeks: Math.floor((width() - 4) / 2),
}),
)
const shades = createMemo(() => [
theme.text.subdued,
...[0.3, 0.5, 0.75, 1].map((alpha) =>
tint(theme.background.default, theme.categorical[0][themes.mode() === "light" ? 800 : 200], alpha),
),
])
return (
<box width={width()} flexDirection="column" alignItems="center" flexShrink={0} gap={compact() ? 1 : 2}>
<box width="100%" flexDirection={width() < 44 ? "column" : "row"} justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
opencode / stats
</text>
<text fg={theme.text.subdued}>{dates()}</text>
</box>
<Show when={!compact()}>
<Logo />
</Show>
<box alignItems="center" gap={1}>
<Show
when={large()}
fallback={
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{number()}
</text>
}
>
<box>
<For each={[0, 1, 2, 3, 4]}>
{(row) => (
<text fg={theme.text.default} selectable={false}>
{letters()
.map((char) => char[row].replaceAll("1", "\u2588\u2588").replaceAll("0", " "))
.join(" ")}
</text>
)}
</For>
</box>
</Show>
<text fg={theme.text.subdued}>TOKENS</text>
</box>
<box alignItems="center">
<text fg={theme.text.subdued}>
{" " +
calendar()
.months.map((month) => (month.label.length <= month.span * 2 ? month.label : "").padEnd(month.span * 2))
.join("")}
</text>
<For each={["M", "T", "W", "T", "F", "S", "S"]}>
{(day, index) => (
<box flexDirection="row" height={1}>
<text fg={theme.text.subdued}>{day + " "}</text>
<For each={calendar().weeks}>
{(week) => (
<text fg={shades()[Math.max(0, week[index()].level)]} selectable={false}>
{week[index()].level < 0 ? " " : week[index()].level === 0 ? "\u00b7 " : "\u25a0 "}
</text>
)}
</For>
</box>
)}
</For>
<Show when={calendar().clipped}>
<text fg={theme.text.subdued}>Your last {calendar().weeks.length} weeks</text>
</Show>
</box>
<box width="100%" flexDirection="row" justifyContent="space-around">
<For each={metrics().slice(1)}>
{(metric) => (
<box alignItems="center">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{statsNumber(metric.value)}
{metric.label === "best streak" ? " days" : ""}
</text>
<text fg={theme.text.subdued}>{metric.label}</text>
</box>
)}
</For>
</box>
<box width="100%" flexDirection="row" justifyContent="flex-end">
<text fg={theme.text.default}>opencode.ai</text>
</box>
</box>
)
}
function StatsPage(props: { context: Plugin.Context; onClose: () => void }) {
const [result] = createResource(() => {
const now = new Date()
return props.context.client.session.stats({
from: new Date(now.getFullYear(), 0, 1).getTime(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
tools: "none",
})
})
const theme = useTheme()
props.context.keymap.layer(() => ({
commands: [{ bind: "escape", title: "back", run: props.onClose }],
}))
return (
<box width="100%" height="100%" backgroundColor={theme.background.default}>
<scrollbox
flexGrow={1}
contentOptions={{
alignItems: "center",
justifyContent: "center",
minHeight: "100%",
paddingTop: 1,
paddingBottom: 1,
}}
>
<Show
when={!result.error}
fallback={
<text fg={theme.text.feedback.error.default}>Could not load stats. Reopen /stats to try again.</text>
}
>
<Show when={result()} fallback={<text fg={theme.text.subdued}>Gathering your stats...</text>}>
{(value) => <StatsPoster stats={value()} />}
</Show>
</Show>
</scrollbox>
</box>
)
}
export default Plugin.define({
id: "opencode.stats",
setup(context) {
const [previous, setPrevious] = createSignal({ ...context.ui.router.current() })
context.ui.router.register({
name: "stats",
render: () => <StatsPage context={context} onClose={() => context.ui.router.navigate(previous())} />,
})
context.ui.slot({
append: "app",
render() {
context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "stats.open",
title: "Usage statistics",
group: "System",
slash: { name: "stats" },
palette: true,
run() {
const current = context.ui.router.current()
if (current.type === "plugin" && current.name === "stats") return
// The router exposes a mutable store; retain the route before navigating.
setPrevious({ ...current })
context.ui.dialog.clear()
context.ui.router.navigate({ type: "plugin", name: "stats" })
},
},
],
}))
return null
},
})
},
})
+2
View File
@@ -7,6 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
import Plugins from "../feature-plugins/system/plugins"
import Storybook from "../feature-plugins/system/storybook"
import Stats from "../feature-plugins/system/stats"
import Latex from "@opencode-ai/latex/plugin"
import Merman from "@opencode-ai/merman/plugin"
@@ -18,6 +19,7 @@ export const builtins = [
SidebarFooter,
Notifications,
Plugins,
Stats,
Merman,
Latex,
// The storybook is a development tool; keep its route and palette commands out of
+15 -4
View File
@@ -3087,11 +3087,13 @@ function StatusBadge(props: { children: string }) {
type BlockToolProps = {
title?: string
path?: { label: string; value: string }
headerColor?: RGBA
children?: JSX.Element
onClick?: () => void
part?: SessionMessageAssistantTool
spinner?: boolean
error?: string
errorColor?: RGBA
}
function BlockTool(props: BlockToolProps) {
@@ -3137,7 +3139,11 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>{title()}</text>
<text
fg={permission() ? theme.text.feedback.warning.default : (props.headerColor ?? theme.text.subdued)}
>
{title()}
</text>
}
>
<Spinner color={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
@@ -3153,7 +3159,10 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text flexShrink={0} fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
<text
flexShrink={0}
fg={permission() ? theme.text.feedback.warning.default : (props.headerColor ?? theme.text.subdued)}
>
{path().label}
</text>
}
@@ -3165,14 +3174,14 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<FilePath
value={path().value}
maxWidth={Math.max(2, ctx.width - 4 - stringWidth(path().label) - (props.spinner ? 2 : 0))}
fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}
fg={permission() ? theme.text.feedback.warning.default : (props.headerColor ?? theme.text.subdued)}
/>
</box>
)}
</Show>
{props.children}
<Show when={error()}>
<text fg={theme.text.feedback.error.default}>{error()}</text>
<text fg={props.errorColor ?? theme.text.feedback.error.default}>{error()}</text>
</Show>
</box>
)
@@ -3777,6 +3786,8 @@ function ApplyPatch(props: ToolProps) {
}
part={props.part}
spinner={props.part.state.status === "streaming" || props.part.state.status === "running"}
headerColor={props.part.state.status === "error" ? theme.text.feedback.error.default : undefined}
errorColor={props.part.state.status === "error" ? theme.text.subdued : undefined}
/>
</Match>
</Switch>
+21
View File
@@ -0,0 +1,21 @@
export function errorDetails(input: { title: string; error: string; context?: string; diagnosticRef?: string }) {
const text = [
input.title,
...(input.context ? [input.context] : []),
`Error: ${input.error}`,
...(input.diagnosticRef ? [`Reference: ${input.diagnosticRef}`] : []),
].join("\n")
return {
text,
prompt: [
"Investigate why this OpenCode component failed in the current project.",
text,
...(input.diagnosticRef
? [
`Find the server log entry matching reference ${input.diagnosticRef} and inspect its original cause. If the server logs are not accessible from this session, say so and ask for the matching log entry; do not infer the cause from the reference alone.`,
]
: []),
"Inspect the relevant project and global OpenCode configuration, startup or loading behavior, required environment variables or credentials, dependencies, and logs. Identify the root cause and recommend a fix. Do not expose credentials.",
].join("\n\n"),
}
}
+1 -61
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { EmbeddedTerminalRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -921,66 +921,6 @@ test("error investigations repeatedly seed editable home drafts without creating
await setup.waitForFrame((frame) => !frame.includes("Beta initialization failed"))
})
test("shows jump to latest after scrolling one line above the final message", async () => {
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const messages = Array.from({ length: 8 }, (_, index) => ({
id: `message-${index}`,
type: "user",
text: index === 7 ? "Final visible message" : `Earlier message ${index}`,
time: { created: index },
}))
await using setup = await createAppFixture({
width: 80,
height: 20,
config: { animations: false, keybinds: { "session.line.up": "f6", "session.line.down": "f7" } },
args: { sessionID: "dummy" },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: messages.toReversed(), cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
},
})
await setup.waitForFrame((frame) => frame.includes("Final visible message"))
const findScrollBox = (root: Renderable): ScrollBoxRenderable | undefined =>
root instanceof ScrollBoxRenderable && root.getRenderable("message-7")
? root
: root.getChildren().map(findScrollBox).find(Boolean)
const scroll = findScrollBox(setup.renderer.root)
expect(scroll).toBeDefined()
if (!scroll) throw new Error("session transcript scrollbox was not found")
const maximum = () => Math.max(0, scroll.scrollHeight - scroll.viewport.height)
expect(scroll.scrollTop).toBe(maximum())
const initial = setup.captureCharFrame().split("\n")
expect(initial.find((line) => line.includes("Jump to latest"))).toBeUndefined()
expect(initial[initial.findIndex((line) => line.includes("Final visible message")) + 1]).toContain("┃")
setup.mockInput.pressKey("F6")
const clipped = (await setup.waitForFrame((frame) => frame.includes("Jump to latest"))).split("\n")
expect(scroll.scrollTop).toBe(maximum() - 1)
expect(clipped.find((line) => line.includes("Jump to latest"))).toBeDefined()
expect(clipped[clipped.findIndex((line) => line.includes("Final visible message")) + 1]).not.toContain("┃")
setup.mockInput.pressKey("F7")
const restored = (await setup.waitForFrame((frame) => !frame.includes("Jump to latest"))).split("\n")
expect(scroll.scrollTop).toBe(maximum())
expect(restored.find((line) => line.includes("Jump to latest"))).toBeUndefined()
expect(restored[restored.findIndex((line) => line.includes("Final visible message")) + 1]).toContain("┃")
})
test("completed user shell output replaces a partial live read when the final read fails", async () => {
await using state = await tmpdir()
const session = {
@@ -0,0 +1,110 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { DialogErrorDetails } from "../../src/component/dialog-error-details"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { Keymap } from "../../src/context/keymap"
import { LocationProvider } from "../../src/context/location"
import { RouteProvider, useRoute } from "../../src/context/route"
import { ThemeProvider } from "../../src/context/theme"
import { DialogProvider, useDialog } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
for (const width of [40, 100]) {
test(`error details stay compact at ${width} columns and investigate only prepares a draft`, async () => {
await using temporary = await tmpdir()
const source = "/private/var/folders/very-long-temporary-directory/opencode/project/plugins/broken/index.ts"
const copied: string[] = []
const submitted: string[] = []
let route: ReturnType<typeof useRoute> | undefined
const api = createApi(
createFetch((url, request) => {
if (request.method === "POST") submitted.push(url.pathname)
return undefined
}, createEventStream()).fetch,
)
function OpenDialog() {
route = useRoute()
const dialog = useDialog()
onMount(() =>
dialog.replace(
<DialogErrorDetails
title="Server plugin error"
source={source}
error="Plugin failed to load"
context={`Plugin: broken\nRuntime: server\nSource: ${source}`}
diagnosticRef="err_a1b2c3d4"
onBack={() => dialog.clear()}
/>,
),
)
return null
}
const app = await testRender(
() => (
<TestTuiContexts
directory={temporary.path}
paths={{ state: temporary.path }}
clipboard={{ read: async () => undefined, write: async (text) => void copied.push(text) }}
>
<ConfigProvider config={createTuiResolvedConfig()}>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={api}>
<DataProvider directory={temporary.path}>
<LocationProvider>
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<OpenDialog />
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 24, kittyKeyboard: true },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Reference: err_a1b2c3d4"))
const lines = app.captureCharFrame().split("\n")
const heading = lines.find((line) => line.includes("Server plugin error"))
expect(heading).toContain("esc")
expect(lines.some((line) => line.includes("broken/index.ts"))).toBe(true)
expect(lines.join("\n")).not.toContain("very-long-temporary-directory")
const message = lines.find((line) => line.includes("Plugin failed to load"))
const reference = lines.find((line) => line.includes("Reference:"))
expect(message?.indexOf("Plugin")).toBe(reference?.indexOf("Reference:"))
expect(lines.filter((line) => line.trim()).length).toBeLessThanOrEqual(6)
app.mockInput.pressKey("c")
await app.waitFor(() => copied.length === 1)
expect(copied[0]).toContain(source)
expect(copied[0]).toContain("Reference: err_a1b2c3d4")
app.mockInput.pressKey("i")
await app.waitFor(() => route?.data.type === "home" && !!route.data.prompt)
const current = route?.data
expect(current?.type === "home" && current.prompt?.text).toContain(source)
expect(current?.type === "home" && current.prompt?.text).toContain("matching reference err_a1b2c3d4")
expect(submitted).toEqual([])
} finally {
app.renderer.destroy()
}
})
}
@@ -4,6 +4,7 @@ import { MouseButton } from "@opentui/core"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { Keymap } from "../../src/context/keymap"
import { ThemeProvider } from "../../src/context/theme"
@@ -11,6 +12,7 @@ import { DialogProvider } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createApi, createFetch } from "../fixture/tui-client"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
test("releasing a transcript selection over tab controls does not activate them", async () => {
@@ -68,6 +70,7 @@ test("releasing a transcript selection over tab controls does not activate them"
test("the horizontal tab context menu keeps preview tabs open without selecting them", async () => {
const [active, setActive] = createSignal("first")
const promoted: string[] = []
const calls = createFetch()
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
@@ -86,13 +89,15 @@ test("the horizontal tab context menu keeps preview tabs open without selecting
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<SessionTabs controller={controller} animations={false} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
<ClientProvider api={createApi(calls.fetch)}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<SessionTabs controller={controller} animations={false} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
@@ -113,8 +118,15 @@ test("the horizontal tab context menu keeps preview tabs open without selecting
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
const rename = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Rename"))
await app.mockMouse.click(app.captureCharFrame().split("\n")[rename]!.indexOf("Rename"), rename)
await app.waitForFrame((frame) => frame.includes("Rename session"))
app.mockInput.pressKey("ESCAPE")
await app.waitForFrame((frame) => !frame.includes("Rename session"))
const second = app
.captureCharFrame()
@@ -0,0 +1,205 @@
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { createData } from "@opencode-ai/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createRoot, createSignal } from "solid-js"
import { createSessionRetention } from "../../src/context/session-retention"
import { createApi, createFetch, directory, json } from "../fixture/tui-client"
function setup(options: { keep?: string[]; current?: string; limit?: number } = {}) {
return createRoot((dispose) => {
const events = createGlobalEmitter<{
[Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }>
}>()
const api = createApi(
createFetch((url) => {
if (url.pathname.endsWith("/message"))
return json({
data: [{ id: "msg_test", type: "user", text: "Transcript", time: { created: 1 } }],
cursor: {},
})
if (url.pathname.endsWith("/inbox")) return json({ data: [] })
return undefined
}).fetch,
)
const data = createData({ api: () => api, event: events, directory })
const [current, setCurrent] = createSignal(options.current)
const [keep, setKeep] = createSignal(options.keep ?? [])
const evictions: string[] = []
createSessionRetention({
session: {
...data.session,
evict(id) {
evictions.push(id)
data.session.evict(id)
},
},
current,
keep,
limit: options.limit ?? 3,
})
return {
dispose,
data,
events,
evictions,
setCurrent,
setKeep,
remember(id: string, parentID?: string, updated = 0) {
data.session.remember({
id,
parentID,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated },
})
},
async view(id: string) {
setCurrent(id)
await data.session.message.sync(id)
},
cached: (id: string) => data.session.message.list(id).length > 0,
}
})
}
test("retains three recently viewed families and child views touch their root", async () => {
const scope = setup()
try {
for (const id of ["a", "b", "c", "d", "e"]) scope.remember(id)
scope.remember("child", "a")
scope.remember("grandchild", "child")
await scope.view("a")
await scope.view("grandchild")
await scope.view("b")
await scope.view("c")
await scope.view("child")
await scope.view("d")
expect(["a", "child", "grandchild", "c", "d"].every(scope.cached)).toBe(true)
expect(scope.cached("b")).toBe(false)
scope.setCurrent(undefined)
expect(scope.cached("a")).toBe(true)
await scope.view("e")
expect(scope.cached("c")).toBe(false)
await scope.view("c")
expect(["a", "child", "grandchild"].some(scope.cached)).toBe(false)
expect(["c", "d", "e"].every(scope.cached)).toBe(true)
expect(scope.data.session.family("a").toSorted()).toEqual(["a", "child", "grandchild"])
} finally {
scope.dispose()
}
})
test("explicitly kept families are exempt from the recent-family budget", async () => {
const scope = setup({ keep: ["a", "b", "c", "d"] })
try {
for (const id of ["a", "b", "c", "d", "e", "f", "g", "h"]) {
scope.remember(id)
await scope.view(id)
}
expect(["a", "b", "c", "d", "f", "g", "h"].every(scope.cached)).toBe(true)
expect(scope.cached("e")).toBe(false)
scope.setKeep(["b", "c", "d"])
expect(scope.cached("a")).toBe(false)
await scope.view("b")
scope.setKeep(["c", "d"])
expect(scope.cached("b")).toBe(true)
expect(scope.cached("f")).toBe(false)
scope.setKeep([])
expect(["c", "d"].some(scope.cached)).toBe(false)
expect(["b", "g", "h"].every(scope.cached)).toBe(true)
} finally {
scope.dispose()
}
})
test("background metadata changes do not refresh view recency or repeat eviction", async () => {
const scope = setup()
try {
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
scope.remember("a", undefined, 100)
scope.remember("d")
await scope.view("d")
expect(scope.cached("a")).toBe(false)
expect(["b", "c", "d"].every(scope.cached)).toBe(true)
const count = scope.evictions.length
scope.remember("a", undefined, 200)
scope.remember("d", undefined, 300)
expect(scope.evictions.length).toBe(count)
} finally {
scope.dispose()
}
})
test("unviewed families and newly discovered descendants are evicted", async () => {
const scope = setup({ current: "current" })
try {
await scope.data.session.message.sync("background")
expect(scope.cached("background")).toBe(true)
scope.remember("background")
expect(scope.cached("background")).toBe(false)
await scope.data.session.message.sync("child")
scope.remember("child", "background")
expect(scope.cached("child")).toBe(false)
expect(scope.evictions.filter((id) => id === "background")).toHaveLength(2)
scope.remember("current")
scope.remember("kept-child", "current")
await scope.data.session.message.sync("kept-child")
scope.remember("kept-child", "current", 10)
expect(scope.cached("kept-child")).toBe(true)
expect(
scope.data.session
.list()
.map((session) => session.id)
.toSorted(),
).toEqual(["background", "child", "current", "kept-child"])
} finally {
scope.dispose()
}
})
test("current child protects late-arriving ancestry and reopening restores an evicted transcript", async () => {
const scope = setup({ current: "child" })
try {
scope.remember("root")
await scope.data.session.message.sync("child")
scope.remember("child", "parent")
scope.remember("parent", "root")
scope.remember("root")
expect(scope.cached("child")).toBe(true)
expect(scope.evictions).toEqual(["root"])
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
expect(scope.cached("child")).toBe(false)
await scope.view("child")
expect(scope.cached("child")).toBe(true)
expect(scope.cached("a")).toBe(false)
} finally {
scope.dispose()
}
})
test("uses the caller's recent-family limit", async () => {
const scope = setup({ keep: ["a"], limit: 1 })
try {
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
expect(scope.cached("a")).toBe(true)
expect(scope.cached("b")).toBe(false)
expect(scope.cached("c")).toBe(true)
} finally {
scope.dispose()
}
})
+27 -23
View File
@@ -42,7 +42,7 @@ async function renderSessionTabs(
launchDirectory?: string
tabsEnabled?: boolean
viewFailures?: number
preview?: boolean
experimental?: Record<string, boolean>
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -136,7 +136,7 @@ async function renderSessionTabs(
let config!: ReturnType<typeof useConfig>
let configuration = {
tabs: { enabled: options?.tabsEnabled ?? true },
experimental: options?.preview ? { "session-preview-tabs": true } : undefined,
experimental: options?.experimental,
session: { new_location: options?.newLocation ?? "launch" },
}
@@ -202,10 +202,10 @@ async function renderSessionTabs(
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
flush: () => storage.flush(),
setPreviews: (enabled: boolean) =>
setTabsEnabled: (enabled: boolean) =>
config.update((draft) => {
draft.experimental ??= {}
draft.experimental["session-preview-tabs"] = enabled
draft.tabs ??= {}
draft.tabs.enabled = enabled
}),
async destroy() {
app.renderer.destroy()
@@ -291,8 +291,11 @@ test("loads location metadata when an open session moves", async () => {
}
})
test("replaces session previews without replacing permanent tabs or opening existing tabs again", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first", "permanent"], preview: true })
test.each([undefined, true, false])("replaces previews with legacy experiment setting %s", async (preview) => {
const setup = await renderSessionTabs("first", {
persisted: ["first", "permanent"],
experimental: preview === undefined ? undefined : { "session-preview-tabs": preview },
})
try {
await wait(() => setup.tabs.tabs().length === 2)
@@ -330,7 +333,7 @@ test("replaces session previews without replacing permanent tabs or opening exis
})
test("server-wide prompt admissions do not promote a local session preview", async () => {
const setup = await renderSessionTabs("preview", { preview: true })
const setup = await renderSessionTabs("preview")
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview") && setup.tabs.isPreview("preview"))
@@ -360,7 +363,7 @@ test("server-wide prompt admissions do not promote a local session preview", asy
})
test("promotes a local preview before its tab has finished persisting", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
@@ -382,7 +385,7 @@ test("promotes a local preview before its tab has finished persisting", async ()
})
test("reopens a previously permanent session as a preview after a user prompt", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
@@ -401,7 +404,7 @@ test("reopens a previously permanent session as a preview after a user prompt",
})
test("keeps an explicitly promoted home session permanent when admission arrives before navigation", async () => {
const setup = await renderSessionTabs("created", { home: true, preview: true })
const setup = await renderSessionTabs("created", { home: true })
try {
setup.tabs.promote("created")
@@ -416,7 +419,7 @@ test("keeps an explicitly promoted home session permanent when admission arrives
})
test("stores preview tab membership without persisting preview identity", async () => {
const setup = await renderSessionTabs("preview", { preview: true })
const setup = await renderSessionTabs("preview")
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview") && setup.tabs.isPreview("preview"))
@@ -433,7 +436,7 @@ test("stores preview tab membership without persisting preview identity", async
})
test("unrelated user admissions do not pre-promote an unopened local session", async () => {
const setup = await renderSessionTabs("remote", { home: true, preview: true })
const setup = await renderSessionTabs("remote", { home: true })
try {
setup.emit(admitted("remote", "msg_1"))
@@ -453,11 +456,11 @@ test("each client replaces only its own preview in shared tab storage", async ()
const clients: Awaited<ReturnType<typeof renderSessionTabs>>[] = []
try {
const first = await renderSessionTabs("first", { state: temporary.path, preview: true })
const first = await renderSessionTabs("first", { state: temporary.path })
clients.push(first)
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "first"))
const second = await renderSessionTabs("first", { state: temporary.path, preview: true })
const second = await renderSessionTabs("first", { state: temporary.path })
clients.push(second)
expect(second.tabs.isPreview("first")).toBe(false)
@@ -478,7 +481,7 @@ test("each client replaces only its own preview in shared tab storage", async ()
})
test("reopening a closed preview makes it permanent without replacing the current preview", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
@@ -504,7 +507,7 @@ test("reopening a closed preview makes it permanent without replacing the curren
})
test("moving a preview promotes it before opening another preview", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first", "last"], preview: true })
const setup = await renderSessionTabs("first", { persisted: ["first", "last"] })
try {
await wait(() => setup.tabs.tabs().length === 2)
@@ -525,16 +528,16 @@ test("moving a preview promotes it before opening another preview", async () =>
}
})
test("disabling previews clears local identity and prevents stale replacement after re-enabling", async () => {
const setup = await renderSessionTabs("first", { preview: true })
test("disabling tabs clears local preview identity and prevents stale replacement after re-enabling", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first") && setup.tabs.isPreview("first"))
await setup.setPreviews(false)
await setup.setTabsEnabled(false)
expect(setup.tabs.isPreview("first")).toBe(false)
await setup.setPreviews(true)
await setup.setTabsEnabled(true)
expect(setup.tabs.isPreview("first")).toBe(false)
setup.route.navigate({ type: "session", sessionID: "next" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "next"))
@@ -896,7 +899,7 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn
})
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const setup = await renderSessionTabs("background", { persisted: ["background"] })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
@@ -933,7 +936,7 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
})
test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first")
const setup = await renderSessionTabs("first", { persisted: ["first"] })
try {
await wait(() => setup.tabs.current() === "first")
@@ -952,6 +955,7 @@ test("tracks a temporary new session tab across close and creation", async () =>
setup.route.navigate({ type: "home" })
await wait(() => setup.tabs.newTab())
setup.tabs.promote("third")
setup.route.navigate({ type: "session", sessionID: "third" })
expect(setup.tabs.newTab()).toBe(true)
await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
@@ -3,6 +3,7 @@ import { expect, test } from "bun:test"
import { RGBA, TextRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { createSignal } from "solid-js"
import { PromptFooter } from "../../src/feature-plugins/prompt/footer"
test("prompt footer separates simultaneous subagent, shell, and usage status", async () => {
@@ -38,10 +39,13 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
},
},
} as unknown as Context
const app = await testRender(() => <PromptFooter context={context} sessionID="session" mode="normal" />, {
width: 80,
height: 2,
})
const app = await testRender(
() => <PromptFooter context={context} sessionID="session" mode="normal" showDetails={true} />,
{
width: 80,
height: 2,
},
)
try {
await app.renderOnce()
@@ -59,3 +63,82 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
app.renderer.destroy()
}
})
test("prompt footer can hide details", async () => {
const color = RGBA.fromInts(200, 200, 200)
const context = {
location: { directory: "/workspace" },
theme: {
text: {
default: color,
subdued: color,
},
},
keymap: {
shortcuts: (id: string) => {
if (id === "command.palette.show") return ["ctrl+p"]
if (id === "agent.cycle") return ["shift+tab"]
return []
},
},
data: {
session: {
family: () => ["session"],
status: () => "running",
get: () => ({ id: "session", location: { directory: "/workspace" } }),
cost: () => 1,
message: {
list: () => [
{
id: "message",
type: "assistant",
model: { providerID: "provider", id: "model" },
tokens: { input: 1_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
],
},
},
shell: { list: () => [] },
location: {
model: { list: () => [{ providerID: "provider", id: "model", limit: { context: 10_000 } }] },
},
},
} as unknown as Context
const [showDetails, setShowDetails] = createSignal(true)
const [sessionID, setSessionID] = createSignal<string | undefined>("session")
const app = await testRender(
() => (
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
<PromptFooter
context={context}
sessionID={sessionID()}
mode="normal"
showDetails={showDetails()}
/>
</box>
),
{
width: 80,
height: 1,
},
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("1.0K (10%) · $1.00")
expect(app.captureCharFrame()).toContain("ctrl+p commands")
setShowDetails(false)
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).not.toContain("1.0K (10%)")
expect(frame).not.toContain("$1.00")
expect(frame).not.toContain("ctrl+p commands")
setSessionID(undefined)
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("shift+tab agents")
} finally {
app.renderer.destroy()
}
})
+5 -3
View File
@@ -2,7 +2,7 @@ import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import { mkdir, readFile, symlink, utimes, writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
@@ -367,8 +367,10 @@ export default {
)
// Duplicate notifications for unchanged contents must not retry the broken
// generation and cycle the restored plugin again.
await writeFile(source, broken)
// generation and cycle the restored plugin again. Rewriting the file can
// expose truncated contents to the watcher, so change only its metadata.
const touched = new Date(Date.now() + 1000)
await utimes(source, touched, touched)
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
expect(await read()).toBe("a1:setup\na1:cleanup\na1:setup\n")
+8 -8
View File
@@ -29,7 +29,7 @@ test.each([
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "dummy",
id: "ses_test",
title: "Long history",
projectID: "project",
location: { directory },
@@ -63,8 +63,8 @@ test.each([
const events = createEventStream()
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") {
if (url.pathname === "/api/session/ses_test") return json({ data: session })
if (url.pathname === "/api/session/ses_test/message") {
const end = Number(url.searchParams.get("cursor") ?? messages.length)
const limit = Number(url.searchParams.get("limit"))
const start = Math.max(0, end - limit)
@@ -79,8 +79,8 @@ test.each([
if (mode === "prepend-failure" && limit === 200) return json({ message: "offline" }, { status: 503 })
return json({ data: messages.slice(start, end).toReversed(), cursor: end ? { next: String(start) } : {} })
}
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/session/ses_test/inbox") return json({ data: [] })
if (url.pathname === "/api/session/ses_test/permission") return json({ data: [] })
return undefined
}, events)
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
@@ -105,7 +105,7 @@ test.each([
},
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
args: { sessionID: "ses_test" },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
@@ -279,9 +279,9 @@ test.each([
id: "evt_live",
created: 400,
type: "session.inbox.enqueued",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: {
sessionID: "dummy",
sessionID: "ses_test",
inboxID: "message-live",
item: { type: "user", payload: { text: "Live message after failure" }, delivery: "steer" },
},

Some files were not shown because too many files have changed in this diff Show More