mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 07:26:20 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b659ca45c | ||
|
|
02c5f32593 | ||
|
|
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>
|
||||
}
|
||||
|
||||
@@ -258,6 +266,7 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
readonly callbackUrl?: string | undefined
|
||||
}
|
||||
export type SessionPromptOutput = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => Effect.Effect<SessionPromptOutput, E>
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
SessionListInput,
|
||||
@@ -324,6 +326,13 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
@@ -333,6 +342,7 @@ const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: Plugin
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
})
|
||||
|
||||
@@ -475,6 +485,7 @@ const EndpointSessionPrompt = (raw: RawClient["server.session"]) => (input: Sess
|
||||
metadata: input["metadata"],
|
||||
delivery: input["delivery"],
|
||||
resume: input["resume"],
|
||||
callbackUrl: input["callbackUrl"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
|
||||
@@ -103,16 +103,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state === "ready")
|
||||
yield* Effect.tryPromise(() =>
|
||||
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
|
||||
)
|
||||
else {
|
||||
if (!service.legacy)
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
}
|
||||
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
|
||||
if (!service.legacy && service.state !== "ready")
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* stop({
|
||||
file: options.file,
|
||||
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
@@ -145,8 +141,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
const info = yield* read(options.file)
|
||||
if (options.pty === "handoff" && info !== undefined)
|
||||
yield* Effect.tryPromise(() =>
|
||||
PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout),
|
||||
)
|
||||
else yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
SessionListInput,
|
||||
@@ -468,6 +470,19 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/check`,
|
||||
query: { location: input?.["location"] },
|
||||
body: { target: input?.["target"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateOutput>(
|
||||
{
|
||||
@@ -673,6 +688,7 @@ export function make(options: ClientOptions) {
|
||||
metadata: input["metadata"],
|
||||
delivery: input["delivery"],
|
||||
resume: input["resume"],
|
||||
callbackUrl: input["callbackUrl"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 400, 404, 401],
|
||||
|
||||
@@ -18,7 +18,7 @@ export type PluginSource =
|
||||
|
||||
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
|
||||
|
||||
export type PluginState = { status: "active" } | { status: "failed"; error: string }
|
||||
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
@@ -2585,6 +2585,18 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target?: { readonly target?: string | undefined }["target"]
|
||||
}
|
||||
|
||||
export type PluginCheckOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -3719,6 +3731,7 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["id"]
|
||||
readonly text: {
|
||||
readonly id?: string | null
|
||||
@@ -3740,6 +3753,7 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["text"]
|
||||
readonly files?: {
|
||||
readonly id?: string | null
|
||||
@@ -3761,6 +3775,7 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["files"]
|
||||
readonly agents?: {
|
||||
readonly id?: string | null
|
||||
@@ -3782,6 +3797,7 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["agents"]
|
||||
readonly skills?: {
|
||||
readonly id?: string | null
|
||||
@@ -3803,6 +3819,7 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["skills"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
@@ -3824,6 +3841,7 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["metadata"]
|
||||
readonly delivery?: {
|
||||
readonly id?: string | null
|
||||
@@ -3845,6 +3863,7 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["delivery"]
|
||||
readonly resume?: {
|
||||
readonly id?: string | null
|
||||
@@ -3866,7 +3885,30 @@ export type SessionPromptInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["resume"]
|
||||
readonly callbackUrl?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
readonly callbackUrl?: string
|
||||
}["callbackUrl"]
|
||||
}
|
||||
|
||||
export type SessionPromptOutput = { data: SessionInboxUser }["data"]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -804,6 +804,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
sessionID: "ses_test",
|
||||
text: "Hello",
|
||||
resume: false,
|
||||
callbackUrl: "https://notify.example.test/opencode",
|
||||
})
|
||||
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Summarize this session" })
|
||||
const synthetic = await client.session.synthetic({
|
||||
@@ -856,6 +857,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
text: "Hello",
|
||||
resume: false,
|
||||
callbackUrl: "https://notify.example.test/opencode",
|
||||
})
|
||||
const syntheticBody = requests.find((request) => request.url.endsWith("/synthetic"))?.init?.body
|
||||
if (typeof syntheticBody !== "string") throw new Error("Expected JSON synthetic request body")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
SyntheticConflictError,
|
||||
} from "./session/error.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionInbox } from "./session/inbox.js"
|
||||
@@ -407,7 +408,7 @@ const layer = Layer.effect(
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const commands = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
return yield* Command.Service
|
||||
}).pipe(instances.provide(session))
|
||||
const delivery = input.delivery ?? "steer"
|
||||
@@ -525,6 +526,7 @@ export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
httpClient,
|
||||
Job.node,
|
||||
SessionEnvironment.node,
|
||||
Database.node,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -32,10 +32,12 @@ import { SessionPrompt } from "./prompt.js"
|
||||
import { SessionRevert } from "./revert.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { SessionWebhook } from "./webhook.js"
|
||||
|
||||
type PromptRequest = SessionPrompt.Input & {
|
||||
id?: SessionMessage.ID
|
||||
resume?: boolean
|
||||
callbackUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +51,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
const execution = yield* SessionExecution.Service
|
||||
const admission = yield* SessionInbox.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const webhook = yield* SessionWebhook.make()
|
||||
|
||||
const get = Effect.fn("Session.get")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
@@ -181,6 +184,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
}).pipe(
|
||||
Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })),
|
||||
)
|
||||
if (input.callbackUrl) yield* webhook(sessionID, input.callbackUrl)
|
||||
if (input.resume !== false) yield* execution.wake(sessionID)
|
||||
return admitted
|
||||
}),
|
||||
@@ -196,7 +200,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
// Resolve shell services here without pinning Session events to this Location after a move.
|
||||
const shell = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
return yield* Shell.Service
|
||||
}).pipe(instances.provide(session))
|
||||
const started = yield* shell
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ListInput = typeof ListInput.Type
|
||||
|
||||
export type MessagesInput = {
|
||||
sessionID: Session.ID
|
||||
type?: SessionMessage.Type
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
@@ -156,9 +157,11 @@ const layer = Layer.effect(
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const where = and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
input.type === undefined ? undefined : eq(SessionMessageTable.type, input.type),
|
||||
boundary,
|
||||
)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export * as SessionWebhook from "./webhook.js"
|
||||
|
||||
import { Effect, FiberMap, Schema, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Bus } from "../bus.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
export const make = Effect.fn("SessionWebhook.make")(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const http = HttpClient.withScope(HttpClient.filterStatusOk(yield* HttpClient.HttpClient))
|
||||
// Capture the host context, not the submitting request's Location scope.
|
||||
const run = yield* FiberMap.makeRuntime<never, SessionSchema.ID, never, void>()
|
||||
|
||||
return Effect.fn("SessionWebhook.subscribe")((sessionID: SessionSchema.ID, url: string) =>
|
||||
Effect.sync(() => {
|
||||
run(
|
||||
sessionID,
|
||||
bus
|
||||
.subscribe([
|
||||
SessionEvent.Execution.Started,
|
||||
SessionEvent.Execution.Succeeded,
|
||||
SessionEvent.Execution.Failed,
|
||||
SessionEvent.Execution.Interrupted,
|
||||
SessionEvent.Renamed,
|
||||
SessionEvent.Deleted,
|
||||
...Permission.Event.Definitions,
|
||||
...Form.Event.Definitions,
|
||||
])
|
||||
.pipe(
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
(event.type === "form.created" ? event.data.form.sessionID : event.data.sessionID) === sessionID,
|
||||
),
|
||||
Stream.takeUntil(
|
||||
(event) =>
|
||||
event.type === "session.execution.succeeded" ||
|
||||
event.type === "session.execution.failed" ||
|
||||
event.type === "session.execution.interrupted" ||
|
||||
event.type === "session.deleted",
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
const response = (yield* store.messages({ sessionID, type: "assistant", limit: 1 }))[0]
|
||||
yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.bodyJson({
|
||||
...event,
|
||||
session: session ? Schema.encodeSync(SessionSchema.Info)(session) : null,
|
||||
response: response ? Schema.encodeSync(SessionMessage.Info)(response) : null,
|
||||
}),
|
||||
Effect.flatMap(http.execute),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
|
||||
Effect.scoped,
|
||||
Effect.timeout("5 seconds"),
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("Session webhook delivery failed", {
|
||||
sessionID,
|
||||
eventID: event.id,
|
||||
eventType: event.type,
|
||||
reason: error._tag,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Queue, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { ID, Info, Output } from "@opencode-ai/schema/shell"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "../src/bus.js"
|
||||
@@ -51,6 +55,7 @@ const it = testEffect(
|
||||
SessionStore.node,
|
||||
SessionInbox.node,
|
||||
FSUtil.node,
|
||||
httpClient,
|
||||
]),
|
||||
{
|
||||
replacements: [Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer)],
|
||||
@@ -95,7 +100,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
)
|
||||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
|
||||
const locations: Location.Ref[] = []
|
||||
const flushes: Location.Ref[] = []
|
||||
const activationWaits: Location.Ref[] = []
|
||||
const resumes: SessionSchema.ID[] = []
|
||||
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
|
||||
const execution = SessionExecution.Service.of({
|
||||
@@ -143,8 +148,8 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
}),
|
||||
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.sync(() => {
|
||||
flushes.push(ref)
|
||||
awaitActivation: Effect.sync(() => {
|
||||
activationWaits.push(ref)
|
||||
}),
|
||||
}),
|
||||
),
|
||||
@@ -166,6 +171,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
| Instance.Service
|
||||
| SessionExecution.Service
|
||||
| SessionInbox.Service
|
||||
| HttpClient.HttpClient
|
||||
| Scope.Scope
|
||||
>(),
|
||||
Effect.provideService(Instance.Service, {
|
||||
@@ -175,7 +181,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
}),
|
||||
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
|
||||
)
|
||||
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
|
||||
return { sessions, hooks, locations, activationWaits, resumes, wakes, db: database.db, bus, store }
|
||||
})
|
||||
|
||||
describe("Session-owned handles", () => {
|
||||
@@ -252,7 +258,7 @@ describe("Session-owned handles", () => {
|
||||
const calls: string[] = []
|
||||
yield* fixture.hooks.register("session", "prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(fixture.flushes).toEqual([source])
|
||||
expect(fixture.activationWaits).toEqual([source])
|
||||
calls.push(event.prompt.text)
|
||||
event.prompt.text += " prepared"
|
||||
}),
|
||||
@@ -277,7 +283,7 @@ describe("Session-owned handles", () => {
|
||||
)
|
||||
expect(calls).toEqual(["Original"])
|
||||
expect(fixture.locations).toEqual([source])
|
||||
expect(fixture.flushes).toEqual([source])
|
||||
expect(fixture.activationWaits).toEqual([source])
|
||||
expect(fixture.wakes).toEqual([
|
||||
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
|
||||
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
|
||||
@@ -287,6 +293,159 @@ describe("Session-owned handles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("forwards enriched session events across scopes and moves without blocking publication", () =>
|
||||
Effect.gen(function* () {
|
||||
const received = yield* Queue.unbounded<unknown>()
|
||||
const response = Promise.withResolvers<void>()
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.headers.get("content-type")).toContain("application/json")
|
||||
Queue.offerUnsafe(received, await request.json())
|
||||
await response.promise
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.sync(() => response.resolve()).pipe(Effect.andThen(Effect.promise(() => server.stop(true)))),
|
||||
)
|
||||
const fixture = yield* setup()
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const callbackUrl = server.url.href
|
||||
yield* handle
|
||||
.prompt({ text: "Notify me", callbackUrl })
|
||||
.pipe(Effect.provideService(Location.Service, location(source)), Effect.scoped)
|
||||
expect(JSON.stringify(yield* handle.inbox())).not.toContain(callbackUrl)
|
||||
|
||||
const started = yield* fixture.bus.publish(SessionEvent.Execution.Started, { sessionID })
|
||||
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
|
||||
...started,
|
||||
response: null,
|
||||
})
|
||||
yield* fixture.bus.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
projectID: Project.ID.global,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project/moved") }),
|
||||
subpath: RelativePath.make("moved"),
|
||||
})
|
||||
yield* fixture.bus.publish(SessionEvent.Renamed, { sessionID: otherID, title: "Unrelated" })
|
||||
const responses = yield* Effect.forEach(["Earlier answer", "The review is ready."], (text) =>
|
||||
Effect.gen(function* () {
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* fixture.bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
|
||||
})
|
||||
yield* fixture.bus.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, ordinal: 0 })
|
||||
yield* fixture.bus.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, ordinal: 0, text })
|
||||
yield* fixture.bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
return assistantMessageID
|
||||
}),
|
||||
)
|
||||
yield* fixture.bus.publish(SessionEvent.Synthetic, { sessionID, text: "Trailing non-assistant message" })
|
||||
const expected = [
|
||||
yield* fixture.bus.publish(SessionEvent.Renamed, { sessionID, title: "Working" }),
|
||||
yield* fixture.bus.publish(Permission.Event.Asked, {
|
||||
sessionID,
|
||||
id: Permission.ID.create(),
|
||||
action: "shell",
|
||||
resources: ["pwd"],
|
||||
}),
|
||||
yield* fixture.bus.publish(Form.Event.Created, {
|
||||
form: Form.Info.make({
|
||||
id: Form.ID.create(),
|
||||
sessionID,
|
||||
title: "Continue?",
|
||||
fields: [{ key: "continue", type: "boolean" }],
|
||||
}),
|
||||
}),
|
||||
yield* fixture.bus.publish(SessionEvent.Execution.Succeeded, { sessionID }),
|
||||
]
|
||||
// Session work settles while the receiver still has not acknowledged the first POST.
|
||||
expect((yield* handle.get()).outcome).toBe("succeeded")
|
||||
response.resolve()
|
||||
const delivered = yield* Effect.forEach(expected, () => Queue.take(received)).pipe(Effect.timeout("2 seconds"))
|
||||
expect(delivered).toMatchObject(expected)
|
||||
expect(delivered.at(-1)).toMatchObject({
|
||||
session: {
|
||||
id: sessionID,
|
||||
title: "Working",
|
||||
outcome: "succeeded",
|
||||
location: { directory: "/project/moved" },
|
||||
time: { created: expect.any(Number), updated: expect.any(Number), idle: expect.any(Number) },
|
||||
},
|
||||
response: {
|
||||
id: responses.at(-1),
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "The review is ready." }],
|
||||
time: { created: expect.any(Number), completed: expect.any(Number) },
|
||||
},
|
||||
})
|
||||
yield* handle.rename({ title: "After completion" })
|
||||
expect(Option.isNone(yield* Queue.take(received).pipe(Effect.timeoutOption("50 millis")))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("replaces callbacks and isolates HTTP failures without following redirects", () =>
|
||||
Effect.gen(function* () {
|
||||
const received = yield* Queue.unbounded<{ path: string; body: unknown }>()
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const path = new URL(request.url).pathname
|
||||
Queue.offerUnsafe(received, { path, body: await request.json() })
|
||||
return new Response(null, {
|
||||
status: path === "/redirect" ? 307 : 503,
|
||||
headers: { location: new URL("/unexpected", request.url).href },
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
const fixture = yield* setup()
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const prompt = yield* handle.prompt({ text: "First", resume: false, callbackUrl: `${server.url}old` })
|
||||
yield* handle.prompt({ id: prompt.id, text: "Retry", resume: false, callbackUrl: `${server.url}failed` })
|
||||
const started = yield* fixture.bus.publish(SessionEvent.Execution.Started, { sessionID })
|
||||
const failed = yield* fixture.bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID,
|
||||
error: { type: "unknown", message: "Run failed" },
|
||||
})
|
||||
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
|
||||
path: "/failed",
|
||||
body: started,
|
||||
})
|
||||
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
|
||||
path: "/failed",
|
||||
body: failed,
|
||||
})
|
||||
|
||||
yield* handle.prompt({ text: "Redirect", resume: false, callbackUrl: `${server.url}redirect` })
|
||||
const deleted = yield* fixture.bus.publish(SessionEvent.Deleted, { sessionID })
|
||||
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toEqual({
|
||||
path: "/redirect",
|
||||
body: { ...deleted, session: null, response: null },
|
||||
})
|
||||
expect(Option.isNone(yield* Queue.take(received).pipe(Effect.timeoutOption("50 millis")))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps the first admission across handles, including delivered retries and identity conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
@@ -363,7 +522,7 @@ describe("Session-owned handles", () => {
|
||||
expect(fixture.locations).toEqual([source])
|
||||
yield* prompt
|
||||
expect(fixture.locations).toEqual([source, destination])
|
||||
expect(fixture.flushes).toEqual([source, destination])
|
||||
expect(fixture.activationWaits).toEqual([source, destination])
|
||||
expect((yield* fixture.sessions.forSession(otherID).get()).location).toEqual(source)
|
||||
}),
|
||||
)
|
||||
@@ -408,7 +567,7 @@ describe("Session-owned handles", () => {
|
||||
expect(
|
||||
events.filter((event) => event.type === SessionEvent.Skill.Activated.type).map((event) => event.location),
|
||||
).toEqual([undefined, undefined, source])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.activationWaits).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
@@ -442,7 +601,7 @@ describe("Session-owned handles", () => {
|
||||
expect(yield* handle.get()).toEqual(before)
|
||||
expect(yield* handle.inbox()).toEqual([])
|
||||
expect(yield* fixture.store.context(sessionID)).toEqual([])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.activationWaits).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
@@ -776,7 +935,7 @@ describe("Session-owned handles", () => {
|
||||
|
||||
expect(captures).toEqual([source, destination])
|
||||
expect(fixture.locations).toEqual([source, destination, destination])
|
||||
expect(fixture.flushes).toEqual([source, destination, destination])
|
||||
expect(fixture.activationWaits).toEqual([source, destination, destination])
|
||||
expect((yield* handle.get()).revert).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
@@ -800,7 +959,7 @@ describe("SessionPrompt construction", () => {
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(PluginHooks.Service, fixture.hooks),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.sync(() => {
|
||||
awaitActivation: Effect.sync(() => {
|
||||
calls.push("ready")
|
||||
}),
|
||||
}),
|
||||
@@ -842,8 +1001,8 @@ describe("SessionRevert construction", () => {
|
||||
Effect.provide(
|
||||
Layer.merge(
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.sync(() => {
|
||||
calls.push("flush")
|
||||
awaitActivation: Effect.sync(() => {
|
||||
calls.push("awaitActivation")
|
||||
}),
|
||||
}),
|
||||
Layer.mock(Snapshot.Service, {
|
||||
@@ -871,7 +1030,7 @@ describe("SessionRevert construction", () => {
|
||||
yield* revert
|
||||
.stage({ session, messageID: boundary.id, files: false })
|
||||
.pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
|
||||
expect(calls).toEqual(["flush", "capture", "capture", "diff"])
|
||||
expect(calls).toEqual(["awaitActivation", "capture", "capture", "diff"])
|
||||
|
||||
const staged = yield* handle.get()
|
||||
expect(staged.revert?.snapshot).toBe(Snapshot.ID.make("captured-tree"))
|
||||
@@ -879,7 +1038,15 @@ describe("SessionRevert construction", () => {
|
||||
const cleared = yield* handle.get()
|
||||
expect(cleared.revert).toBeUndefined()
|
||||
yield* revert.clear(cleared).pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
|
||||
expect(calls).toEqual(["flush", "capture", "capture", "diff", "flush", "restore", "flush"])
|
||||
expect(calls).toEqual([
|
||||
"awaitActivation",
|
||||
"capture",
|
||||
"capture",
|
||||
"diff",
|
||||
"awaitActivation",
|
||||
"restore",
|
||||
"awaitActivation",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ const setup = Effect.gen(function* () {
|
||||
const services = locations.get(session.location)
|
||||
const hooks = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
return yield* PluginHooks.Service
|
||||
}).pipe(Effect.provide(services))
|
||||
return { sessions, session, hooks, services }
|
||||
|
||||
@@ -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"],
|
||||
@@ -2017,6 +2143,9 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"callbackUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["text"],
|
||||
@@ -16772,6 +16901,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,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionStats } from "@opencode-ai/schema/session-stats"
|
||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, optional, statics } from "@opencode-ai/schema/schema"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
@@ -343,6 +343,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
metadata: SessionInbox.UserPayload.fields.metadata,
|
||||
delivery: SessionInbox.Delivery.pipe(Schema.optional),
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
callbackUrl: Schema.String.pipe(optional).annotate({
|
||||
description:
|
||||
"HTTP(S) URL for best-effort JSON POSTs of execution, rename, permission and form events until execution settles or the session is deleted. Each event includes session and response fields containing the current Session.Info and latest assistant message in HTTP API JSON format, or null when unavailable. Replaces the active callback for this session. Process-local, without retries; the URL is not stored in prompt history.",
|
||||
}),
|
||||
}),
|
||||
success: Schema.Struct({ data: SessionInbox.User }),
|
||||
error: [ConflictError, InvalidRequestError, SessionNotFoundError],
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -289,6 +289,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.prompt",
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.payload.callbackUrl !== undefined) {
|
||||
const url = URL.parse(ctx.payload.callbackUrl)
|
||||
if (!url || !["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
||||
return yield* new InvalidRequestError({
|
||||
message: "Expected an HTTP(S) callback URL without credentials",
|
||||
field: "callbackUrl",
|
||||
})
|
||||
}
|
||||
return {
|
||||
data: yield* session
|
||||
.prompt({
|
||||
@@ -301,6 +309,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
metadata: ctx.payload.metadata,
|
||||
delivery: ctx.payload.delivery,
|
||||
resume: ctx.payload.resume,
|
||||
callbackUrl: ctx.payload.callbackUrl,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Effect, Schedule, Schema } from "effect"
|
||||
import { Effect, Queue, Schedule, Schema } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
@@ -118,6 +118,73 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("validates prompt callback URLs and delivers real execution events over HTTP", () =>
|
||||
Effect.gen(function* () {
|
||||
const received = yield* Queue.unbounded<unknown>()
|
||||
const receiver = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
Queue.offerUnsafe(received, await request.json())
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
const created = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: { id: "missing-model", providerID: "missing-provider" } }),
|
||||
}),
|
||||
).then((response) => response.json()),
|
||||
)
|
||||
const endpoint = `http://opencode.local/api/session/${created.data.id}`
|
||||
yield* Effect.forEach(["not a URL", "ftp://example.test", "https://user:password@example.test"], (callbackUrl) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(`${endpoint}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: "Invalid callback", callbackUrl }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||
_tag: "InvalidRequestError",
|
||||
field: "callbackUrl",
|
||||
})
|
||||
}),
|
||||
)
|
||||
const submitted = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(`${endpoint}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: "Notify me", callbackUrl: receiver.url.href }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(submitted.status).toBe(200)
|
||||
expect(yield* Queue.take(received).pipe(Effect.timeout("5 seconds"))).toMatchObject({
|
||||
type: "session.execution.started",
|
||||
data: { sessionID: created.data.id },
|
||||
})
|
||||
expect(yield* Queue.take(received).pipe(Effect.timeout("5 seconds"))).toMatchObject({
|
||||
type: "session.execution.failed",
|
||||
data: { sessionID: created.data.id },
|
||||
session: { id: created.data.id, outcome: "failed" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("activates credentials through the HttpApi", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user