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