Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 911a3998de fix(core): resolve coherent state before OAuth refresh 2026-09-01 10:37:44 -04:00
Kit Langton b9cd9e3e3c fix(core): refresh OAuth credentials during batched activation 2026-09-01 10:22:31 -04:00
113 changed files with 1138 additions and 3148 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`.
- 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.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
+13 -1
View File
@@ -3213,6 +3213,8 @@
"@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=="],
@@ -5451,6 +5453,8 @@
"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=="],
@@ -5665,6 +5669,8 @@
"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=="],
@@ -5813,7 +5819,9 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"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=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -5845,6 +5853,8 @@
"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=="],
@@ -6599,6 +6609,8 @@
"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,24 +199,6 @@ 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: {
@@ -1,14 +0,0 @@
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
}),
)
@@ -1,101 +0,0 @@
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,9 +8,7 @@ import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -24,23 +22,15 @@ 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,
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
[
...(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 })),
],
input.builtin,
)
if (!output) {
@@ -53,50 +43,37 @@ export default Runtime.handler(
export function format(
plugins: readonly PluginInfo[],
tui: ReadonlyArray<{ readonly target: string; readonly version?: string }>,
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
builtin = false,
) {
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.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))),
),
)
.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]
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
.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)
.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
}
@@ -1,44 +0,0 @@
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
}
}),
)
+84 -19
View File
@@ -1,7 +1,5 @@
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"
@@ -93,7 +91,7 @@ type RenderOptions = {
const colors = terminalPalette()
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
const totalTokens = TokenUsage.total(stats.tokens)
const totalTokens = tokenTotal(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
@@ -169,23 +167,43 @@ function renderActivity(
color: boolean,
width: number,
) {
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 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 weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
return [
style(
calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity",
`1;${colors.primary}`,
color,
),
` ${style(months, "2", color)}`,
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
` ${style(monthLabels(weekStarts), "2", color)}`,
...weekdays.flatMap((label, day) => [
`${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`,
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
...(day === weekdays.length - 1 ? [] : [""]),
]),
"",
@@ -220,7 +238,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
width,
),
` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
]),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
@@ -230,7 +248,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(TokenUsage.total(item.tokens)),
formatNumber(tokenTotal(item.tokens)),
formatNumber(item.steps),
`$${item.cost.toFixed(2)}`,
),
@@ -292,6 +310,10 @@ 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`
@@ -320,6 +342,13 @@ 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
@@ -341,3 +370,39 @@ 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,8 +42,6 @@ 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
@@ -1,18 +0,0 @@
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")
})
+21 -42
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("lists plugin IDs, installed versions, and sources without runtime sections", () => {
test("formats server and TUI plugins in sections without builtins", () => {
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", version: "1.0.0" },
source: { type: "package", target: "acme-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
@@ -26,22 +26,24 @@ test("lists plugin IDs, installed versions, and sources without runtime sections
},
],
[
{ 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" },
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
],
)
.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"],
])
),
).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),
)
})
test("includes builtins when requested", () => {
@@ -57,29 +59,6 @@ test("includes builtins when requested", () => {
],
[],
true,
)
.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("")
),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
-14
View File
@@ -48,20 +48,6 @@ 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,13 +88,6 @@ 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
@@ -104,7 +97,6 @@ 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>
}
@@ -266,7 +258,6 @@ 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,8 +15,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
@@ -326,13 +324,6 @@ 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(
@@ -342,7 +333,6 @@ const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: Plugin
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
})
@@ -485,7 +475,6 @@ 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,12 +103,16 @@ 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.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)
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)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -141,12 +145,8 @@ 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,8 +9,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
@@ -470,19 +468,6 @@ 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>(
{
@@ -688,7 +673,6 @@ export function make(options: ClientOptions) {
metadata: input["metadata"],
delivery: input["delivery"],
resume: input["resume"],
callbackUrl: input["callbackUrl"],
},
successStatus: 200,
declaredStatuses: [409, 400, 404, 401],
+1 -43
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; ref?: string }
export type PluginState = { status: "active" } | { status: "failed"; error: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -2585,18 +2585,6 @@ 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
@@ -3731,7 +3719,6 @@ 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
@@ -3753,7 +3740,6 @@ 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
@@ -3775,7 +3761,6 @@ 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
@@ -3797,7 +3782,6 @@ 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
@@ -3819,7 +3803,6 @@ 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
@@ -3841,7 +3824,6 @@ 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
@@ -3863,7 +3845,6 @@ 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
@@ -3885,30 +3866,7 @@ 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,12 +84,14 @@ 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")
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)
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)
lastSpawn = 0
}
} else {
@@ -117,10 +119,8 @@ 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,8 +38,6 @@ 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. */
+15 -53
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 && !entry.invalidated) state.set(key, true)
if (state.get(key) === entry) state.set(key, true)
})
.finally(() => {
if (state.get(key) === entry) state.delete(key)
@@ -493,33 +493,6 @@ 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))
@@ -1287,11 +1260,9 @@ export function createData(config: CreateDataInput) {
return creating.has(sessionID)
},
remember(info: SessionInfo) {
batch(() => {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
})
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
},
setStatus(sessionID: string, status: DataSessionStatus) {
setSessionActive(sessionID, status)
@@ -1302,13 +1273,6 @@ 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
@@ -1520,19 +1484,17 @@ export function createData(config: CreateDataInput) {
: [],
])
const sessions = [info, ...children]
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)
}
})
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,7 +804,6 @@ 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({
@@ -857,7 +856,6 @@ 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
@@ -1,177 +0,0 @@
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.awaitActivation
yield* plugins.flush
yield* reconcile(policy.current())
}).pipe(
Effect.catchCauseIf(
+6 -3
View File
@@ -676,9 +676,12 @@ const layer = Layer.effect(
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
// Plugin activation batches registrations: a plugin resolving during its own
// setup must see the refresh implementation it just registered, or an expired
// credential is silently returned without refreshing.
const current = yield* state.resolve()
const implementation = current.integrations
.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
+6 -19
View File
@@ -26,11 +26,6 @@ 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 },
@@ -44,21 +39,13 @@ 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* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
if (!entrypoint) return yield* Effect.fail(new Error(`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).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 value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
@@ -90,11 +77,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 } : {}),
+118 -3
View File
@@ -1,3 +1,6 @@
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"
@@ -17,6 +20,9 @@ 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,
@@ -24,6 +30,20 @@ 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)
@@ -40,7 +60,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 } = {}
const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {}
const command = (args: string[]) =>
processes
@@ -66,7 +86,28 @@ export const AzurePlugin = define({
})
const available = Boolean(which("az"))
const form = () =>
// 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) =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
@@ -76,6 +117,16 @@ 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,
}
: {}),
},
])
})
@@ -92,7 +143,7 @@ export const AzurePlugin = define({
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(),
form: form(true),
},
authorize: (answer) =>
Effect.succeed({
@@ -105,6 +156,7 @@ 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",
@@ -125,6 +177,42 @@ 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
@@ -132,11 +220,13 @@ 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()
@@ -155,6 +245,31 @@ 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,12 +8,10 @@ import { Context, Effect } from "effect"
*/
export interface Interface {
/**
* 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.
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
* UI startup, or other unrelated work on plugin boot should be avoided.
*/
readonly awaitActivation: Effect.Effect<void>
readonly flush: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+13 -15
View File
@@ -55,13 +55,11 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
}
const plugin = yield* PluginModule.load(operation, { install }).pipe(
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 }),
)
}),
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if ("pending" in plugin) {
pending.add(operation.target)
@@ -70,7 +68,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, ref: plugin.ref },
state: { status: "failed", error: plugin.error },
features: { server: true },
})
continue
@@ -129,7 +127,9 @@ 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,14 +162,12 @@ export const layer = Layer.effect(
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
updates.changes().pipe(
Stream.filter((update) => packages.has(update.target)),
Stream.tap((update) =>
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
),
Stream.filter((target) => packages.has(target)),
Stream.tap((target) => Effect.sync(() => outdated.delete(target))),
Stream.map(() => undefined),
),
).pipe(
// Make accepted work visible to awaitActivation before coalescing the burst.
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
@@ -191,7 +189,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({ awaitActivation: ready.await })
return Service.of({ flush: ready.await })
}),
)
+6 -7
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, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
readonly check: (target: string) => Effect.Effect<boolean>
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
readonly changes: () => Stream.Stream<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
@@ -22,29 +22,28 @@ 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<{ readonly target: string; readonly outdated: boolean }>()
const changes = yield* PubSub.unbounded<string>()
return Service.of({
check: (target, options) =>
check: (target) =>
locks.withLock(target)(
Effect.gen(function* () {
const checkedAt = yield* Clock.currentTimeMillis
const current = status.get(target)
if (!options?.refresh && current && checkedAt - current.checkedAt < interval) return current.outdated
if (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, outdated: false })),
Effect.tap(() => PubSub.publish(changes, target)),
Effect.asVoid,
),
changes: () => Stream.fromPubSub(changes),
+1 -3
View File
@@ -41,7 +41,6 @@ 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"
@@ -408,7 +407,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.awaitActivation
yield* plugins.flush
return yield* Command.Service
}).pipe(instances.provide(session))
const delivery = input.delivery ?? "steer"
@@ -526,7 +525,6 @@ export const node = makeGlobalNode({
service: Service,
layer,
deps: [
httpClient,
Job.node,
SessionEnvironment.node,
Database.node,
+16 -73
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, Message } 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,21 +10,18 @@ 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 { SessionModelRequest } from "./model-request.js"
import type { 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
@@ -74,9 +71,7 @@ export type AutoInput = {
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
readonly context: SessionContext.Loaded
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
export type ManualInput = {
readonly session: SessionSchema.Info
@@ -112,67 +107,6 @@ 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
@@ -430,18 +364,27 @@ 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),
)
return estimateTokens(input) >= promptCeiling
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
}
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.awaitActivation
yield* plugins.flush
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.awaitActivation
yield* plugins.flush
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.awaitActivation
yield* plugins.flush
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.awaitActivation
yield* plugins.flush
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.awaitActivation
yield* plugins.flush
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, context: loaded })) {
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
+1 -5
View File
@@ -32,12 +32,10 @@ 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
}
/**
@@ -51,7 +49,6 @@ 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)
@@ -184,7 +181,6 @@ 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
}),
@@ -200,7 +196,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.awaitActivation
yield* plugins.flush
return yield* Shell.Service
}).pipe(instances.provide(session))
const started = yield* shell
+5 -2
View File
@@ -8,7 +8,6 @@ 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"
@@ -342,7 +341,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) => TokenUsage.total(b.tokens) - TokenUsage.total(a.tokens))
.sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens))
.map((model) => ({ ...model, cost: Money.USD.make(model.cost) })),
}
})
@@ -379,6 +378,10 @@ 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,
+3 -6
View File
@@ -41,7 +41,6 @@ export type ListInput = typeof ListInput.Type
export type MessagesInput = {
sessionID: Session.ID
type?: SessionMessage.Type
limit?: number
order?: "asc" | "desc"
cursor?: {
@@ -157,11 +156,9 @@ const layer = Layer.effect(
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = and(
eq(SessionMessageTable.session_id, input.sessionID),
input.type === undefined ? undefined : eq(SessionMessageTable.type, input.type),
boundary,
)
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
-77
View File
@@ -1,77 +0,0 @@
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,
}),
),
),
),
),
)
}),
)
})
+25 -2
View File
@@ -74,7 +74,14 @@ export interface Options<State, DraftApi> {
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
/** Returns the last published value without rebuilding or waiting. */
readonly get: () => State
/**
* Resolves completed registration changes, joining an in-progress rebuild or
* materializing batched changes before returning the published value. Does not
* wait for future registrations or a scheduled reload's debounce.
*/
readonly resolve: () => Effect.Effect<State>
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
@@ -84,11 +91,13 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
let requestedAt = 0
let running = false
let closed = false
let dirty = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
dirty = false
if (options.finalize) yield* options.finalize(options.draft(next))
})
@@ -162,6 +171,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
closed = true
return
}
dirty = true
batch.reloads.add(materializeReload)
return
}
@@ -177,12 +187,25 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
if (batch?.active) {
dirty = true
batch.reloads.add(materializeReload)
} else yield* materializeReload()
return { dispose }
}),
)
}),
reload,
resolve: Effect.fnUntraced(
function* () {
const batch = yield* CurrentBatch
if (dirty) yield* materialize()
// Resolution replaces this batch's queued rebuild only after publication succeeds.
batch?.reloads.delete(materializeReload)
return state
},
// Wait interruptibly for the owner, then finish publication and notification together.
(effect) => semaphore.withPermit(Effect.uninterruptible(effect)),
),
}
}
+6 -17
View File
@@ -145,8 +145,10 @@ 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) => {
const messages = [
const input = (tokens: number) => ({
session,
resolved,
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_compaction_config"),
type: "assistant",
@@ -156,20 +158,7 @@ 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)
+19 -34
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 { Cause, Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { 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,15 +217,12 @@ describe("PluginSupervisor config", () => {
)
it.live("logs invalid packages and continues loading", () => {
const output: Array<{ target: string; ref: string; diagnostic: string }> = []
const output: 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) 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) })
if (typeof details !== "object" || details === null || !("target" in details)) return
if (typeof details.target === "string") output.push(details.target)
})
return withLocation(
{
@@ -233,7 +230,6 @@ 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" },
@@ -247,30 +243,16 @@ describe("PluginSupervisor config", () => {
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
expect(output.map((entry) => entry.target)).toEqual([
expect(output).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"),
])
const failed = (yield* plugins.list()).filter((plugin) => plugin.state.status === "failed")
expect(failed.map((plugin) => plugin.source)).toEqual([
expect(
(yield* plugins.list()).filter((plugin) => plugin.state.status === "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])))
})
@@ -508,7 +490,7 @@ describe("PluginSupervisor config", () => {
),
)
it.live("unblocks awaitActivation when plugin activation fails", () =>
it.live("unblocks flush when plugin activation fails", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
@@ -548,18 +530,19 @@ 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.awaitActivation.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
yield* supervisor.awaitActivation.pipe(Effect.timeout("2 seconds"))
yield* supervisor.flush.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.awaitActivation
yield* supervisor.flush
})
const waitForFile = (file: string) =>
@@ -627,10 +610,12 @@ 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({ awaitActivation: Effect.void })),
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: 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({ awaitActivation: Effect.void })
return PluginSupervisor.Service.of({ flush: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
@@ -31,7 +31,7 @@ export const promptLocationNode = makeGlobalNode({
}),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.void,
flush: 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.awaitActivation
yield* plugins.flush
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.awaitActivation
yield* supervisor.flush
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.awaitActivation
yield* supervisor.flush
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.awaitActivation
yield* supervisor.flush
const config = yield* Config.Service
// Only the pathless host-injected document; nothing file-backed.
const entries = yield* config.entries()
+36
View File
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -262,6 +263,41 @@ describe("Integration", () => {
}),
)
it.effect("refreshes an expired OAuth credential registered in the same batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("opencode")
const methodID = Integration.MethodID.make("device")
const stored = yield* credentials.create({
integrationID,
label: "Work",
value: Credential.OAuth.make({ type: "oauth", methodID, access: "expired", refresh: "refresh", expires: 1 }),
})
// Plugin activation batches setup, deferring method registration until
// every plugin finishes. A plugin resolving its connection during setup
// must still reach the refresh implementation it just registered.
const resolved = yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Device" },
authorize: () => Effect.die(new Error("unused authorize")),
refresh: (credential) =>
Effect.succeed({ ...credential, access: "fresh", expires: Number.MAX_SAFE_INTEGER }),
}),
)
return yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: "Work" })
}),
)
expect(resolved).toMatchObject({ type: "oauth", access: "fresh" })
expect((yield* credentials.get(stored.id))?.value).toMatchObject({ access: "fresh" })
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+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.awaitActivation
yield* supervisor.flush
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 activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
Effect.provide(context),
Effect.forkChild,
)
expect(activationFiber.pollUnsafe()).toBeUndefined()
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(activationFiber)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
yield* Fiber.join(flushFiber)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).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 activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
@@ -296,10 +296,10 @@ describe("LocationServiceMap", () => {
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(activationFiber.pollUnsafe()).toBeUndefined()
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(activationFiber)
yield* Fiber.join(flushFiber)
}),
),
),
@@ -352,22 +352,22 @@ describe("LocationServiceMap", () => {
)
yield* Fiber.join(updated)
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
Effect.provide(context),
Effect.forkChild,
)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(activationFiber.pollUnsafe()).toBeUndefined()
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(activationFiber)
yield* Fiber.join(flushFiber)
expect(activations.count).toBe(2)
}),
),
),
)
itWithSdk.live("keeps awaitActivation pending while startup updates continue", () =>
itWithSdk.live("keeps flush 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 activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).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(activationFiber.pollUnsafe()).toBeUndefined()
yield* Fiber.join(activationFiber)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Fiber.join(flushFiber)
}),
),
),
@@ -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.awaitActivation).pipe(Effect.provide(context))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).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 awaitActivation pending while later hot reload runs", () =>
itWithSdk.live("keeps flush open 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.awaitActivation).pipe(Effect.provide(context))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).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 activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
expect(activationFiber.pollUnsafe()).toBeUndefined()
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(activationFiber)
yield* Fiber.join(flushFiber)
yield* Deferred.await(completed)
}),
),
),
)
itWithSdk.live("does not cancel activation when an awaitActivation waiter is interrupted", () =>
itWithSdk.live("does not cancel activation when a flush 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 activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
yield* Fiber.interrupt(activationFiber)
yield* Fiber.interrupt(flushFiber)
yield* Deferred.succeed(release, undefined)
yield* Deferred.await(completed)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).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.awaitActivation
yield* supervisor.flush
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.awaitActivation
yield* supervisor.flush
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.awaitActivation
yield* supervisor.flush
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.awaitActivation
yield* supervisor.flush
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
type: "remote",
@@ -1 +0,0 @@
throw new Error("private plugin loader details")
+208 -35
View File
@@ -14,6 +14,7 @@ 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"
@@ -53,7 +54,12 @@ 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>) {
function withAzureCommands<A, E, R>(
run: (args: readonly string[]) => unknown,
fx: () => Effect.Effect<A, E, R>,
deploymentDelay = 0,
signedIn = true,
) {
return Effect.gen(function* () {
const processes = yield* AppProcess.Service
const directory = (yield* Location.Service).directory
@@ -62,6 +68,9 @@ function withAzureCommands<A, E, R>(run: (args: readonly string[]) => unknown, f
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) => {
@@ -70,7 +79,7 @@ function withAzureCommands<A, E, R>(run: (args: readonly string[]) => unknown, f
if (value instanceof Error) {
return Effect.fail(new AppProcess.AppProcessError({ command: "az", cause: value }))
}
return Effect.succeed({
const result = Effect.succeed({
command: `az ${command.args.join(" ")}`,
exitCode: 0,
stdout: Buffer.from(JSON.stringify(value)),
@@ -78,11 +87,16 @@ function withAzureCommands<A, E, R>(run: (args: readonly string[]) => unknown, f
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)),
)
@@ -163,12 +177,13 @@ describe("AzurePlugin", () => {
),
)
it.live("does not invoke Azure CLI at startup without an Azure connection", () => {
it.live("does not invoke Azure CLI at startup without a cached Azure login", () => {
const commands: string[] = []
return withEnv(
{
AZURE_RESOURCE_NAME: undefined,
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined,
AZURE_RESOURCE_GROUP: undefined,
},
() =>
withAzureCommands(
@@ -183,20 +198,48 @@ 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("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 }, () =>
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 }, () =>
withAzureCommands(
(args) => {
commands.push([...args])
return {
accessToken: "legacy-cli-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
if (args.includes("get-access-token")) {
return {
accessToken: "legacy-cli-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}
}
return []
},
() =>
Effect.gen(function* () {
@@ -219,28 +262,130 @@ 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("does not invoke Azure CLI at startup with an existing connection", () => {
const commands: string[][] = []
return withAzureCommands(
(args) => {
commands.push([...args])
return []
},
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"),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -249,18 +394,46 @@ 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))).toEqual({ target: "fixture", outdated: false })
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toBe("fixture")
}),
)
+11 -94
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, 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 estimates current content against the buffered prompt ceiling", () =>
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
@@ -161,45 +161,25 @@ it.effect("auto compaction estimates current content against the buffered prompt
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 }) => {
const resolved = SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image", "pdf"], output: ["text"] },
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => ({
session,
resolved: SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit,
})
const messages = [
}),
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [{ type: "text", text: "Done" }],
content: [],
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)
@@ -212,69 +192,6 @@ it.effect("auto compaction estimates current content against the buffered prompt
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, { awaitActivation: Effect.void })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
+16 -183
View File
@@ -1,18 +1,14 @@
import { describe, expect } from "bun:test"
import { and, eq } from "drizzle-orm"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Queue, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
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"
@@ -55,7 +51,6 @@ const it = testEffect(
SessionStore.node,
SessionInbox.node,
FSUtil.node,
httpClient,
]),
{
replacements: [Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer)],
@@ -100,7 +95,7 @@ const setup = Effect.fnUntraced(function* (options?: {
)
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
const locations: Location.Ref[] = []
const activationWaits: Location.Ref[] = []
const flushes: Location.Ref[] = []
const resumes: SessionSchema.ID[] = []
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
const execution = SessionExecution.Service.of({
@@ -148,8 +143,8 @@ const setup = Effect.fnUntraced(function* (options?: {
}),
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.sync(() => {
activationWaits.push(ref)
flush: Effect.sync(() => {
flushes.push(ref)
}),
}),
),
@@ -171,7 +166,6 @@ const setup = Effect.fnUntraced(function* (options?: {
| Instance.Service
| SessionExecution.Service
| SessionInbox.Service
| HttpClient.HttpClient
| Scope.Scope
>(),
Effect.provideService(Instance.Service, {
@@ -181,7 +175,7 @@ const setup = Effect.fnUntraced(function* (options?: {
}),
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
)
return { sessions, hooks, locations, activationWaits, resumes, wakes, db: database.db, bus, store }
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
})
describe("Session-owned handles", () => {
@@ -258,7 +252,7 @@ describe("Session-owned handles", () => {
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
expect(fixture.activationWaits).toEqual([source])
expect(fixture.flushes).toEqual([source])
calls.push(event.prompt.text)
event.prompt.text += " prepared"
}),
@@ -283,7 +277,7 @@ describe("Session-owned handles", () => {
)
expect(calls).toEqual(["Original"])
expect(fixture.locations).toEqual([source])
expect(fixture.activationWaits).toEqual([source])
expect(fixture.flushes).toEqual([source])
expect(fixture.wakes).toEqual([
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
@@ -293,159 +287,6 @@ 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()
@@ -522,7 +363,7 @@ describe("Session-owned handles", () => {
expect(fixture.locations).toEqual([source])
yield* prompt
expect(fixture.locations).toEqual([source, destination])
expect(fixture.activationWaits).toEqual([source, destination])
expect(fixture.flushes).toEqual([source, destination])
expect((yield* fixture.sessions.forSession(otherID).get()).location).toEqual(source)
}),
)
@@ -567,7 +408,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.activationWaits).toEqual([])
expect(fixture.flushes).toEqual([])
expect(fixture.resumes).toEqual([])
expect(fixture.wakes).toEqual([])
}),
@@ -601,7 +442,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.activationWaits).toEqual([])
expect(fixture.flushes).toEqual([])
expect(fixture.resumes).toEqual([])
expect(fixture.wakes).toEqual([])
}),
@@ -935,7 +776,7 @@ describe("Session-owned handles", () => {
expect(captures).toEqual([source, destination])
expect(fixture.locations).toEqual([source, destination, destination])
expect(fixture.activationWaits).toEqual([source, destination, destination])
expect(fixture.flushes).toEqual([source, destination, destination])
expect((yield* handle.get()).revert).toBeUndefined()
}),
)
@@ -959,7 +800,7 @@ describe("SessionPrompt construction", () => {
Layer.mergeAll(
Layer.succeed(PluginHooks.Service, fixture.hooks),
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.sync(() => {
flush: Effect.sync(() => {
calls.push("ready")
}),
}),
@@ -1001,8 +842,8 @@ describe("SessionRevert construction", () => {
Effect.provide(
Layer.merge(
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.sync(() => {
calls.push("awaitActivation")
flush: Effect.sync(() => {
calls.push("flush")
}),
}),
Layer.mock(Snapshot.Service, {
@@ -1030,7 +871,7 @@ describe("SessionRevert construction", () => {
yield* revert
.stage({ session, messageID: boundary.id, files: false })
.pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["awaitActivation", "capture", "capture", "diff"])
expect(calls).toEqual(["flush", "capture", "capture", "diff"])
const staged = yield* handle.get()
expect(staged.revert?.snapshot).toBe(Snapshot.ID.make("captured-tree"))
@@ -1038,15 +879,7 @@ 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([
"awaitActivation",
"capture",
"capture",
"diff",
"awaitActivation",
"restore",
"awaitActivation",
])
expect(calls).toEqual(["flush", "capture", "capture", "diff", "flush", "restore", "flush"])
}),
)
})
@@ -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.awaitActivation
yield* plugins.flush
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({
awaitActivation: Effect.sync(() => (ready = true)),
flush: 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.awaitActivation
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
@@ -85,10 +85,7 @@ 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({ awaitActivation: Effect.void }),
)
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
+7 -6
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: 200_000, output: 1_000 })
const recoveryModel = testModel("recovery", { context: 20_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>(),
pluginActivationHook: Effect.void,
pluginFlushHook: 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({
awaitActivation: Effect.suspend(() => state.pluginActivationHook),
flush: Effect.suspend(() => state.pluginFlushHook),
}),
)
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.pluginActivationHook = Deferred.await(release)
s.pluginFlushHook = Deferred.await(release)
yield* s.session.prompt({ sessionID, text: "Wait for plugins", resume: false })
s.requests.length = 0
@@ -2540,16 +2540,17 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("compacts before requesting an undersized configured context limit", function* (s) {
scenario("recovers from provider context overflow despite 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(2)
expect(s.requests).toHaveLength(3)
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, {
awaitActivation: Effect.void,
flush: 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, { awaitActivation: Effect.void })),
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { flush: Effect.void })),
],
),
)
+178 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Scheduler, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
@@ -55,12 +55,18 @@ describe("State", () => {
}),
)
it.effect("runs transforms during every reload", () =>
it.effect("skips a reload's debounce when resolving but joins an in-progress reload", () =>
Effect.gen(function* () {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let value = "first"
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () =>
value === "first"
? Effect.void
: Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* state.transform((editor) => {
@@ -70,8 +76,15 @@ describe("State", () => {
value = "second"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
expect((yield* state.resolve()).values).toEqual(["first"])
yield* TestClock.adjust("500 millis")
yield* Deferred.await(rebuilding)
const reader = yield* state.resolve().pipe(Effect.forkChild({ startImmediately: true }))
expect(reader.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(reload)
expect((yield* Fiber.join(reader)).values).toEqual(["second"])
expect(state.get().values).toEqual(["second"])
}),
)
@@ -133,6 +146,168 @@ describe("State", () => {
}),
)
it.effect("resolves registrations deferred by a batch without rebuilding twice", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
expect(yield* state.resolve()).toBe(state.get())
expect(finalized).toBe(0)
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => {
draft.add("first")
})
expect(state.get().values).toEqual([])
expect((yield* state.resolve()).values).toEqual(["first"])
expect(state.get().values).toEqual(["first"])
expect(finalized).toBe(1)
expect(yield* state.resolve()).toBe(state.get())
expect(finalized).toBe(1)
yield* state.transform((draft) => {
draft.add("second")
})
}),
)
// Resolution absorbed the queued batch rebuild; only the later registration
// rebuilds at batch completion.
expect(state.get().values).toEqual(["first", "second"])
expect(finalized).toBe(2)
}),
)
it.effect("resolves disposals deferred by a batch", () =>
Effect.gen(function* () {
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
const scope = yield* Scope.make()
yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
expect(state.get().values).toEqual(["value"])
yield* State.batch(
Effect.gen(function* () {
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual(["value"])
expect((yield* state.resolve()).values).toEqual([])
}),
)
}),
)
it.effect("joins a concurrent resolution instead of returning the previous publication", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const values = Array.from({ length: 128 }, (_, index) => index)
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as number[] }),
draft: (draft) => draft,
finalize: () => Effect.sync(() => finalized++),
})
yield* State.batch(
Effect.gen(function* () {
yield* Effect.forEach(values, (value) =>
state.transform((draft) => {
draft.values.push(value)
if (value === 0) Deferred.doneUnsafe(started, Effect.void)
}),
)
const reader = yield* Deferred.await(started).pipe(
Effect.andThen(
Effect.gen(function* () {
expect(state.get().values).toEqual([])
return yield* state.resolve()
}),
),
Effect.forkChild({ startImmediately: true }),
)
// Yield during synchronous transform replay, before the next value is published.
const writer = yield* state
.resolve()
.pipe(Effect.provideService(Scheduler.MaxOpsBeforeYield, 64), Effect.forkChild({ startImmediately: true }))
const observed = yield* Fiber.join(reader)
const published = yield* Fiber.join(writer)
expect(observed.values).toEqual(values)
expect(observed).toBe(published)
}),
)
expect(finalized).toBe(1)
}),
)
it.effect("keeps queued resolution cancellable but finishes a rebuild once started", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => draft,
finalize: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.sync(() => finalized++)),
),
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.values.push("value"))
const writer = yield* state.resolve().pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
yield* Deferred.await(started)
const reader = yield* state.resolve().pipe(Effect.forkChild({ startImmediately: true }))
expect(reader.pollUnsafe()).toBeUndefined()
yield* Fiber.interrupt(reader)
expect(writer.pollUnsafe()).toBeUndefined()
const interruption = yield* Fiber.interrupt(writer).pipe(Effect.forkChild({ startImmediately: true }))
expect(interruption.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interruption)
expect((yield* state.resolve()).values).toEqual(["value"])
}),
)
expect(finalized).toBe(1)
}),
)
it.effect("can resolve again after transform replay fails", () =>
Effect.gen(function* () {
let fail = true
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => draft,
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => {
if (fail) throw new Error("replay failed")
draft.values.push("value")
})
expect(Exit.isFailure(yield* Effect.exit(state.resolve()))).toBeTrue()
expect(state.get().values).toEqual([])
fail = false
expect((yield* state.resolve()).values).toEqual(["value"])
}),
)
}),
)
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
Effect.gen(function* () {
let finalized = 0
@@ -160,6 +335,7 @@ describe("State", () => {
yield* Fiber.join(pending)
yield* registration.dispose
yield* state.reload()
expect(yield* state.resolve()).toBe(state.get())
expect(finalized).toBe(1)
}),
)
+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({ awaitActivation: Effect.void }))),
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: 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.awaitActivation
yield* plugins.flush
const registry = yield* Tool.Service
return yield* body(registry)
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
+2 -6
View File
@@ -107,9 +107,7 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
),
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
@@ -157,9 +155,7 @@ const completionIt = testEffect(
const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(locations.get(location)),
)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).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.
+1 -5
View File
@@ -156,11 +156,7 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
}
type PromptFooterInput = {
readonly sessionID?: string
readonly mode: "normal" | "shell"
readonly showDetails: boolean
}
type PromptFooterInput = { readonly sessionID?: string; readonly mode: "normal" | "shell" }
/**
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
-132
View File
@@ -486,132 +486,6 @@
"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"],
@@ -2143,9 +2017,6 @@
"type": "null"
}
]
},
"callbackUrl": {
"type": "string"
}
},
"required": ["text"],
@@ -16901,9 +16772,6 @@
},
"error": {
"type": "string"
},
"ref": {
"type": "string"
}
},
"required": ["status", "error"],
-16
View File
@@ -20,22 +20,6 @@ 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,
+1 -5
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, optional, statics } from "@opencode-ai/schema/schema"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, 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,10 +343,6 @@ 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, ref: Schema.String.pipe(optional) }),
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }),
]).annotate({ identifier: "Plugin.State" })
export type State = typeof State.Type
-4
View File
@@ -12,7 +12,3 @@ 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,17 +23,3 @@ 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
@@ -1,18 +0,0 @@
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 { awaitActivation: release.open.pipe(Effect.andThen(plugins.awaitActivation)) }
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
}),
).pipe(Layer.provide(layer)),
)
+1 -43
View File
@@ -14,52 +14,10 @@ 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.awaitActivation
yield* supervisor.flush
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.awaitActivation
yield* supervisor.flush
const rpc = yield* Rpc.Service
const output = yield* rpc.call(params.rpcID, params.method, payload.input)
return output === undefined ? {} : { output }
-9
View File
@@ -289,14 +289,6 @@ 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({
@@ -309,7 +301,6 @@ 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),
+1 -68
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, Queue, Schedule, Schema } from "effect"
import { Effect, Schedule, Schema } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -118,73 +118,6 @@ 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)
+1 -18
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.sync(() => version === "1.0.0"),
check: () => Effect.succeed(false),
update: () => {
if (fail) return Effect.fail(new Npm.InstallFailedError({ dir: plugin }))
return Effect.sync(() => (version = "2.0.0")).pipe(Effect.map(entry))
@@ -60,23 +60,6 @@ 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.awaitActivation
yield* supervisor.flush
}).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.awaitActivation).pipe(Effect.provide(context)),
PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)),
)
expect(activations).toBe(2)
-6
View File
@@ -1,6 +0,0 @@
# 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() && !(route.data.type === "plugin" && route.data.id === "opencode.stats")}>
<Show when={devtools()}>
<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, Show } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -9,25 +9,17 @@ import { useRoute } from "../context/route"
import { getScrollAcceleration } from "../util/scroll"
import { useTheme } from "../context/theme"
import { emptyPrompt } from "../prompt/history"
import { dialogWidth, useDialog } from "../ui/dialog"
import { FilePath } from "../ui/file-path"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { errorDetails } from "../util/error-details"
export function DialogErrorDetails(props: {
title: string
source?: string
error: string
context?: string
diagnosticRef?: string
onBack: () => void
}) {
export function DialogErrorDetails(props: { title: string; error: string; context?: 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
@@ -59,7 +51,7 @@ export function DialogErrorDetails(props: {
const copy = () => {
void clipboard
.write(errorDetails(props).text)
.write(props.error)
.then(() => setCopied(true))
.catch(toast.error)
}
@@ -70,7 +62,7 @@ export function DialogErrorDetails(props: {
location: location.ref,
prompt: {
...emptyPrompt(),
text: errorDetails(props).prompt,
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.`,
},
})
dialog.clear()
@@ -96,49 +88,40 @@ export function DialogErrorDetails(props: {
})
return (
<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 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>
<box>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={theme.text.default} wrapMode="word">
<text fg={overlayTheme.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} flexWrap="wrap">
<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>
<text onMouseUp={investigate}>
<span style={{ fg: theme.text.default }}>
<b>i</b>
@@ -151,9 +134,6 @@ export function DialogErrorDetails(props: {
</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,7 +13,13 @@ 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[] = []
export const experiments: Experiment[] = [
{
id: "session-preview-tabs",
title: "Session preview tabs",
description: "Preview sessions in temporary tabs before keeping them open",
},
]
export function DialogExperiments() {
const config = useConfig()
+1 -5
View File
@@ -1595,11 +1595,7 @@ 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,
showDetails: store.interrupt === 0 || dimensions().width >= 80,
})
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
+1 -2
View File
@@ -374,7 +374,6 @@ 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
@@ -384,7 +383,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
: []),
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, title),
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
@@ -1,41 +0,0 @@
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)))
})
}
+26 -18
View File
@@ -1,5 +1,4 @@
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"
@@ -13,7 +12,6 @@ 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,
@@ -65,6 +63,7 @@ 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.
@@ -75,7 +74,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
key: "sessionID",
})
const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({})
const [preview, updatePreview] = storage.memory<{ global?: string; cwd?: string }>("session-tab-preview", {
initial: {},
})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
@@ -107,7 +108,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const previewID = () => preview[config.tabs.scope]
const setPreview = (sessionID: string | undefined) => updatePreview(config.tabs.scope, sessionID)
const setPreview = (sessionID: string | undefined) => {
const scope = config.tabs.scope
updatePreview((draft) => {
if (sessionID === undefined) {
delete draft[scope]
return
}
draft[scope] = sessionID
})
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs.scope
@@ -139,13 +149,6 @@ 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
@@ -175,9 +178,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
createEffect(() => {
if (enabled()) return
if (enabled() && previews()) return
promotedSession = undefined
updatePreview({ global: undefined, cwd: undefined })
if (!preview.global && !preview.cwd) return
updatePreview((draft) => {
delete draft.global
delete draft.cwd
})
})
// Shared storage updates must not re-admit a tab unless this client changes route or scope.
@@ -196,9 +203,10 @@ 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 replaced = permanent ? undefined : previewID()
const temporary = previews() && !permanent
const replaced = temporary ? previewID() : undefined
if (replaced) scrollAnchors.delete(replaced)
if (!permanent) setPreview(sessionID)
if (temporary) setPreview(sessionID)
update((draft) => {
if (cancelledTabs.has(sessionID)) return
const tab = {
@@ -369,7 +377,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return state().tabs
},
isPreview(sessionID: string) {
return enabled() && previewID() === root(sessionID)
return enabled() && previews() && previewID() === root(sessionID)
},
newTab() {
return newTab()
@@ -396,7 +404,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
route.navigate({ type: "session", sessionID: root(sessionID) })
},
promote(sessionID: string) {
if (!enabled()) return
if (!enabled() || !previews()) return
const session = root(sessionID)
if (previewID() === session) {
setPreview(undefined)
@@ -440,7 +448,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const tabs = result.tabs
if (!tabs || !result.sessionID) return
cancelledTabs.delete(result.sessionID)
promotedSession = result.sessionID
if (previews()) promotedSession = result.sessionID
update((draft) => {
draft.tabs = tabs
})
@@ -8,12 +8,7 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
export function PromptFooter(props: {
context: Plugin.Context
sessionID?: string
mode: "normal" | "shell"
showDetails: boolean
}) {
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const [liveHovered, setLiveHovered] = createSignal(false)
const subagents = createMemo(() => {
@@ -74,7 +69,7 @@ export function PromptFooter(props: {
</text>
</box>
</Show>
<Show when={props.showDetails && status().length > 0}>
<Show when={status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live()}> · </Show>
{status().join(" · ")}
@@ -82,14 +77,14 @@ export function PromptFooter(props: {
</Show>
</box>
</Match>
<Match when={props.showDetails && dimensions().width >= 44}>
<Match when={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={props.showDetails}>
<text fg={props.context.theme.text.default} wrapMode="none" flexShrink={0}>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Show>
@@ -111,14 +106,7 @@ export default Plugin.define({
setup(context) {
context.ui.slot({
append: "prompt.footer",
render: (props) => (
<PromptFooter
context={context}
sessionID={props.sessionID}
mode={props.mode}
showDetails={props.showDetails}
/>
),
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
})
},
})
@@ -186,11 +186,9 @@ export function PluginsDialog(props: {
>
{(entry) => (
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin error`}
source={pluginSource(entry(), props.context)}
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={pluginError(entry()) ?? "Unknown plugin error"}
diagnosticRef={pluginErrorRef(entry())}
context={`Plugin: ${label(entry(), props.context)}\nStatus: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -243,14 +241,11 @@ 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(() => ({
@@ -1,18 +0,0 @@
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)
}
@@ -1,218 +0,0 @@
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,7 +7,6 @@ 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"
@@ -19,7 +18,6 @@ export const builtins = [
SidebarFooter,
Notifications,
Plugins,
Stats,
Merman,
Latex,
// The storybook is a development tool; keep its route and palette commands out of
+4 -15
View File
@@ -3087,13 +3087,11 @@ 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) {
@@ -3139,11 +3137,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text
fg={permission() ? theme.text.feedback.warning.default : (props.headerColor ?? theme.text.subdued)}
>
{title()}
</text>
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>{title()}</text>
}
>
<Spinner color={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
@@ -3159,10 +3153,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text
flexShrink={0}
fg={permission() ? theme.text.feedback.warning.default : (props.headerColor ?? theme.text.subdued)}
>
<text flexShrink={0} fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
{path().label}
</text>
}
@@ -3174,14 +3165,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 : (props.headerColor ?? theme.text.subdued)}
fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}
/>
</box>
)}
</Show>
{props.children}
<Show when={error()}>
<text fg={props.errorColor ?? theme.text.feedback.error.default}>{error()}</text>
<text fg={theme.text.feedback.error.default}>{error()}</text>
</Show>
</box>
)
@@ -3786,8 +3777,6 @@ 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
@@ -1,21 +0,0 @@
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"),
}
}
+61 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { EmbeddedTerminalRenderable, type Renderable, ScrollBoxRenderable } 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,6 +921,66 @@ 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 = {
@@ -1,110 +0,0 @@
/** @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,7 +4,6 @@ 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"
@@ -12,7 +11,6 @@ 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 () => {
@@ -70,7 +68,6 @@ 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" },
@@ -89,15 +86,13 @@ test("the horizontal tab context menu keeps preview tabs open without selecting
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<SessionTabs controller={controller} animations={false} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ClientProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<SessionTabs controller={controller} animations={false} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
@@ -118,15 +113,8 @@ test("the horizontal tab context menu keeps preview tabs open without selecting
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
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"))
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
const second = app
.captureCharFrame()
@@ -1,205 +0,0 @@
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()
}
})

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