Compare commits

...

33 Commits

Author SHA1 Message Date
Kit Langton ae2ecd1ed3 Migrate custom provider tests to instance fixtures
Migrate the next custom provider/model config tests to Effect-aware instance fixtures while keeping timing neutral and behavior unchanged.
2026-05-18 17:49:26 +00:00
Kit Langton 159d271e1e refactor(sync): publish via EffectBridge.fork for codebase consistency (#28187) 2026-05-18 13:30:41 -04:00
Kit Langton 762850dfe5 Migrate provider config tests to instance fixtures
Convert the first provider env/config/filtering tests to Effect-aware instance fixtures while keeping behavior unchanged and documenting neutral timing.
2026-05-18 13:22:59 -04:00
Kit Langton b039702e8c feat(tui): add syntax highlighting for elixir, fsharp, r, make, vim, xml, agda (#28198) 2026-05-18 13:08:08 -04:00
opencode-agent[bot] 094886c848 chore: generate 2026-05-18 17:03:44 +00:00
Shoubhit Dash dc0297f4ac refactor(session): extract reference prompt helpers (#28197) 2026-05-18 22:32:18 +05:30
Kit Langton 3ab67f3280 Stabilize watcher test readiness (#28194) 2026-05-18 12:51:46 -04:00
Shoubhit Dash a2e6bd503b refactor(reference): split materialization state (#28190) 2026-05-18 22:14:33 +05:30
Aiden Cline 05ac345696 refactor(session): move prompt reminders out of core loop (#28082) 2026-05-18 11:36:38 -05:00
opencode-agent[bot] e3feca09b3 chore: generate 2026-05-18 16:20:46 +00:00
Kit Langton 896ad7b884 Speed up targeted opencode tests
Reduce avoidable setup costs in slow opencode tests while preserving reviewed coverage and recording the benchmark evidence for follow-up test-suite work.
2026-05-18 16:18:29 +00:00
opencode-agent[bot] 0a945219a9 chore: generate 2026-05-18 16:17:46 +00:00
Shoubhit Dash 94828eb44b refactor(repository): type cache failures (#28188) 2026-05-18 21:46:18 +05:30
Shoubhit Dash 96192495ae refactor(repository): add cache service (#28184) 2026-05-18 21:25:38 +05:30
opencode-agent[bot] f7b5576bcc chore: generate 2026-05-18 15:39:33 +00:00
Kit Langton cb35493242 fix(bus): acquire PubSub subscription eagerly to close /event race (#27959) 2026-05-18 11:38:05 -04:00
Shoubhit Dash 5bfd7fd16c refactor(repository): clarify reference domain (#28182) 2026-05-18 21:04:44 +05:30
opencode-agent[bot] 2932b41e64 chore: generate 2026-05-18 15:14:13 +00:00
Shoubhit Dash eb389c58eb refactor(reference): normalize config entries (#28178) 2026-05-18 20:42:41 +05:30
Shoubhit Dash 54ff0a669b test(reference): cover configured reference contracts (#28170) 2026-05-18 20:09:27 +05:30
opencode-agent[bot] e56999fd36 chore: generate 2026-05-18 12:31:37 +00:00
Simon Klee 1124315267 run: refresh prompt layout after paste (#28164)
Pasting into the prompt textarea left its layout stale until the next edit, so the visible content did not reflect the pasted text. Mark the layout dirty on paste and notify the content-change handler once the renderer is idle so the prompt updates immediately.
2026-05-18 12:30:20 +00:00
opencode-agent[bot] 2bf3f3041f chore: update nix node_modules hashes 2026-05-18 12:03:51 +00:00
Sebastian 6e4db5666a upgrade opentui to 0.2.14 (#28090) 2026-05-18 13:44:08 +02:00
Shoubhit Dash 564cde393e fix(tui): copy pasted prompt content (#28156) 2026-05-18 17:02:34 +05:30
opencode-agent[bot] c813927bb6 chore: generate 2026-05-18 11:07:52 +00:00
Simon Klee 5970c12d90 run: replay session history on interactive resume (#26880) 2026-05-18 13:06:27 +02:00
opencode-agent[bot] 116a4e33ba chore: generate 2026-05-18 11:06:05 +00:00
Shoubhit Dash 611e48c4ac fix(tui): collapse long tool output lines (#28148) 2026-05-18 16:34:34 +05:30
Brendan Allan 836a33198e fix(ui): fix question dock overflow and message part flex layout (#28142) 2026-05-18 17:55:27 +08:00
opencode-agent[bot] 7afd477d1a chore: generate 2026-05-18 09:28:20 +00:00
SpiritChen51 fe143df151 fix(ui): fallback to execCommand for clipboard copy when navigator.clipboard fails (#27993)
Co-authored-by: SpiritChen51 <spiritchen51@users.noreply.github.com>
2026-05-18 17:26:45 +08:00
Kagura e94aecaa08 fix(tui): use contrast-aware foreground for paste summary badge (#27969)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-18 08:38:40 +00:00
76 changed files with 4159 additions and 1474 deletions
+15 -15
View File
@@ -536,9 +536,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.13",
"@opentui/keymap": ">=0.2.13",
"@opentui/solid": ">=0.2.13",
"@opentui/core": ">=0.2.14",
"@opentui/keymap": ">=0.2.14",
"@opentui/solid": ">=0.2.14",
},
"optionalPeers": [
"@opentui/core",
@@ -721,9 +721,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.13",
"@opentui/keymap": "0.2.13",
"@opentui/solid": "0.2.13",
"@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.14",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1590,23 +1590,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.13", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.13", "@opentui/core-darwin-x64": "0.2.13", "@opentui/core-linux-arm64": "0.2.13", "@opentui/core-linux-x64": "0.2.13", "@opentui/core-win32-arm64": "0.2.13", "@opentui/core-win32-x64": "0.2.13" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-CFnke/uhuekinVIkcyeVF62VC35I4OTrw5MXUlKU18mMsjb9U1pzB0oBJp3us1oCHKd/KuaeCnsRz4zhEPThKA=="],
"@opentui/core": ["@opentui/core@0.2.14", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.14", "@opentui/core-darwin-x64": "0.2.14", "@opentui/core-linux-arm64": "0.2.14", "@opentui/core-linux-x64": "0.2.14", "@opentui/core-win32-arm64": "0.2.14", "@opentui/core-win32-x64": "0.2.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-17YCr3BqM9mhi/DdNVM+omgmrKQNIl0G5RzoaTFOHe4+OAhG+W3iooYi+WdsekJWSUOEwZqDRz0QBTZhOtgZsQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZREOhS54UkF2nd7keORI1NwFe2xQdX6NCA2Uft945NqsZ5+cBe4dqoPcn6Qe4WcSfysaZQBcN0eKo623v+hiNA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iS4NZQkOKX2EP5rsNjDcU7inDLcKhPaSBn8ENjDXKx2smOh7p/rgM2qlEaiLI3njtL784QoF+nxTzSXbEI6+Jw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-4q+sjMATKbx/YzEKjuB4LaYe9+vrK0jFzuHaKY/Xg/cLXD8yZ9OpnyHQMCs75ijTBNKZwrsRhCUV174KLsb4eQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft4ZwYHCV0VtRMwQtHH5mAgwqRLHEXP26DWcwtCZWDEHDvghClBR0cj9UZLH5JAKn/j7ds5hZDCCZz+nUiEHYA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-yPQuEdSLmZFvml4B4KevbcWqWFnSJ5xQPBTUX0Y9lEGgw8xEMkJH7QBPgGSDElTihYPu8/jTZKR0pKxknxYdbw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-t/EKD4+rlzWuwYAa6NzGCmiBOHvF+hzjNwExj+dnSqX5wK7TU+VHl+N2iYUl4VhhJK94kPP6BnrF5GcHnZGFLg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-8i1dR80/3mz5dOGja8+ui7SHl0FkaPF60YtKJhTYEOrvkhGpQoFVAx0YhjO3YM5cYQ4CkZyeD1+bpK4v65YSyQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Lvqmd92UZ+KZVnr0xU0jYj4XqnCSsBQJHS/FpYkJgZSAN7/4NmPlgMvQXIGW8a3BcFaGRKe8LuGpqM2E4oaX0Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-5tlUt/sV/fiELecwOqeIIQOgjtdyUmNTe828JGhwRdMWCKcKz/YPMo0gwXx/HFa3lNLThA9cKADdVMnCK6N0Ow=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jnuud29daaEoZNEp80dxUDLyUcwLr+g6SruHPyyWerOe7J10JE1ihJNkDlXLT7T49xdBGYRQlRkuNGwRfZWx5A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.13", "", { "os": "win32", "cpu": "x64" }, "sha512-okqYNxKeNeEr/4IngR+lBrOYjt/cIybLh5AjPzafyNELBTJj6yAFTRSRJxMzuEjRyY3CB3a/aBveEdZ3N8YkcA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-2ZUNh7yaAMUwAOK8oFEO28qXqPFrWPGGD0KHK1Gp97Th9XuVZniMLtbkbrFtDRh15+PVj7MyrG0N967W7rLr1A=="],
"@opentui/keymap": ["@opentui/keymap@0.2.13", "", { "dependencies": { "@opentui/core": "0.2.13" }, "peerDependencies": { "@opentui/react": "0.2.13", "@opentui/solid": "0.2.13", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Y/IVToeiBCm5KEkt8mGP9ZdIuW9NTavOdkxo5r1/lAbo9E4O9aELD97+nRh4BccRfvRALHcVcOoYTYEGCyKHHQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.14", "", { "dependencies": { "@opentui/core": "0.2.14" }, "peerDependencies": { "@opentui/react": "0.2.14", "@opentui/solid": "0.2.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Jd4F3S98D8bJcr41jk7KcsFwwQTA0GCNKb+LoDMkPwv00k+NV6XcrXPB3QlNeM/JrVbe55pyGaT/ynZklGYHRw=="],
"@opentui/solid": ["@opentui/solid@0.2.13", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.13", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-NQSuXj0e2Epae0z5nT7HZp7jpHTKgYAl7mwsMM/HJ/6BOtDFago2QaWIoi70gI+SsvO5z2YUREHUaQ7BI/rH3g=="],
"@opentui/solid": ["@opentui/solid@0.2.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-tSWiiwdh/J+crkjwHgd26HXAlJHYocwkN/eUoqSH3+Y/uO3c3qRnbzQz7zzds+j4XDQU/8e9QcZshYLvQT9c7w=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-LN6VLX8bXADSPGt67+YjP1Sy0QdQY+HFPq8iXN5nkck=",
"aarch64-linux": "sha256-3yhpjuCLwi7KCZZvsfabtc50hgznVfD8rcMZ9/JAnSs=",
"aarch64-darwin": "sha256-6spcWVHVBuM0SlWYTFrDvForGbtxFXxeVvJMC9thN+g=",
"x86_64-darwin": "sha256-hnD68Dexq/3EP/Sm3MnL88ezIvLLa6/nVzaV+bRpn9w="
"x86_64-linux": "sha256-qAkjcbc1nJqOnCrNQ0bnsM4WG2ii5K1JWS9ohAYdjus=",
"aarch64-linux": "sha256-Nb+F0e3CvQv+uLzHzj9JKp5hV78mCnlSqFXzgIvgR24=",
"aarch64-darwin": "sha256-BIXALWWrjEZLUKZrY6l6+scjZmKFscFxX26TvWOvXGQ=",
"x86_64-darwin": "sha256-3uaFXl/n6je7AzIfsY1pvt3Ln/U1Oshx3z7ohuVPEs8="
}
}
+3 -3
View File
@@ -35,9 +35,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.13",
"@opentui/keymap": "0.2.13",
"@opentui/solid": "0.2.13",
"@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.14",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
+2
View File
@@ -10,6 +10,8 @@
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
"bench:test": "bun run script/bench-test-suite.ts",
"profile:test": "bun run script/profile-test-files.ts",
"build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
+76
View File
@@ -296,5 +296,81 @@ export default {
],
},
},
{
filetype: "elixir",
wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
],
},
},
{
filetype: "fsharp",
wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
],
},
},
{
filetype: "r",
wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
],
},
},
{
filetype: "make",
aliases: ["makefile"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
],
},
},
{
filetype: "vim",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
],
},
},
{
filetype: "xml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
],
},
},
{
filetype: "agda",
wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
],
},
},
],
}
@@ -0,0 +1,52 @@
// Full-suite timing harness for the test-speed research in ../../perf/test-suite.md.
// Use this for periodic sanity checks; use profile-test-files.ts for discovery.
// Env: BENCH_WARMUPS=0 BENCH_RUNS=1 bun run bench:test
const warmups = Number(Bun.env.BENCH_WARMUPS ?? 0)
const runs = Number(Bun.env.BENCH_RUNS ?? 1)
const timings: number[] = []
if (!Number.isInteger(warmups) || warmups < 0) {
console.error("BENCH_WARMUPS must be a non-negative integer")
process.exit(1)
}
if (!Number.isInteger(runs) || runs < 1) {
console.error("BENCH_RUNS must be a positive integer")
process.exit(1)
}
for (const index of Array.from({ length: warmups + runs }, (_, index) => index)) {
const measured = index >= warmups
const label = measured ? `run ${index - warmups + 1}/${runs}` : `warmup ${index + 1}/${warmups}`
const start = performance.now()
console.log(`bench:test ${label}`)
const proc = Bun.spawn(["bun", "test", "--timeout", "30000"], {
cwd: import.meta.dir + "/..",
stdout: "inherit",
stderr: "inherit",
env: Bun.env,
})
const exitCode = await proc.exited
if (exitCode !== 0) {
console.error(`bench:test failed during ${label} with exit code ${exitCode}`)
process.exit(exitCode)
}
const seconds = (performance.now() - start) / 1000
console.log(`bench:test ${label} ${seconds.toFixed(3)}s`)
if (measured) timings.push(seconds)
}
const sorted = timings.toSorted((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)]
const mean = timings.reduce((sum, timing) => sum + timing, 0) / timings.length
const best = sorted[0] ?? median
const worst = sorted.at(-1) ?? median
console.log(
`bench:test median=${median.toFixed(3)}s mean=${mean.toFixed(3)}s best=${best.toFixed(3)}s worst=${worst.toFixed(3)}s`,
)
console.log(`METRIC test_suite_seconds=${median.toFixed(3)}`)
console.log(`METRIC test_suite_best_seconds=${best.toFixed(3)}`)
console.log(`METRIC test_suite_worst_seconds=${worst.toFixed(3)}`)
@@ -0,0 +1,42 @@
// Per-file profiler for finding candidate test-speed work; see ../../perf/test-suite.md
// for the benchmark notes, kept wins, and discarded experiments.
// Example: TEST_PROFILE_GLOB='test/server/**/*.test.ts' TEST_PROFILE_TOP=15 bun run profile:test
const pattern = Bun.env.TEST_PROFILE_GLOB ?? "test/**/*.test.{ts,tsx}"
const limit = Number(Bun.env.TEST_PROFILE_LIMIT ?? 0)
const timeout = Bun.env.TEST_PROFILE_TIMEOUT ?? "30000"
const files = Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: import.meta.dir + "/..", onlyFiles: true }))
.then((files) => files.toSorted())
.then((files) => (limit > 0 ? files.slice(0, limit) : files))
const results = []
for (const file of await files) {
const start = performance.now()
const proc = Bun.spawn(["bun", "test", "--timeout", timeout, file], {
cwd: import.meta.dir + "/..",
stdout: "pipe",
stderr: "pipe",
env: Bun.env,
})
const [output, error, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
const seconds = (performance.now() - start) / 1000
results.push({ file, seconds, exitCode })
console.log(`${exitCode === 0 ? "PASS" : "FAIL"} ${seconds.toFixed(3)}s ${file}`)
if (exitCode !== 0) console.log((output + error).trim())
}
const sorted = results.toSorted((a, b) => b.seconds - a.seconds)
console.log("\nSlowest test files:")
for (const result of sorted.slice(0, Number(Bun.env.TEST_PROFILE_TOP ?? 20))) {
console.log(`${result.seconds.toFixed(3)}s ${result.exitCode === 0 ? "PASS" : "FAIL"} ${result.file}`)
}
if (sorted[0]) {
console.log(`METRIC slowest_test_file_seconds=${sorted[0].seconds.toFixed(3)}`)
console.log(`METRIC profiled_test_files=${results.length}`)
}
if (results.some((result) => result.exitCode !== 0)) process.exit(1)
+29 -21
View File
@@ -37,8 +37,16 @@ export interface Interface {
properties: BusProperties<D>,
options?: { id?: string },
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
// subscribe / subscribeAll are eager: the underlying PubSub subscription is
// acquired in the caller's Scope at `yield*` time. Any publish after the
// yield is delivered, even if stream consumption starts later. The previous
// Stream-returning shape acquired the subscription lazily on first pull,
// opening a race window during which publishes were lost — see
// test/bus/bus-effect.test.ts RACE tests.
readonly subscribe: <D extends BusEvent.Definition>(
def: D,
) => Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope>
readonly subscribeAll: () => Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
@@ -109,26 +117,26 @@ export const layer = Layer.effect(
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
const subscribe = <D extends BusEvent.Definition>(
def: D,
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: def.type })
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
const subscription = yield* PubSub.subscribe(ps)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
return Stream.fromSubscription(subscription)
})
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: "*" })
const s = yield* InstanceState.get(state)
const subscription = yield* PubSub.subscribe(s.wildcard)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
return Stream.fromSubscription(subscription)
})
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
+30
View File
@@ -218,6 +218,15 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "show thinking blocks",
})
.option("replay", {
type: "boolean",
default: false,
describe: "replay visible session history on interactive resume",
})
.option("replay-limit", {
type: "number",
describe: "cap visible interactive replay to the newest N messages",
})
.option("interactive", {
alias: ["i"],
type: "boolean",
@@ -269,6 +278,21 @@ export const RunCommand = effectCmd({
die("--interactive cannot be used with --format json")
}
if (args.replay && !args.interactive) {
die("--replay requires --interactive")
}
if (args["replay-limit"] !== undefined && !args.interactive) {
die("--replay-limit requires --interactive")
}
if (
args["replay-limit"] !== undefined &&
(!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
) {
die("--replay-limit must be a positive integer")
}
if (args.interactive && !process.stdout.isTTY) {
die("--interactive requires a TTY stdout")
}
@@ -281,6 +305,8 @@ export const RunCommand = effectCmd({
}
}
const replay = args.replay || args["replay-limit"] !== undefined
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const directory = (() => {
if (!args.dir) return args.attach ? undefined : root
@@ -786,6 +812,8 @@ export const RunCommand = effectCmd({
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session || args.continue) && !args.fork,
replay,
replayLimit: args["replay-limit"],
agent,
model,
variant: args.variant,
@@ -821,6 +849,8 @@ export const RunCommand = effectCmd({
agent: args.agent,
model,
variant: args.variant,
replay,
replayLimit: args["replay-limit"],
files,
initialInput,
thinking,
@@ -7,7 +7,7 @@
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { useKeyboard, useRenderer } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -197,13 +197,45 @@ export function RunPromptBody(props: {
onContentChange: () => void
bind: (area?: TextareaRenderable) => void
}) {
const renderer = useRenderer()
let area: TextareaRenderable | undefined
let pasteTick: ReturnType<typeof setTimeout> | undefined
const refreshPasteLayout = () => {
if (pasteTick) {
clearTimeout(pasteTick)
}
pasteTick = setTimeout(() => {
pasteTick = undefined
if (!area || area.isDestroyed) {
return
}
// Paste can leave the textarea layout stale until the next edit.
area.getLayoutNode().markDirty()
renderer.requestRender()
void renderer
.idle()
.then(() => {
if (!area || area.isDestroyed) {
return
}
props.onContentChange()
})
.catch(() => {})
}, 0)
}
onMount(() => {
props.bind(area)
})
onCleanup(() => {
if (pasteTick) {
clearTimeout(pasteTick)
}
props.bind(undefined)
})
@@ -226,6 +258,9 @@ export function RunPromptBody(props: {
keyBindings={props.bindings()}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={() => {
refreshPasteLayout()
}}
onContentChange={props.onContentChange}
ref={(next) => {
area = next
@@ -51,6 +51,8 @@ type RunRuntimeInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
}
@@ -67,6 +69,8 @@ type RunLocalInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
}
@@ -490,6 +494,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
directory: ctx.directory,
sessionID: state.sessionID,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
limits: () => state.limits,
footer,
trace: log,
@@ -722,6 +728,8 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
resolveSession: () => {
if (session) {
@@ -774,6 +782,8 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
boot: async () => ({
sdk: input.sdk,
@@ -0,0 +1,188 @@
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { messagePrompt, type SessionMessages } from "./session.shared"
import type { FooterPatch, StreamCommit } from "./types"
type ReplayInput = {
messages: SessionMessages
permissions: PermissionRequest[]
questions: QuestionRequest[]
thinking: boolean
limits: Record<string, number>
}
export type SessionReplay = {
data: SessionData
commits: StreamCommit[]
patch?: FooterPatch
}
type ReplayMessage = {
commits: StreamCommit[]
patch?: FooterPatch
}
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
return reduceSessionData({
data,
event,
sessionID,
thinking,
limits,
})
}
function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) {
if (!left) {
return right
}
if (!right) {
return left
}
return {
...left,
...right,
}
}
function active(data: SessionData) {
return data.part.size > 0 || data.tools.size > 0
}
function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
if (active(data)) {
if (!patch) {
return {
phase: "running",
} satisfies FooterPatch
}
return {
...patch,
phase: "running",
} satisfies FooterPatch
}
if (data.permissions.length > 0 || data.questions.length > 0) {
if (!patch) {
return {
phase: "idle",
} satisfies FooterPatch
}
return {
...patch,
phase: "idle",
} satisfies FooterPatch
}
if (!patch) {
return undefined
}
return {
...patch,
phase: "idle",
status: "",
} satisfies FooterPatch
}
function replayMessage(
data: SessionData,
message: SessionMessages[number],
thinking: boolean,
limits: Record<string, number>,
): ReplayMessage {
if (message.info.role === "user") {
const prompt = messagePrompt(message)
if (!prompt.text.trim()) {
return {
commits: [],
}
}
return {
commits: [
{
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: message.info.id,
},
],
}
}
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const info = apply(
data,
{
id: `bootstrap:message:${message.info.id}`,
type: "message.updated",
properties: {
sessionID: message.info.sessionID,
info: message.info,
},
},
message.info.sessionID,
thinking,
limits,
)
commits.push(...info.commits)
patch = mergePatch(patch, info.footer?.patch)
for (const part of message.parts) {
const next = apply(
data,
{
id: `bootstrap:part:${part.id}`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 0,
},
},
message.info.sessionID,
thinking,
limits,
)
patch = mergePatch(patch, next.footer?.patch)
commits.push(...next.commits)
}
return {
commits,
patch,
}
}
export function replaySession(input: ReplayInput): SessionReplay {
const data = createSessionData()
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
bootstrapSessionData({
data,
messages: input.messages,
permissions: input.permissions,
questions: input.questions,
})
for (const message of input.messages) {
const next = replayMessage(data, message, input.thinking, input.limits)
commits.push(...next.commits)
patch = mergePatch(patch, next.patch)
}
return {
data,
commits,
patch: replayPatch(data, patch),
}
}
@@ -60,7 +60,7 @@ function fileSource(
}
}
function prompt(msg: SessionMessages[number]): RunPrompt {
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
const parts: RunPrompt["parts"] = []
let text = msg.parts
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
@@ -135,7 +135,7 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
}
return {
prompt: prompt(msg),
prompt: messagePrompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.model.variant,
@@ -27,6 +27,7 @@ import {
reduceSessionData,
type SessionData,
} from "./session-data"
import { replaySession } from "./session-replay"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
@@ -66,6 +67,8 @@ type StreamInput = {
directory?: string
sessionID: string
thinking: boolean
replay?: boolean
replayLimit?: number
limits: () => Record<string, number>
footer: FooterApi
trace?: Trace
@@ -432,7 +435,12 @@ function createLayer(input: StreamInput) {
blockerTick: 0,
blockers: new Map(),
}
let booting = true
const buffered: Event[] = []
const replayedParts = new Set<string>()
const recovering = new Set<string>()
const tracked = (sessionID: string | undefined) =>
sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID))
const currentSubagentState = () => {
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
state.selectedSubagent = undefined
@@ -550,11 +558,11 @@ function createLayer(input: StreamInput) {
}
})
const messages = (sessionID: string, limit: number) =>
const messages = (sessionID: string, limit?: number) =>
Effect.promise(() =>
input.sdk.session.messages({
sessionID,
limit,
...(typeof limit === "number" ? { limit } : {}),
}),
).pipe(
Effect.map((item) => item.data ?? []),
@@ -596,7 +604,14 @@ function createLayer(input: StreamInput) {
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
const [messagesList, children, permissions, questions] = yield* Effect.all(
[
messages(input.sessionID, SUBAGENT_BOOTSTRAP_LIMIT),
messages(
input.sessionID,
input.replay
? input.replayLimit === undefined
? undefined
: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT)
: SUBAGENT_BOOTSTRAP_LIMIT,
),
Effect.promise(() =>
input.sdk.session.children({
sessionID: input.sessionID,
@@ -619,12 +634,52 @@ function createLayer(input: StreamInput) {
},
)
bootstrapSessionData({
data: state.data,
messages: messagesList,
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
questions: questions.filter((item) => item.sessionID === input.sessionID),
})
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
const history = input.replay
? replaySession({
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
: undefined
const replay =
history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
? replaySession({
messages: messagesList.slice(-input.replayLimit),
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
: history
replayedParts.clear()
if (history) {
state.data = history.data
}
if (!history) {
bootstrapSessionData({
data: state.data,
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
})
}
if (replay) {
for (const [partID] of replay.data.text) {
if (!replay.data.part.has(partID)) {
continue
}
replayedParts.add(partID)
}
}
bootstrapSubagentData({
data: state.subagent,
messages: messagesList,
@@ -632,6 +687,7 @@ function createLayer(input: StreamInput) {
permissions,
questions,
})
clearFinishedSubagents(state.subagent)
for (const request of [
...state.data.permissions,
@@ -642,9 +698,29 @@ function createLayer(input: StreamInput) {
seedBlocker(request.id)
}
if (replay) {
const activeCommitIDs = new Set([...state.data.part.keys(), ...state.data.tools])
for (const commit of replay.commits) {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
if (commit.partID && activeCommitIDs.has(commit.partID)) {
continue
}
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
}
}
const snapshot = currentSubagentState()
traceTabs(input.trace, [], snapshot.tabs)
syncFooter([], undefined, snapshot)
syncFooter([], replay?.patch, snapshot)
if (replay) {
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
}
booting = false
yield* drainBuffered()
const sessions = [...state.subagent.tabs.keys()]
if (sessions.length === 0) {
@@ -738,6 +814,86 @@ function createLayer(input: StreamInput) {
})
}
const applyEvent = Effect.fn("RunStreamTransport.applyEvent")(function* (event: Event) {
if (event.type === "message.part.delta" && event.properties.sessionID === input.sessionID) {
if (replayedParts.has(event.properties.partID)) {
const seen = state.data.text.get(event.properties.partID) ?? ""
if (seen.endsWith(event.properties.delta)) {
return
}
replayedParts.delete(event.properties.partID)
}
}
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(event)
yield* mark(event)
})
const drainBuffered = Effect.fn("RunStreamTransport.drainBuffered")(function* () {
let pending = buffered.splice(0)
while (pending.length > 0) {
const next: Event[] = []
let changed = false
for (const event of pending) {
if (!tracked(sid(event))) {
next.push(event)
continue
}
changed = true
yield* applyEvent(event)
}
if (!changed) {
buffered.push(...next)
return
}
pending = next
}
})
const watch = Effect.fn("RunStreamTransport.watch")(() =>
Stream.fromAsyncIterable(events.stream, (error) =>
error instanceof Error ? error : new Error(String(error)),
@@ -762,53 +918,25 @@ function createLayer(input: StreamInput) {
}
const sessionID = sid(event)
if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) {
if (booting) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
}
return
}
if (!tracked(sessionID)) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
}
return
}
input.trace?.write("recv.event", event)
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(event)
yield* mark(event)
yield* applyEvent(event)
yield* drainBuffered()
}),
),
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
@@ -823,8 +951,8 @@ function createLayer(input: StreamInput) {
),
)
yield* bootstrap()
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
yield* bootstrap()
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
if (closed || next.signal?.aborted || input.footer.isClosed) {
@@ -420,9 +420,26 @@ function ensureBlockerTab(
title: string | undefined,
kind: "permission" | "question",
) {
if (data.tabs.has(sessionID)) {
const current = data.tabs.get(sessionID)
if (current) {
ensureDetail(data, sessionID)
return false
if (current.status !== "running") {
return false
}
const next = {
...current,
description: kind === "permission" ? "Pending permission" : "Pending question",
status: "running" as const,
title: current.title ?? title,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
data.tabs.set(sessionID, {
@@ -52,6 +52,8 @@ export type RunInput = {
sessionID: string
sessionTitle?: string
resume?: boolean
replay?: boolean
replayLimit?: number
agent: string | undefined
model: PromptModel | undefined
variant: string | undefined
@@ -19,7 +19,7 @@ import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
import { Reference } from "@/reference/reference"
import type { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
function removeLineRange(input: string) {
@@ -310,7 +310,7 @@ export function Autocomplete(props: {
`Referenced configured reference @${reference.name}.`,
...(reference.kind === "local" ? ["Kind: local directory"] : []),
...(reference.kind === "git" ? ["Kind: git repository"] : []),
...(reference.kind === "invalid" ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "invalid" && reference.repository ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
@@ -324,7 +324,7 @@ export function Autocomplete(props: {
const references = createMemo(() =>
Reference.resolveAll({
references: (sync.data.config.reference ?? {}) as NonNullable<Config.Info["reference"]>,
references: ConfigReference.normalize(sync.data.config.reference ?? {}),
directory: sync.path.directory || process.cwd(),
worktree: sync.path.worktree || sync.path.directory || process.cwd(),
}),
@@ -28,7 +28,7 @@ import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { assign, expandPastedTextPlaceholders } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -1544,6 +1544,9 @@ export function Prompt(props: PromptProps) {
}}
ref={(r: TextareaRenderable) => {
input = r
Object.assign(r, {
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
})
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
@@ -14,3 +14,10 @@ export function assign(part: Item): Item & { id: PartID } {
id: PartID.ascending(),
}
}
export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["parts"]) {
return parts.reduce((result, part) => {
if (part.type !== "text" || !part.source?.text) return result
return result.replace(part.source.text.value, part.text)
}, text)
}
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
{
scope: ["extmark.paste"],
style: {
foreground: theme.background,
foreground: selectedForeground(theme, theme.warning),
background: theme.warning,
bold: true,
},
@@ -30,6 +30,7 @@ import type {
ToolTextContent,
} from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { collapseToolOutput } from "../../util/collapse-tool-output"
const id = "internal:session-v2-debug"
const route = "session.v2.messages"
@@ -198,26 +199,28 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
function ShellMessage(props: { message: SessionMessageShell }) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi(props.message.output.trim()))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<BlockTool
title="# Shell"
spinner={!props.message.time.completed}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -518,14 +521,15 @@ type ToolProps = {
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Show
@@ -539,11 +543,11 @@ function GenericTool(props: ToolProps) {
<BlockTool
title={`# ${props.part.name} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -702,15 +706,17 @@ function BlockTool(props: {
function Bash(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Switch>
@@ -719,12 +725,12 @@ function Bash(props: ToolProps) {
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {command()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -114,6 +114,7 @@ type RuntimeState = {
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
pending: Map<string, ConfigPlugin.Origin>
dispose_timeout_ms: number
}
const log = Log.create({ service: "tui.plugin" })
@@ -394,7 +395,7 @@ async function syncPluginThemes(plugin: PluginEntry) {
}
}
function createPluginScope(load: PluginLoad, id: string) {
function createPluginScope(load: PluginLoad, id: string, disposeTimeoutMs: number) {
const ctrl = new AbortController()
let list: { key: symbol; fn: TuiDispose }[] = []
let done = false
@@ -436,14 +437,14 @@ function createPluginScope(load: PluginLoad, id: string) {
ctrl.abort()
const queue = [...list].reverse()
list = []
const until = Date.now() + DISPOSE_TIMEOUT_MS
const until = Date.now() + disposeTimeoutMs
for (const item of queue) {
const left = until - Date.now()
if (left <= 0) {
fail("timed out cleaning up tui plugin", {
path: load.spec,
id,
timeout: DISPOSE_TIMEOUT_MS,
timeout: disposeTimeoutMs,
})
break
}
@@ -454,7 +455,7 @@ function createPluginScope(load: PluginLoad, id: string) {
fail("timed out cleaning up tui plugin", {
path: load.spec,
id,
timeout: DISPOSE_TIMEOUT_MS,
timeout: disposeTimeoutMs,
})
break
}
@@ -523,7 +524,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (persist) writePluginEnabledState(state.api, plugin.id, true)
if (plugin.scope) return true
const scope = createPluginScope(plugin.load, plugin.id)
const scope = createPluginScope(plugin.load, plugin.id, state.dispose_timeout_ms)
const api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve()
.then(async () => {
@@ -1002,7 +1003,12 @@ let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; dispose?: () => void }) {
export async function init(input: {
api: HostPluginApi
config: TuiConfig.Resolved
dispose?: () => void
disposeTimeoutMs?: number
}) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
@@ -1052,7 +1058,7 @@ export async function dispose() {
state.dispose?.()
}
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void }) {
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void; disposeTimeoutMs?: number }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
@@ -1064,6 +1070,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
}
runtime = next
try {
@@ -84,6 +84,7 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
@@ -1696,12 +1697,12 @@ function GenericTool(props: ToolProps<any>) {
const ctx = use()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
@@ -1716,11 +1717,11 @@ function GenericTool(props: ToolProps<any>) {
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1871,14 +1872,16 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const ctx = use()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
const workdirDisplay = createMemo(() => {
@@ -1902,14 +1905,14 @@ function Shell(props: ToolProps<typeof ShellTool>) {
title={title()}
part={props.part}
spinner={isRunning()}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -0,0 +1,19 @@
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
return { output, overflow: false }
}
const preview = lines.slice(0, maxLines).join("\n")
if (Array.from(preview).length > maxChars) {
return {
output:
Array.from(preview)
.slice(0, Math.max(0, maxChars - 1))
.join("") + "…",
overflow: true,
}
}
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
}
@@ -7,6 +7,7 @@ type Toast = {
type FocusableSelectionTarget = {
hasSelection: () => boolean
getClipboardText?: (text: string) => string
}
type Renderer = {
@@ -23,10 +24,17 @@ type SelectionKeyEvent = {
}
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
const selection = renderer.getSelection()
if (!selection) return false
const text = selection.getSelectedText()
if (!text) return false
Clipboard.copy(text)
const focus = renderer.currentFocusedRenderable
const clipboardText =
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
Clipboard.copy(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
+46
View File
@@ -18,6 +18,52 @@ const Local = Schema.Struct({
})
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
export type Entry = Schema.Schema.Type<typeof Entry>
export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" })
export type Info = Schema.Schema.Type<typeof Info>
export type NormalizedEntry =
| {
kind: "local"
path: string
}
| {
kind: "git"
repository: string
branch?: string
}
| {
kind: "invalid"
message: string
}
export type NormalizedInfo = Record<string, NormalizedEntry>
export function validateAlias(name: string) {
if (name.length === 0) return "Reference alias must not be empty"
if (/[\/\s`,]/.test(name)) {
return "Reference alias must not contain /, whitespace, comma, or backtick"
}
}
export function normalizeEntry(entry: Entry): NormalizedEntry {
if (typeof entry === "string") {
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) {
return { kind: "local", path: entry }
}
return { kind: "git", repository: entry }
}
if ("path" in entry) return { kind: "local", path: entry.path }
return { kind: "git", repository: entry.repository, branch: entry.branch }
}
export function normalize(info: Info): NormalizedInfo {
return Object.fromEntries(
Object.entries(info).map(([name, entry]) => {
const aliasError = validateAlias(name)
return [name, aliasError ? { kind: "invalid" as const, message: aliasError } : normalizeEntry(entry)] as const
}),
)
}
@@ -160,6 +160,7 @@ export interface Interface {
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
timeout?: number,
) => Effect.Effect<void, WaitForSyncError>
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
}
@@ -946,12 +947,13 @@ export const layer = Layer.effect(
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
timeout = TIMEOUT,
) {
if (synced(state)) return
yield* Effect.catch(
waitEvent({
timeout: TIMEOUT,
timeout,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
+1 -1
View File
@@ -243,7 +243,7 @@ export const layer = Layer.effect(
}
// Subscribe to bus events, fiber interrupted when scope closes
yield* bus.subscribeAll().pipe(
yield* (yield* bus.subscribeAll()).pipe(
Stream.runForEach((input) =>
Effect.sync(() => {
for (const hook of hooks) {
+1 -1
View File
@@ -425,7 +425,7 @@ export const layer: Layer.Layer<
const initState = yield* InstanceState.make(
Effect.fn("Project.initState")(function* (ctx) {
yield* bus.subscribe(Command.Event.Executed).pipe(
yield* (yield* bus.subscribe(Command.Event.Executed)).pipe(
Stream.runForEach((payload) =>
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
),
+1 -1
View File
@@ -298,7 +298,7 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
yield* (yield* bus.subscribe(FileWatcher.Event.Updated)).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) =>
Effect.gen(function* () {
+73 -75
View File
@@ -3,14 +3,12 @@ import { Effect, Context, Layer, Scope } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository"
import { RepositoryCache } from "./repository-cache"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
export type Resolved =
| {
name: string
@@ -21,23 +19,25 @@ export type Resolved =
name: string
kind: "git"
repository: string
reference: RepositoryReference
reference: RemoteReference
path: string
branch?: string
}
| {
name: string
kind: "invalid"
repository: string
repository?: string
message: string
}
type State = {
references: Resolved[]
materializeAll: Effect.Effect<void>
materializeByPath: { path: string; run: Effect.Effect<void> }[]
materializeByPath: Materializer[]
}
type Materializer = { path: string; run: Effect.Effect<void> }
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly list: () => Effect.Effect<Resolved[]>
@@ -90,31 +90,75 @@ function containsReferencePath(referencePath: string, target: string) {
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
}
function uniqueGitReferences(references: Resolved[]) {
const seenPath = new Set<string>()
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git") return false
if (seenPath.has(reference.path)) return false
seenPath.add(reference.path)
return true
})
}
function materializeReference(cache: RepositoryCache.Interface, reference: Extract<Resolved, { kind: "git" }>) {
return cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository").pipe(
Effect.annotateLogs({ name: reference.name, cause }),
),
),
)
}
const materializers = Effect.fn("Reference.materializers")(function* (
cache: RepositoryCache.Interface,
references: Resolved[],
) {
return yield* Effect.forEach(
uniqueGitReferences(references),
Effect.fnUntraced(function* (reference) {
return { path: reference.path, run: yield* Effect.cached(materializeReference(cache, reference)) }
}),
{ concurrency: "unbounded" },
)
})
function materializeAll(input: { flags: RuntimeFlags.Info; materializers: Materializer[] }) {
if (!input.flags.experimentalScout) return Effect.void
return Effect.forEach(
input.materializers,
Effect.fnUntraced(function* (item) {
yield* item.run
}),
{ concurrency: 4, discard: true },
)
}
function materializeByPath(materializers: Materializer[], target: string) {
return materializers.find((item) => containsReferencePath(item.path, target))?.run ?? Effect.void
}
function containsGitReferencePath(references: Resolved[], target: string) {
return references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, target))
}
export function resolve(input: {
name: string
reference: ReferenceEntry
reference: ConfigReference.NormalizedEntry
directory: string
worktree: string
}): Resolved {
if (typeof input.reference === "string") {
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
}
return resolveGit({ name: input.name, repository: input.reference })
if (input.reference.kind === "invalid") {
return { name: input.name, kind: "invalid", message: input.reference.message }
}
if ("path" in input.reference) {
if (input.reference.kind === "local") {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
}
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
}
export function resolveAll(input: {
references: NonNullable<Config.Info["reference"]>
directory: string
worktree: string
}) {
export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]) => {
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
@@ -140,8 +184,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const flags = yield* RuntimeFlags.Service
@@ -149,53 +192,14 @@ export const layer = Layer.effect(
Effect.fn("Reference.state")(function* (ctx) {
const cfg = yield* config.get()
const references = resolveAll({
references: cfg.reference ?? {},
references: ConfigReference.normalize(cfg.reference ?? {}),
directory: ctx.directory,
worktree: ctx.worktree,
})
const seenPath = new Set<string>()
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git") return false
if (seenPath.has(reference.path)) return false
seenPath.add(reference.path)
return true
})
const materializeByPath = yield* Effect.forEach(
gitReferences,
Effect.fnUntraced(function* (reference) {
const run = yield* Effect.cached(
RepositoryCache.ensure(
{ reference: reference.reference, branch: reference.branch, refresh: true },
{ fs, git },
).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository").pipe(
Effect.annotateLogs({ name: reference.name, cause }),
),
),
),
)
return { path: reference.path, run }
}),
{ concurrency: "unbounded" },
)
const materializeByPath = yield* materializers(cache, references)
const materializeAllCached = yield* Effect.cached(materializeAll({ flags, materializers: materializeByPath }))
const materializeAll = yield* Effect.cached(
flags.experimentalScout
? Effect.gen(function* () {
yield* Effect.forEach(
materializeByPath,
Effect.fnUntraced(function* (item) {
yield* item.run
}),
{ concurrency: 4, discard: true },
)
})
: Effect.void,
)
return { references, materializeAll, materializeByPath }
return { references, materializeAll: materializeAllCached, materializeByPath }
}),
)
@@ -214,18 +218,13 @@ export const layer = Layer.effect(
if (!flags.experimentalScout) return
const full = normalizedTarget(target)
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
return yield* InstanceState.useEffect(
state,
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
)
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
}),
contains: Effect.fn("Reference.contains")(function* (target?: string) {
if (!flags.experimentalScout) return false
const full = normalizedTarget(target)
if (!full) return false
return yield* InstanceState.use(state, (s) =>
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
)
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
}),
})
}),
@@ -233,8 +232,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
@@ -1,5 +1,5 @@
import path from "path"
import { Effect } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { Git } from "@/git"
@@ -8,7 +8,8 @@ import {
sameRepositoryReference,
parseRepositoryReference,
validateRepositoryBranch,
type Reference as RepositoryReference,
isRemoteRepositoryReference,
type RemoteReference,
} from "@/util/repository"
export type Result = {
@@ -21,6 +22,82 @@ export type Result = {
branch?: string
}
export type EnsureInput = {
reference: RemoteReference
refresh?: boolean
branch?: string
}
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
"RepositoryCacheInvalidRepositoryError",
{
repository: Schema.String,
message: Schema.String,
},
) {}
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
"RepositoryCacheInvalidBranchError",
{
branch: Schema.String,
message: Schema.String,
},
) {}
export class CloneFailedError extends Schema.TaggedErrorClass<CloneFailedError>()("RepositoryCacheCloneFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class FetchFailedError extends Schema.TaggedErrorClass<FetchFailedError>()("RepositoryCacheFetchFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class CheckoutFailedError extends Schema.TaggedErrorClass<CheckoutFailedError>()(
"RepositoryCacheCheckoutFailedError",
{
repository: Schema.String,
branch: Schema.String,
message: Schema.String,
},
) {}
export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("RepositoryCacheResetFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class LockFailedError extends Schema.TaggedErrorClass<LockFailedError>()("RepositoryCacheLockFailedError", {
localPath: Schema.String,
message: Schema.String,
}) {}
export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationError>()(
"RepositoryCacheOperationError",
{
operation: Schema.String,
path: Schema.String,
message: Schema.String,
},
) {}
export type Error =
| InvalidRepositoryError
| InvalidBranchError
| CloneFailedError
| FetchFailedError
| CheckoutFailedError
| ResetFailedError
| LockFailedError
| CacheOperationError
export interface Interface {
ensure: (input: EnsureInput) => Effect.Effect<Result, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false) return "refreshed" as const
@@ -43,18 +120,53 @@ function resetTarget(input: {
return "HEAD"
}
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
input: {
reference: RepositoryReference
refresh?: boolean
branch?: string
},
function errorMessage(error: unknown) {
return error instanceof globalThis.Error ? error.message : String(error)
}
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidRepositoryError ||
error instanceof InvalidBranchError ||
error instanceof CloneFailedError ||
error instanceof FetchFailedError ||
error instanceof CheckoutFailedError ||
error instanceof ResetFailedError ||
error instanceof LockFailedError ||
error instanceof CacheOperationError
)
}
export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) {
const reference = parseRepositoryReference(repository)
if (!reference) {
return yield* new InvalidRepositoryError({
repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
})
}
if (!isRemoteRepositoryReference(reference)) {
return yield* new InvalidRepositoryError({ repository, message: "Local file repositories are not supported" })
}
return reference
})
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
try {
validateRepositoryBranch(branch)
} catch (error) {
return yield* new InvalidBranchError({ branch, message: errorMessage(error) })
}
})
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
input: EnsureInput,
services: {
fs: AppFileSystem.Interface
git: Git.Interface
},
) {
if (input.branch) validateRepositoryBranch(input.branch)
if (input.branch) yield* validateBranch(input.branch)
const repository = input.reference.label
const remote = input.reference.remote
@@ -62,10 +174,24 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
return yield* Effect.acquireUseRelease(
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })).pipe(
Effect.catch((error: unknown) =>
Effect.fail(new LockFailedError({ localPath, message: errorMessage(error) || `Failed to lock ${localPath}` })),
),
),
() =>
Effect.gen(function* () {
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(
Effect.catch((error: unknown) =>
Effect.fail(
new CacheOperationError({
operation: "ensure cache directory",
path: localPath,
message: errorMessage(error),
}),
),
),
)
const exists = yield* services.fs.existsSafe(localPath)
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
@@ -75,7 +201,17 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
if (exists && !reuse) {
yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
yield* services.fs.remove(localPath, { recursive: true }).pipe(
Effect.catch((error: unknown) =>
Effect.fail(
new CacheOperationError({
operation: "remove stale cache",
path: localPath,
message: errorMessage(error),
}),
),
),
)
}
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
@@ -91,14 +227,20 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
{ cwd: path.dirname(localPath) },
)
if (clone.exitCode !== 0) {
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
return yield* new CloneFailedError({
repository,
message: clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
})
}
}
if (status === "refreshed") {
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
if (fetch.exitCode !== 0) {
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
return yield* new FetchFailedError({
repository,
message: fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
})
}
if (input.branch) {
@@ -106,9 +248,12 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
cwd: localPath,
})
if (checkout.exitCode !== 0) {
throw new Error(
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
)
return yield* new CheckoutFailedError({
repository,
branch: input.branch,
message:
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
})
}
}
@@ -122,7 +267,10 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
if (reset.exitCode !== 0) {
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
return yield* new ResetFailedError({
repository,
message: reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
})
}
}
@@ -144,4 +292,23 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
)
})
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
return Service.of({
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
return yield* ensureWithServices(input, { fs, git })
}),
})
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
export * as RepositoryCache from "./repository-cache"
@@ -20,10 +20,11 @@ function eventData(data: unknown): Sse.Event {
function eventResponse(bus: Bus.Interface) {
return Effect.gen(function* () {
const context = yield* Effect.context()
const events = bus.subscribeAll().pipe(
Stream.provideContext(context),
// Subscribe eagerly: the bus subscription is acquired in the request scope
// at this yield, so any publish from now on is queued for the body-pump
// fiber to drain — closing the race where Stream.concat(server.connected,
// lazy-subscribe) used to drop publishes in the prefix-consume window.
const events = (yield* bus.subscribeAll()).pipe(
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
)
const heartbeat = Stream.tick("10 seconds").pipe(
+7 -222
View File
@@ -16,8 +16,6 @@ import { ProviderTransform } from "@/provider/transform"
import { SystemPrompt } from "./system"
import { Instruction } from "./instruction"
import { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "@/tool/registry"
import { ToolJsonSchema } from "@/tool/json-schema"
@@ -63,6 +61,8 @@ import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
import * as Database from "@/storage/db"
import { SessionTable } from "./session.sql"
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
import { SessionReminders } from "./reminders"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -83,88 +83,6 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
const log = Log.create({ service: "session.prompt" })
const elog = EffectLogger.create({ service: "session.prompt" })
type ReferencePromptMetadata = {
name: string
kind: "local" | "git" | "invalid"
path?: string
repository?: string
branch?: string
target?: string
targetPath?: string
problem?: string
source: { value: string; start: number; end: number }
}
function stringField(record: Record<string, unknown>, key: string) {
return typeof record[key] === "string" ? record[key] : undefined
}
function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined {
if (!input || typeof input !== "object" || Array.isArray(input)) return
const record = input as Record<string, unknown>
const name = stringField(record, "name")
const kind = stringField(record, "kind")
if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return
if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return
const source = record.source as Record<string, unknown>
const value = stringField(source, "value")
if (!value || typeof source.start !== "number" || typeof source.end !== "number") return
return {
name,
kind,
path: stringField(record, "path"),
repository: stringField(record, "repository"),
branch: stringField(record, "branch"),
target: stringField(record, "target"),
targetPath: stringField(record, "targetPath"),
problem: stringField(record, "problem"),
source: { value, start: source.start, end: source.end },
}
}
function referenceTextPart(input: {
reference: Reference.Resolved
source: ReferencePromptMetadata["source"]
target?: string
targetPath?: string
problem?: string
}): MessageV2.TextPartInput {
const metadata: ReferencePromptMetadata = {
name: input.reference.name,
kind: input.reference.kind,
...(input.reference.kind === "invalid"
? { repository: input.reference.repository }
: { path: input.reference.path }),
...(input.reference.kind === "git"
? { repository: input.reference.repository, branch: input.reference.branch }
: {}),
...(input.target === undefined ? {} : { target: input.target }),
...(input.targetPath ? { targetPath: input.targetPath } : {}),
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
source: input.source,
}
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
return {
type: "text",
synthetic: true,
text: [
`Referenced configured reference ${label}.`,
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
...(metadata.problem
? [`Problem: ${metadata.problem}`]
: [
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
]),
].join("\n"),
metadata: { reference: metadata },
}
}
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
@@ -382,143 +300,6 @@ export const layer = Layer.effect(
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
})
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
messages: MessageV2.WithParts[]
agent: Agent.Info
session: Session.Info
}) {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages
if (!flags.experimentalPlanMode) {
if (input.agent.name === "plan") {
userMessage.parts.push({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PROMPT_PLAN,
synthetic: true,
})
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
userMessage.parts.push({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
synthetic: true,
})
}
return input.messages
}
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
if (!(yield* fsys.existsSafe(plan))) return input.messages
const part = yield* sessions.updatePart({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`,
synthetic: true,
})
userMessage.parts.push(part)
return input.messages
}
if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const exists = yield* fsys.existsSafe(plan)
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
const part = yield* sessions.updatePart({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: `<system-reminder>
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
## Plan File Info:
${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
## Plan Workflow
### Phase 1: Initial Understanding
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type.
1. Focus on understanding the user's request and the code associated with their request
2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
- Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
- Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
- Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
- If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
### Phase 2: Design
Goal: Design an implementation approach.
Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
You can launch up to 1 agent(s) in parallel.
**Guidelines:**
- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
Examples of when to use multiple agents:
- The task touches multiple parts of the codebase
- It's a large refactor or architectural change
- There are many edge cases to consider
- You'd benefit from exploring different approaches
Example perspectives by task type:
- New feature: simplicity vs performance vs maintainability
- Bug fix: root cause vs workaround vs prevention
- Refactoring: minimal change vs clean architecture
In the agent prompt:
- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
- Describe requirements and constraints
- Request a detailed implementation plan
### Phase 3: Review
Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
1. Read the critical files identified by agents to deepen your understanding
2. Ensure that the plans align with the user's original request
3. Use question tool to clarify any remaining questions with the user
### Phase 4: Final Plan
Goal: Write your final plan to the plan file (the only file you can edit).
- Include only your recommended approach, not all alternatives
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
- Include the paths of critical files to be modified
- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)
### Phase 5: Call plan_exit tool
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning.
This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons.
**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
</system-reminder>`,
synthetic: true,
})
userMessage.parts.push(part)
return input.messages
})
const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
agent: Agent.Info
model: Provider.Model
@@ -1726,7 +1507,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = yield* insertReminders({ messages: msgs, agent, session })
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
Effect.provideService(RuntimeFlags.Service, flags),
Effect.provideService(AppFileSystem.Service, fsys),
Effect.provideService(Session.Service, sessions),
)
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
@@ -0,0 +1,70 @@
<system-reminder>
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
## Plan File Info:
${planInfo}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
## Plan Workflow
### Phase 1: Initial Understanding
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type.
1. Focus on understanding the user's request and the code associated with their request
2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
- Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
- Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
- Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
- If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
### Phase 2: Design
Goal: Design an implementation approach.
Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
You can launch up to 1 agent(s) in parallel.
**Guidelines:**
- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
Examples of when to use multiple agents:
- The task touches multiple parts of the codebase
- It's a large refactor or architectural change
- There are many edge cases to consider
- You'd benefit from exploring different approaches
Example perspectives by task type:
- New feature: simplicity vs performance vs maintainability
- Bug fix: root cause vs workaround vs prevention
- Refactoring: minimal change vs clean architecture
In the agent prompt:
- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
- Describe requirements and constraints
- Request a detailed implementation plan
### Phase 3: Review
Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
1. Read the critical files identified by agents to deepen your understanding
2. Ensure that the plans align with the user's original request
3. Use question tool to clarify any remaining questions with the user
### Phase 4: Final Plan
Goal: Write your final plan to the plan file (the only file you can edit).
- Include only your recommended approach, not all alternatives
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
- Include the paths of critical files to be modified
- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)
### Phase 5: Call plan_exit tool
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning.
This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons.
**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
</system-reminder>
@@ -0,0 +1,73 @@
import { Option, Schema } from "effect"
import { MessageV2 } from "../message-v2"
import { Reference } from "@/reference/reference"
const Source = Schema.Struct({
value: Schema.String,
start: Schema.Number,
end: Schema.Number,
})
export const ReferencePromptMetadata = Schema.Struct({
name: Schema.String,
kind: Schema.Literals(["local", "git", "invalid"]),
path: Schema.optional(Schema.String),
repository: Schema.optional(Schema.String),
branch: Schema.optional(Schema.String),
target: Schema.optional(Schema.String),
targetPath: Schema.optional(Schema.String),
problem: Schema.optional(Schema.String),
source: Source,
})
export type ReferencePromptMetadata = typeof ReferencePromptMetadata.Type
const decodeReferencePromptMetadata = Schema.decodeUnknownOption(ReferencePromptMetadata)
export function referencePromptMetadata(input: unknown) {
return Option.getOrUndefined(decodeReferencePromptMetadata(input))
}
export function referenceTextPart(input: {
reference: Reference.Resolved
source: ReferencePromptMetadata["source"]
target?: string
targetPath?: string
problem?: string
}): MessageV2.TextPartInput {
const metadata: ReferencePromptMetadata = {
name: input.reference.name,
kind: input.reference.kind,
...(input.reference.kind === "invalid"
? { repository: input.reference.repository }
: { path: input.reference.path }),
...(input.reference.kind === "git"
? { repository: input.reference.repository, branch: input.reference.branch }
: {}),
...(input.target === undefined ? {} : { target: input.target }),
...(input.targetPath ? { targetPath: input.targetPath } : {}),
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
source: input.source,
}
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
return {
type: "text",
synthetic: true,
text: [
`Referenced configured reference ${label}.`,
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
...(metadata.problem
? [`Problem: ${metadata.problem}`]
: [
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
]),
].join("\n"),
metadata: { reference: metadata },
}
}
export * as ReferencePrompt from "./reference"
@@ -0,0 +1,91 @@
import path from "path"
import { Effect } from "effect"
import { Agent } from "@/agent/agent"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import * as Session from "./session"
import PROMPT_PLAN from "./prompt/plan.txt"
import BUILD_SWITCH from "./prompt/build-switch.txt"
import PLAN_MODE from "./prompt/plan-mode.txt"
export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
messages: MessageV2.WithParts[]
agent: Agent.Info
session: Session.Info
}) {
const flags = yield* RuntimeFlags.Service
const fsys = yield* AppFileSystem.Service
const sessions = yield* Session.Service
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages
if (!flags.experimentalPlanMode) {
if (input.agent.name === "plan") {
userMessage.parts.push({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PROMPT_PLAN,
synthetic: true,
})
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
userMessage.parts.push({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
synthetic: true,
})
}
return input.messages
}
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const exists = yield* fsys.existsSafe(plan)
const part = yield* sessions.updatePart({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: exists
? `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`
: BUILD_SWITCH,
synthetic: true,
})
userMessage.parts.push(part)
return input.messages
}
if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const exists = yield* fsys.existsSafe(plan)
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
const part = yield* sessions.updatePart({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PLAN_MODE.replace("${planInfo}", () =>
exists
? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.`
: `No plan file exists yet. You should create your plan at ${plan} using the write tool.`,
),
synthetic: true,
})
userMessage.parts.push(part)
return input.messages
})
export * as SessionReminders from "./reminders"
+11 -7
View File
@@ -168,16 +168,20 @@ export const layer = Layer.effect(
fn: (evt: { properties: any }) => Effect.Effect<void, unknown>,
) =>
bus.subscribe(def as never).pipe(
Stream.runForEach((evt) =>
fn(evt).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.error("share subscriber failed", { type: def.type, cause })
}),
Effect.flatMap((stream) =>
stream.pipe(
Stream.runForEach((evt) =>
fn(evt).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.error("share subscriber failed", { type: def.type, cause })
}),
),
),
),
Effect.forkScoped,
),
),
Effect.forkScoped,
)
yield* watch(Session.Event.Updated, (evt) =>
+39 -53
View File
@@ -7,9 +7,7 @@ import { eq } from "drizzle-orm"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import type { InstanceContext } from "@/project/instance-context"
import { EventSequenceTable, EventTable } from "./event.sql"
import type { WorkspaceID } from "@/control-plane/schema"
import { EventID } from "./schema"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import type { DeepMutable } from "@opencode-ai/core/schema"
@@ -17,7 +15,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { attachWith } from "@/effect/run-service"
import { EffectBridge } from "@/effect/bridge"
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
// when writing to the database. Bus payloads (`Properties`) stay readonly —
@@ -51,10 +49,6 @@ export type SerializedEvent<Def extends Definition = Definition> = Event<Def> &
type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
type PublishContext = {
instance?: InstanceContext
workspace?: WorkspaceID
}
export interface Interface {
readonly run: <Def extends Definition>(
@@ -107,16 +101,14 @@ export const layer = Layer.effect(Service)(
}
const publish = !!options?.publish
const context = publish
? {
instance: yield* InstanceState.context,
workspace: yield* InstanceState.workspaceID,
}
: undefined
// Bridge captures handler-fiber refs (InstanceRef/WorkspaceRef) and the
// full Effect context, so the forked publish + GlobalBus emit run with
// the right state without a per-call attachWith.
const bridge = yield* EffectBridge.make()
process(def, event, {
bus,
bridge,
publish,
context,
ownerID: options?.ownerID,
experimentalWorkspaces: flags.experimentalWorkspaces,
})
@@ -154,12 +146,7 @@ export const layer = Layer.effect(Service)(
}
const { publish = true } = options || {}
const context = publish
? {
instance: yield* InstanceState.context,
workspace: yield* InstanceState.workspaceID,
}
: undefined
const bridge = yield* EffectBridge.make()
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
@@ -175,7 +162,7 @@ export const layer = Layer.effect(Service)(
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { bus, publish, context, experimentalWorkspaces: flags.experimentalWorkspaces })
process(def, event, { bus, bridge, publish, experimentalWorkspaces: flags.experimentalWorkspaces })
},
{
behavior: "immediate",
@@ -308,8 +295,8 @@ function process<Def extends Definition>(
event: Event<Def>,
options: {
bus: ProjectBus.Interface
bridge: EffectBridge.Shape
publish: boolean
context?: PublishContext
ownerID?: string
experimentalWorkspaces: boolean
},
@@ -351,37 +338,36 @@ function process<Def extends Definition>(
}
Database.effect(() => {
if (options?.publish) {
if (!options.context?.instance) {
throw new Error("SyncEvent.process: publish requires instance context")
}
const result = convertEvent(def.type, event.data)
const publish = (data: unknown) =>
Effect.runPromise(
attachWith(options.bus.publish(def, data as Properties<Def>, { id: event.id }), {
instance: options.context?.instance,
workspace: options.context?.workspace,
}),
)
if (result instanceof Promise) {
void result.then(publish)
} else {
void publish(result)
}
GlobalBus.emit("event", {
directory: options.context.instance.directory,
project: options.context.instance.project.id,
workspace: options.context.workspace,
payload: {
type: "sync",
syncEvent: {
type: versionedType(def.type, def.version),
...event,
},
},
})
if (!options.publish) return
const result = convertEvent(def.type, event.data)
// The bridge was built inside the caller's fiber so it already carries
// InstanceRef/WorkspaceRef and the full Effect context. Both the bus
// publish and the GlobalBus emit run inside the forked Effect so they
// share the same instance/workspace lookup.
const publish = (data: unknown) =>
options.bridge.fork(
Effect.gen(function* () {
yield* options.bus.publish(def, data as Properties<Def>, { id: event.id })
const instance = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: instance.directory,
project: instance.project.id,
workspace,
payload: {
type: "sync",
syncEvent: {
type: versionedType(def.type, def.version),
...event,
},
},
})
}),
)
if (result instanceof Promise) {
void result.then(publish)
} else {
publish(result)
}
})
})
+3 -1
View File
@@ -25,6 +25,7 @@ import { ProviderID, type ModelID } from "../provider/schema"
import { WebSearchTool } from "./websearch"
import { RepoCloneTool } from "./repo_clone"
import { RepoOverviewTool } from "./repo_overview"
import { RepositoryCache } from "@/reference/repository-cache"
import * as Log from "@opencode-ai/core/util/log"
import { LspTool } from "./lsp"
import * as Truncate from "./truncate"
@@ -93,6 +94,7 @@ export const layer: Layer.Layer<
| BackgroundJob.Service
| Provider.Service
| Git.Service
| RepositoryCache.Service
| Reference.Service
| LSP.Service
| Instruction.Service
@@ -378,7 +380,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Session.defaultLayer),
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
Layer.provide(Provider.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
Layer.provide(Reference.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
+10 -13
View File
@@ -1,9 +1,7 @@
import { Effect, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Git } from "@/git"
import DESCRIPTION from "./repo_clone.txt"
import * as Tool from "./tool"
import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository"
import { repositoryCachePath } from "@/util/repository"
import { RepositoryCache } from "@/reference/repository-cache"
export const Parameters = Schema.Struct({
@@ -28,19 +26,18 @@ type Metadata = {
branch?: string
}
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, RepositoryCache.Service>(
"repo_clone",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const cache = yield* RepositoryCache.Service
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
const reference = parseRemoteRepositoryReference(params.repository)
if (params.branch) validateRepositoryBranch(params.branch)
const reference = yield* RepositoryCache.parseRemoteReference(params.repository)
if (params.branch) yield* RepositoryCache.validateBranch(params.branch)
const repository = reference.label
const remote = reference.remote
@@ -59,10 +56,7 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
},
})
const result = yield* RepositoryCache.ensure(
{ reference, refresh: params.refresh, branch: params.branch },
{ fs, git },
)
const result = yield* cache.ensure({ reference, refresh: params.refresh, branch: params.branch })
return {
title: repository,
metadata: result,
@@ -74,7 +68,10 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
...(result.head ? [`HEAD: ${result.head}`] : []),
].join("\n"),
}
}).pipe(Effect.orDie),
}).pipe(
Effect.catchIf(RepositoryCache.isError, (error) => Effect.fail(new Error(error.message))),
Effect.orDie,
),
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
}),
)
+41 -17
View File
@@ -2,7 +2,7 @@ import path from "path"
import { fileURLToPath } from "url"
import { Global } from "@opencode-ai/core/global"
export type Reference = {
type BaseReference = {
host: string
path: string
segments: string[]
@@ -10,10 +10,20 @@ export type Reference = {
repo: string
remote: string
label: string
}
export type RemoteReference = BaseReference & {
protocol?: string
}
function normalize(input: string) {
export type FileReference = BaseReference & {
host: "file"
protocol: "file:"
}
export type Reference = RemoteReference | FileReference
function normalizeRepositoryInput(input: string) {
return input
.trim()
.replace(/^git\+/, "")
@@ -54,7 +64,7 @@ function githubRemote(pathname: string) {
return new URL(`${pathname}.git`, withSlash(base)).href
}
function build(input: { host: string; segments: string[]; remote?: string; protocol?: string }) {
function buildRemoteReference(input: { host: string; segments: string[]; remote?: string; protocol?: string }) {
const segments = input.segments.map(trimGitSuffix).filter(Boolean)
if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null
const pathname = segments.join("/")
@@ -69,10 +79,10 @@ function build(input: { host: string; segments: string[]; remote?: string; proto
remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`),
label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
protocol: input.protocol,
} satisfies Reference
} satisfies RemoteReference
}
function buildFile(input: { url: URL; remote: string }) {
function buildFileReference(input: { url: URL; remote: string }) {
const filePath = path.normalize(fileURLToPath(input.url))
const segments = filePath.split(/[\\/]+/).filter(Boolean)
if (!segments.length) return null
@@ -85,36 +95,38 @@ function buildFile(input: { url: URL; remote: string }) {
remote: input.remote,
label: filePath,
protocol: "file:",
} satisfies Reference
} satisfies FileReference
}
export function parseRepositoryReference(input: string) {
const cleaned = normalize(input)
const cleaned = normalizeRepositoryInput(input)
if (!cleaned) return null
const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
if (githubPrefixed) {
return buildRemoteReference({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
}
if (!cleaned.includes("://")) {
const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
if (scp) return buildRemoteReference({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
const direct = parts(cleaned)
if (direct.length >= 2 && hostLike(direct[0])) {
return build({ host: direct[0], segments: direct.slice(1) })
return buildRemoteReference({ host: direct[0], segments: direct.slice(1) })
}
if (direct.length === 2) {
return build({ host: "github.com", segments: direct })
return buildRemoteReference({ host: "github.com", segments: direct })
}
}
try {
const url = new URL(cleaned)
if (url.protocol === "file:") return buildFile({ url, remote: cleaned })
if (url.protocol === "file:") return buildFileReference({ url, remote: cleaned })
const pathname = parts(url.pathname)
const host = url.host
return build({
return buildRemoteReference({
host,
segments: pathname,
remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned,
@@ -125,10 +137,18 @@ export function parseRepositoryReference(input: string) {
}
}
export function isFileRepositoryReference(reference: Reference): reference is FileReference {
return reference.protocol === "file:"
}
export function isRemoteRepositoryReference(reference: Reference): reference is RemoteReference {
return !isFileRepositoryReference(reference)
}
export function parseRemoteRepositoryReference(input: string) {
const reference = parseRepositoryReference(input)
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
if (reference.protocol === "file:") throw new Error("Local file repositories are not supported")
if (!isRemoteRepositoryReference(reference)) throw new Error("Local file repositories are not supported")
return reference
}
@@ -141,7 +161,7 @@ export function validateRepositoryBranch(branch: string) {
}
export function parseGitHubRemote(input: string) {
const cleaned = normalize(input)
const cleaned = normalizeRepositoryInput(input)
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
const parsed = parseRepositoryReference(cleaned)
@@ -153,6 +173,10 @@ export function repositoryCachePath(input: Reference) {
return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments)
}
export function sameRepositoryReference(left: Reference, right: Reference) {
return left.host === right.host && left.path === right.path
export function repositoryCacheIdentity(input: Reference) {
return `${input.host}/${input.path}`
}
export function sameRepositoryReference(left: Reference, right: Reference) {
return repositoryCacheIdentity(left) === repositoryCacheIdentity(right)
}
+92 -6
View File
@@ -44,7 +44,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* ready.open
@@ -71,7 +71,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* ready.open
@@ -98,7 +98,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
Effect.gen(function* () {
if (evt.type === TestEvent.Warmup.type) {
yield* ready.open
@@ -129,7 +129,7 @@ describe("Bus (Effect-native)", () => {
const readyA = yield* Latch.make()
const readyB = yield* Latch.make()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* readyA.open
@@ -140,7 +140,7 @@ describe("Bus (Effect-native)", () => {
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* readyB.open
@@ -162,6 +162,92 @@ describe("Bus (Effect-native)", () => {
}),
)
// RACE 1: eager subscription means publishing immediately after yield*
// bus.subscribe is delivered. Regression for the old lazy `Stream.unwrap`
// shape where PubSub.subscribe ran on first pull and missed any publish
// in the hand-off window.
it.instance("eager subscribe: publish after yield* is delivered without consumer-activation race", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const stream = yield* bus.subscribe(TestEvent.Ping)
// Hand-off window: subscription is alive (we yielded). Publish goes
// straight into the subscription queue, even with no consumer running.
yield* bus.publish(TestEvent.Ping, { value: 99 })
const collected = yield* stream.pipe(
Stream.take(1),
Stream.runCollect,
Effect.timeout("400 millis"),
Effect.option,
)
expect(collected._tag).toBe("Some")
if (collected._tag === "Some") {
const arr = Array.from(collected.value)
expect(arr[0].properties.value).toBe(99)
}
}),
)
// RACE 2: same property for subscribeAll.
it.instance("eager subscribeAll: publish after yield* is delivered", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const stream = yield* bus.subscribeAll()
yield* bus.publish(TestEvent.Ping, { value: 42 })
const collected = yield* stream.pipe(
Stream.take(1),
Stream.runCollect,
Effect.timeout("400 millis"),
Effect.option,
)
expect(collected._tag).toBe("Some")
if (collected._tag === "Some") {
const arr = Array.from(collected.value)
expect(arr[0].type).toBe(TestEvent.Ping.type)
}
}),
)
// RACE 3: the /event-handler shape exactly. With eager subscription, the
// bus subscription is alive before Stream.concat ever starts. Publishes
// during the prefix consumption window are queued and delivered.
it.instance("eager subscribe: Stream.concat(initial, subscribe) delivers publish during prefix", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const sawInitial = yield* Deferred.make<void>()
const sawPublish = yield* Deferred.make<number>()
type Frame = { marker?: "initial"; value?: number }
const subscriptionStream = yield* bus.subscribe(TestEvent.Ping)
const handlerStream: Stream.Stream<Frame> = Stream.make({ marker: "initial" } as Frame).pipe(
Stream.concat(subscriptionStream.pipe(Stream.map((evt): Frame => ({ value: evt.properties.value })))),
)
yield* Stream.runForEach(handlerStream, (frame) =>
Effect.gen(function* () {
if (frame.marker === "initial") {
Deferred.doneUnsafe(sawInitial, Effect.void)
return
}
if (frame.value !== undefined) Deferred.doneUnsafe(sawPublish, Effect.succeed(frame.value))
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(sawInitial).pipe(Effect.timeout("1 second"))
yield* bus.publish(TestEvent.Ping, { value: 7 })
const got = yield* Deferred.await(sawPublish).pipe(Effect.timeout("1 second"), Effect.option)
expect(got._tag).toBe("Some")
if (got._tag === "Some") expect(got.value).toBe(7)
}),
)
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
@@ -174,7 +260,7 @@ describe("Bus (Effect-native)", () => {
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
Effect.gen(function* () {
if (evt.type === TestEvent.Warmup.type) {
yield* ready.open
@@ -96,6 +96,17 @@ function assistant(text: string, phase: StreamCommit["phase"] = "progress"): Str
}
}
function reasoning(text: string, phase: StreamCommit["phase"] = "progress"): StreamCommit {
return {
kind: "reasoning",
text,
phase,
source: "reasoning",
messageID: "msg-r-1",
partID: "part-r-1",
}
}
function user(text: string): StreamCommit {
return {
kind: "user",
@@ -392,6 +403,39 @@ test("inserts spacers for new visible groups", async () => {
}
})
test("renders replayed user, reasoning, and assistant output after completion", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(user("Hello you"))
take()
await out.scrollback.append(reasoning("Thinking: **Plan**\n\nSay hello.", "progress"))
await out.scrollback.complete()
take()
await out.scrollback.append(assistant("Hello.", "progress"))
await out.scrollback.complete()
take()
const output = lines.join("\n")
expect(output).toContain(" Hello you")
expect(output).toContain("Thinking:")
expect(output).toContain("Plan")
expect(output).toContain("Hello.")
} finally {
out.scrollback.destroy()
}
})
test("coalesces same-line tool progress into one snapshot", async () => {
const out = await setup()
@@ -0,0 +1,159 @@
import { describe, expect, test } from "bun:test"
import { replaySession } from "@/cli/cmd/run/session-replay"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
function userMessage(id: string, text: string): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text,
},
],
}
}
function assistantInfo(id: string) {
return {
id,
sessionID: "session-1",
role: "assistant" as const,
time: {
created: 2,
},
parentID: "msg-user-1",
modelID: "gpt-5",
providerID: "openai",
mode: "chat",
agent: "build",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
}
function assistantMessage(id: string, text: string): SessionMessages[number] {
return {
info: assistantInfo(id),
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text,
time: {
start: 2,
end: 3,
},
},
],
}
}
function runningToolMessage(id: string): SessionMessages[number] {
return {
info: assistantInfo(id),
parts: [
{
id: `${id}-tool`,
sessionID: "session-1",
messageID: id,
type: "tool",
callID: `${id}-call`,
tool: "bash",
state: {
status: "running",
input: {
command: "pwd",
},
time: {
start: 2,
},
},
},
],
}
}
describe("run session replay", () => {
test("replays persisted user and assistant history into scrollback commits", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Hello, whats the weather today?"),
assistantMessage("msg-1", "What city or ZIP code should I check?"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits).toEqual([
expect.objectContaining({
kind: "user",
text: "Hello, whats the weather today?",
phase: "start",
source: "system",
messageID: "msg-user-1",
}),
expect.objectContaining({
kind: "assistant",
text: "What city or ZIP code should I check?",
phase: "progress",
source: "assistant",
messageID: "msg-1",
}),
])
expect(out.patch).toEqual(
expect.objectContaining({
phase: "idle",
status: "",
}),
)
})
test("keeps the footer in a running state for resumed active tools", () => {
const out = replaySession({
messages: [runningToolMessage("msg-1")],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.patch).toEqual(
expect.objectContaining({
phase: "running",
status: "running bash",
}),
)
})
})
@@ -67,6 +67,22 @@ function idle(sessionID = "session-1") {
} satisfies SdkEvent
}
function retry(sessionID: string, attempt: number, message: string) {
return {
id: `evt-${sessionID}-retry-${attempt}`,
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt,
message,
next: 1,
},
},
} satisfies SdkEvent
}
function assistant(id: string) {
return {
id: `evt-${id}`,
@@ -290,12 +306,12 @@ function toolUpdated(part: SessionToolPart): SdkEvent {
}
}
function textDelta(messageID: string, partID: string, delta: string): SdkEvent {
function textDelta(messageID: string, partID: string, delta: string, sessionID = "session-1"): SdkEvent {
return {
id: `evt-${partID}-delta`,
type: "message.part.delta",
properties: {
sessionID: "session-1",
sessionID,
messageID,
partID,
field: "text",
@@ -331,6 +347,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
const commits: StreamCommit[] = []
const events: FooterEvent[] = []
let closed = false
let idleCalls = 0
const api: FooterApi = {
get isClosed() {
@@ -346,6 +363,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
fn?.(next)
},
idle() {
idleCalls += 1
return Promise.resolve()
},
close() {
@@ -356,7 +374,14 @@ function footer(fn?: (commit: StreamCommit) => void) {
},
}
return { api, commits, events }
return {
api,
commits,
events,
get idleCalls() {
return idleCalls
},
}
}
function sdk(
@@ -398,6 +423,355 @@ function sdk(
}
describe("run stream transport", () => {
test("does not replay persisted main-session history during bootstrap by default", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) =>
sessionID === "session-1"
? ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [
{
...textPart("text-1", "msg-1", "Hello."),
time: {
start: 1,
end: 2,
},
},
],
}),
])
: ok([]),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
expect(ui.commits).toEqual([])
expect(ui.idleCalls).toBe(0)
} finally {
src.close()
await transport.close()
}
})
test("replays persisted main-session history during bootstrap when enabled", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) =>
sessionID === "session-1"
? ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [
{
...textPart("text-1", "msg-1", "Hello."),
time: {
start: 1,
end: 2,
},
},
],
}),
])
: ok([]),
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
try {
await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello."))
expect(ui.idleCalls).toBeGreaterThan(0)
} finally {
src.close()
await transport.close()
}
})
test("caps replayed bootstrap history to the configured number of messages", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) =>
ok(
sessionID === "session-1"
? [
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [
{
...textPart("text-1", "msg-1", "Hello."),
time: {
start: 1,
end: 2,
},
},
],
}),
assistantMessage({
sessionID: "session-1",
id: "msg-2",
parts: [
{
...textPart("text-2", "msg-2", "World."),
time: {
start: 3,
end: 4,
},
},
],
}),
]
: [],
),
}),
sessionID: "session-1",
thinking: true,
replay: true,
replayLimit: 1,
limits: () => ({}),
footer: ui.api,
})
try {
await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
expect.objectContaining({
text: "World.",
}),
])
} finally {
src.close()
await transport.close()
}
})
test("skips buffered pre-bootstrap deltas already covered by replay history", async () => {
const src = eventFeed()
const ui = footer()
const gate = defer<void>()
let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
const task = createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) => {
if (sessionID !== "session-1") {
return ok([])
}
await gate.promise
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [textPart("text-1", "msg-1", "Hello")],
}),
])
},
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
try {
await Promise.resolve()
src.push(textDelta("msg-1", "text-1", "lo"))
gate.resolve()
transport = await task
await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
await Bun.sleep(20)
expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
expect.objectContaining({
text: "Hello",
}),
])
} finally {
src.close()
await transport?.close()
}
})
test("applies buffered pre-bootstrap deltas not yet persisted", async () => {
const src = eventFeed()
const ui = footer()
const gate = defer<void>()
let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
const task = createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) => {
if (sessionID !== "session-1") {
return ok([])
}
await gate.promise
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [textPart("text-1", "msg-1", "")],
}),
])
},
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
try {
await Promise.resolve()
src.push(textDelta("msg-1", "text-1", "Hello"))
gate.resolve()
transport = await task
await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
await Bun.sleep(20)
expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
expect.objectContaining({
text: "Hello",
}),
])
} finally {
src.close()
await transport?.close()
}
})
test("preserves running footer state for resumed active sessions", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) =>
sessionID === "session-1"
? ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [
runningTool({
sessionID: "session-1",
messageID: "msg-1",
id: "bash-1",
callID: "call-1",
tool: "bash",
body: {
command: "pwd",
},
}),
],
}),
])
: ok([]),
}),
sessionID: "session-1",
thinking: true,
replay: true,
limits: () => ({}),
footer: ui.api,
})
try {
const patch = await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.patch")
return item?.type === "stream.patch" ? item.patch : undefined
})
expect(patch).toEqual(
expect.objectContaining({
phase: "running",
status: "running bash",
}),
)
} finally {
src.close()
await transport.close()
}
})
test("drops completed historical subagent tabs during bootstrap", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) => {
if (sessionID !== "session-1") {
return ok([])
}
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [
completedTool({
sessionID: "session-1",
messageID: "msg-1",
id: "task-1",
callID: "call-1",
tool: "task",
body: {
description: "Explore run folder",
subagent_type: "explore",
},
metadata: {
sessionId: "child-1",
},
}),
],
}),
])
},
children: async () => ok([child("child-1")]),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
const state = await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.subagent")
return item?.type === "stream.subagent" ? item.state : undefined
})
expect(state.tabs).toEqual([])
expect(state.details).toEqual({})
} finally {
src.close()
await transport.close()
}
})
test("bootstraps child tabs and resumed blocker input", async () => {
const src = eventFeed()
const ui = footer()
@@ -487,7 +861,7 @@ describe("run stream transport", () => {
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
description: "Explore run folder",
description: "Pending permission",
status: "running",
}),
])
@@ -569,7 +943,7 @@ describe("run stream transport", () => {
sessionID: "session-1",
id: "msg-1",
parts: [
completedTool({
runningTool({
sessionID: "session-1",
messageID: "msg-1",
id: "task-1",
@@ -711,6 +1085,109 @@ describe("run stream transport", () => {
}
})
test("replays child events buffered during bootstrap once the tab is known", async () => {
const global = globalFeed()
const ui = footer()
const gate = defer<void>()
let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
const task = createSessionTransport({
sdk: sdk({
globalStream: global.stream,
messages: async ({ sessionID }) => {
if (sessionID !== "session-1") {
return ok([])
}
await gate.promise
return ok([])
},
children: async () => ok([]),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
await Promise.resolve()
global.push(globalEvent(retry("child-1", 1, "retry child")))
global.push(
globalEvent({
id: "evt-child-message",
type: "message.updated",
properties: {
sessionID: "child-1",
info: assistantMessage({
sessionID: "child-1",
id: "msg-child-1",
parts: [],
}).info,
},
}),
)
global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "", "child-1"))))
global.push(globalEvent(textDelta("msg-child-1", "txt-child-1", "Hello", "child-1")))
global.push(
globalEvent(
toolUpdated(
runningTool({
sessionID: "session-1",
messageID: "msg-1",
id: "task-1",
callID: "call-1",
tool: "task",
body: {
description: "Explore run.ts",
subagent_type: "explore",
},
metadata: {
sessionId: "child-1",
},
}),
),
),
)
gate.resolve()
transport = await task
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.subagent")
return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1")
? item
: undefined
})
transport.selectSubagent("child-1")
const detail = await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.subagent")
const next = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined
return next?.commits.some((commit) => commit.kind === "error" && commit.text === "retry child") &&
next.commits.some((commit) => commit.kind === "assistant" && commit.text === "Hello")
? next
: undefined
})
expect(detail).toEqual({
sessionID: "child-1",
commits: expect.arrayContaining([
expect.objectContaining({
kind: "error",
text: "retry child",
}),
expect.objectContaining({
kind: "assistant",
text: "Hello",
}),
]),
})
} finally {
global.close()
await transport?.close()
}
})
test("streams selected subagent output from global events while it is running", async () => {
const global = globalFeed()
const ui = footer()
@@ -205,10 +205,10 @@ test(
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config, disposeTimeoutMs: 25 })
const done = await new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve("timeout"), 7000)
const timer = setTimeout(() => resolve("timeout"), 500)
void TuiPluginRuntime.dispose().then(() => {
clearTimeout(timer)
resolve("done")
@@ -41,7 +41,10 @@ test("replace slot mounts plugin content once", async () => {
)
}
await testRender(() => <App />)
expect(mounts).toBe(1)
const app = await testRender(() => <App />)
try {
expect(mounts).toBe(1)
} finally {
app.renderer.destroy()
}
})
@@ -1101,9 +1101,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
},
})
// TODO: this is a hack to wait for backgruounded gitignore
await new Promise((resolve) => setTimeout(resolve, 1000))
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
} finally {
@@ -173,8 +173,12 @@ const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspa
Effect.provide(workspaceLayer(experimentalWorkspaces)),
),
)
const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
const waitForWorkspaceSync = (
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
timeout?: number,
) => runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal, timeout)))
function captureGlobalEvents() {
const events: GlobalEvent[] = []
@@ -1639,9 +1643,9 @@ describe("workspace waitForSync", () => {
await withInstance(async () => {
const sessionID = SessionID.descending("ses_wait_timeout")
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 })).rejects.toThrow(
`Timed out waiting for sync fence: {"${sessionID}":1}`,
)
await expect(
waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25),
).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`)
})
}, 7000)
})
+47 -17
View File
@@ -1,7 +1,8 @@
import { describe, expect } from "bun:test"
import path from "path"
import { realpath } from "fs/promises"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ConfigProvider, Deferred, Effect, Layer, Option } from "effect"
import { ConfigProvider, Deferred, Duration, Effect, Layer, Option } from "effect"
import { TestInstance, provideInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
@@ -78,23 +79,49 @@ function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
})
}
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
function maybeNextUpdate<E>(
directory: string,
check: (evt: WatcherEvent) => boolean,
trigger: Effect.Effect<void, E>,
timeout: Duration.Input = "5 seconds",
) {
return Effect.acquireUseRelease(
wait(directory, check),
({ deferred }) =>
Effect.gen(function* () {
yield* trigger
return yield* Deferred.await(deferred).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")),
}),
)
return yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeout))
}),
({ cleanup }) => Effect.sync(cleanup),
)
}
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
return Effect.gen(function* () {
const result = yield* maybeNextUpdate(directory, check, trigger)
if (Option.isSome(result)) return result.value
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
})
}
function eventuallyUpdate<E>(
directory: string,
check: (evt: WatcherEvent) => boolean,
trigger: () => Effect.Effect<void, E>,
) {
return Effect.gen(function* () {
while (true) {
const result = yield* maybeNextUpdate(directory, check, trigger(), "250 millis")
if (Option.isSome(result)) return result.value
}
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
}),
)
}
/** Effect that asserts no matching event arrives within `ms`. */
function noUpdate<E>(
directory: string,
@@ -125,22 +152,25 @@ function ready(directory: string) {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
yield* nextUpdate(
yield* eventuallyUpdate(
directory,
(evt) => evt.file === file && evt.event === "add",
fs.writeFileString(file, "ready"),
(evt) => evt.file === file,
() => fs.writeFileString(file, `ready-${Math.random()}`),
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
if (!(yield* fs.existsSafe(head))) return
const branch = `watch-${Math.random().toString(36).slice(2)}`
const realHead = yield* Effect.promise(() => realpath(head).catch(() => head))
const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
yield* nextUpdate(
yield* eventuallyUpdate(
directory,
(evt) => evt.file === head && evt.event !== "unlink",
fs
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))),
(evt) => (evt.file === head || evt.file === realHead) && evt.event !== "unlink",
() => {
const branch = `watch-${Math.random().toString(36).slice(2)}`
return fs
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`)))
},
).pipe(Effect.asVoid)
})
}
+10
View File
@@ -0,0 +1,10 @@
import { mkdir } from "fs/promises"
import path from "path"
export async function markPluginDependenciesReady(dir: string) {
await mkdir(path.join(dir, "node_modules"), { recursive: true })
await Bun.write(
path.join(dir, "package-lock.json"),
JSON.stringify({ packages: { "": { dependencies: { "@opencode-ai/plugin": "0.0.0" } } } }),
)
}
@@ -66,7 +66,7 @@ describe("plugin.install.concurrent", () => {
test("serializes concurrent server config updates across processes", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const all = mods("mod-server", 12)
const all = mods("mod-server", 6)
const out = await Promise.all(
all.map((mod) =>
@@ -89,7 +89,7 @@ describe("plugin.install.concurrent", () => {
test("serializes concurrent server+tui config updates across processes", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const all = mods("mod-both", 10)
const all = mods("mod-both", 6)
const out = await Promise.all(
all.map((mod) =>
@@ -118,7 +118,7 @@ describe("plugin.install.concurrent", () => {
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2))
const next = mods("mod-json", 8)
const next = mods("mod-json", 5)
const out = await Promise.all(
next.map((mod) =>
run({
+259 -343
View File
@@ -3,6 +3,7 @@ import { mkdir, unlink } from "fs/promises"
import path from "path"
import { disposeAllInstances, tmpdir, withTestInstance } from "../fixture/fixture"
import { markPluginDependenciesReady } from "../fixture/plugin"
import { Global } from "@opencode-ai/core/global"
import type { InstanceContext } from "../../src/project/instance-context"
import { Plugin } from "../../src/plugin/index"
@@ -28,15 +29,21 @@ function rememberEnv(k: string) {
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
}
const setProcessEnv = (k: string, v: string) =>
Effect.sync(() => {
rememberEnv(k)
process.env[k] = v
})
const set = (ctx: InstanceContext, k: string, v: string) => {
rememberEnv(k)
process.env[k] = v
return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
return env.runPromise((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
}
const remove = (ctx: InstanceContext, k: string) => {
rememberEnv(k)
delete process.env[k]
return env.runSync((svc) => svc.remove(k).pipe(Effect.provideService(InstanceRef, ctx)))
return env.runPromise((svc) => svc.remove(k).pipe(Effect.provideService(InstanceRef, ctx)))
}
afterEach(async () => {
@@ -96,14 +103,6 @@ async function defaultModel(ctx: InstanceContext) {
return run(ctx, (provider) => provider.defaultModel())
}
async function markPluginDependenciesReady(dir: string) {
await mkdir(path.join(dir, "node_modules"), { recursive: true })
await Bun.write(
path.join(dir, "package-lock.json"),
JSON.stringify({ packages: { "": { dependencies: { "@opencode-ai/plugin": "0.0.0" } } } }),
)
}
function paid(providers: Awaited<ReturnType<typeof list>>) {
const item = providers[ProviderID.make("opencode")]
expect(item).toBeDefined()
@@ -135,236 +134,159 @@ const alphaProviderConfig = {
},
}
test("provider loaded from env variable", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
// Provider should retain its connection source even if custom loaders
// merge additional options.
expect(providers[ProviderID.anthropic].source).toBe("env")
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
},
})
})
it.instance("provider loaded from env variable", () =>
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeDefined()
// Provider should retain its connection source even if custom loaders
// merge additional options.
expect(providers[ProviderID.anthropic].source).toBe("env")
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
}),
)
test("provider loaded from config with apiKey option", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
options: {
apiKey: "config-api-key",
it.instance(
"provider loaded from config with apiKey option",
Effect.gen(function* () {
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeDefined()
}),
{
config: {
provider: {
anthropic: {
options: {
apiKey: "config-api-key",
},
},
},
},
},
)
it.instance(
"disabled_providers excludes provider",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeUndefined()
}),
{ config: { disabled_providers: ["anthropic"] } },
)
it.instance(
"enabled_providers restricts to only listed providers",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
yield* setProcessEnv("OPENAI_API_KEY", "test-openai-key")
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
}),
{ config: { enabled_providers: ["anthropic"] } },
)
it.instance(
"model whitelist filters models for provider",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models.length).toBe(1)
}),
{
config: {
provider: {
anthropic: {
whitelist: ["claude-sonnet-4-20250514"],
},
},
},
},
)
it.instance(
"model blacklist excludes specific models",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).not.toContain("claude-sonnet-4-20250514")
}),
{
config: {
provider: {
anthropic: {
blacklist: ["claude-sonnet-4-20250514"],
},
},
},
},
)
it.instance(
"custom model alias via config",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
}),
{
config: {
provider: {
anthropic: {
models: {
"my-alias": {
id: "claude-sonnet-4-20250514",
name: "My Custom Alias",
},
},
},
},
},
},
)
it.instance(
"custom provider with npm package",
Effect.gen(function* () {
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
}),
{
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"custom-model": {
name: "Custom Model",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
},
})
})
test("disabled_providers excludes provider", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
disabled_providers: ["anthropic"],
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeUndefined()
},
})
})
test("enabled_providers restricts to only listed providers", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic"],
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
},
})
})
test("model whitelist filters models for provider", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
whitelist: ["claude-sonnet-4-20250514"],
},
options: {
apiKey: "custom-key",
},
}),
)
},
},
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models.length).toBe(1)
},
})
})
test("model blacklist excludes specific models", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
blacklist: ["claude-sonnet-4-20250514"],
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).not.toContain("claude-sonnet-4-20250514")
},
})
})
test("custom model alias via config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
models: {
"my-alias": {
id: "claude-sonnet-4-20250514",
name: "My Custom Alias",
},
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
},
})
})
test("custom provider with npm package", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"custom-model": {
name: "Custom Model",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
},
})
})
},
)
it.instance(
"filters alpha provider models by default",
@@ -386,66 +308,58 @@ experimentalModels.instance(
{ config: alphaProviderConfig },
)
test("custom DeepSeek openai-compatible model defaults interleaved reasoning field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": {
name: "DeepSeek R1",
},
"deepseek-details": {
name: "DeepSeek Details",
interleaved: { field: "reasoning_details" },
},
"custom-model": {
name: "Custom Model",
},
},
options: {
apiKey: "custom-key",
},
it.instance(
"custom DeepSeek openai-compatible model defaults interleaved reasoning field",
Effect.gen(function* () {
const providers = yield* Provider.Service.use((provider) => provider.list())
const provider = providers[ProviderID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
expect(providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe(
false,
)
}),
{
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": {
name: "DeepSeek R1",
},
"custom-anthropic-provider": {
name: "Custom Anthropic Provider",
npm: "@ai-sdk/anthropic",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": {
name: "DeepSeek R1",
},
},
options: {
apiKey: "custom-key",
},
"deepseek-details": {
name: "DeepSeek Details",
interleaved: { field: "reasoning_details" },
},
"custom-model": {
name: "Custom Model",
},
},
}),
)
options: {
apiKey: "custom-key",
},
},
"custom-anthropic-provider": {
name: "Custom Anthropic Provider",
npm: "@ai-sdk/anthropic",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": {
name: "DeepSeek R1",
},
},
options: {
apiKey: "custom-key",
},
},
},
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const providers = await list(ctx)
const provider = providers[ProviderID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
expect(
providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved,
).toBe(false)
},
})
})
},
)
test("env variable takes precedence, config merges options", async () => {
await using tmp = await tmpdir({
@@ -469,7 +383,7 @@ test("env variable takes precedence, config merges options", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "env-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "env-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
// Config options should be merged
@@ -493,7 +407,7 @@ test("getModel returns model for valid provider/model", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
@@ -518,7 +432,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"), ctx)).rejects.toThrow()
},
})
@@ -569,7 +483,7 @@ test("defaultModel returns first available model when no config set", async () =
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel(ctx)
expect(model.providerID).toBeDefined()
expect(model.modelID).toBeDefined()
@@ -592,7 +506,7 @@ test("defaultModel respects config model setting", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel(ctx)
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
@@ -725,7 +639,7 @@ test("closest finds model by partial match", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const result = await closest(ProviderID.anthropic, ["sonnet-4"], ctx)
expect(result).toBeDefined()
expect(String(result?.providerID)).toBe("anthropic")
@@ -778,7 +692,7 @@ test("getModel uses realIdByKey for aliased models", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
@@ -891,7 +805,7 @@ test("model inherits properties from existing database model", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet")
@@ -917,7 +831,7 @@ test("disabled_providers prevents loading even with env var", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "OPENAI_API_KEY", "test-openai-key")
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(providers[ProviderID.openai]).toBeUndefined()
},
@@ -939,8 +853,8 @@ test("enabled_providers with empty array allows no providers", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
set(ctx, "OPENAI_API_KEY", "test-openai-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(Object.keys(providers).length).toBe(0)
},
@@ -967,7 +881,7 @@ test("whitelist and blacklist can be combined", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
@@ -1074,7 +988,7 @@ test("getSmallModel returns appropriate small model", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getSmallModel(ProviderID.anthropic, ctx)
expect(model).toBeDefined()
expect(model?.id).toContain("haiku")
@@ -1097,7 +1011,7 @@ test("getSmallModel respects config small_model override", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getSmallModel(ProviderID.anthropic, ctx)
expect(model).toBeDefined()
expect(String(model?.providerID)).toBe("anthropic")
@@ -1121,7 +1035,7 @@ test("getSmallModel ignores invalid config small_model", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
expect(await getSmallModel(ProviderID.anthropic, ctx)).toBeUndefined()
},
})
@@ -1164,8 +1078,8 @@ test("multiple providers can be configured simultaneously", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
set(ctx, "OPENAI_API_KEY", "test-openai-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeDefined()
@@ -1241,7 +1155,7 @@ test("model alias name defaults to alias key when id differs", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
},
@@ -1279,7 +1193,7 @@ test("provider with multiple env var options only includes apiKey when single en
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "MULTI_ENV_KEY_1", "test-key")
await set(ctx, "MULTI_ENV_KEY_1", "test-key")
const providers = await list(ctx)
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
// When multiple env options exist, key should NOT be auto-set
@@ -1319,7 +1233,7 @@ test("provider with single env var includes apiKey automatically", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "SINGLE_ENV_KEY", "my-api-key")
await set(ctx, "SINGLE_ENV_KEY", "my-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.make("single-env")]).toBeDefined()
// Single env option should auto-set key
@@ -1354,7 +1268,7 @@ test("model cost overrides existing cost values", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999)
@@ -1431,9 +1345,9 @@ test("disabled_providers and enabled_providers interaction", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic")
set(ctx, "OPENAI_API_KEY", "test-openai")
set(ctx, "GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
await set(ctx, "ANTHROPIC_API_KEY", "test-anthropic")
await set(ctx, "OPENAI_API_KEY", "test-openai")
await set(ctx, "GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
const providers = await list(ctx)
// anthropic: in enabled, not in disabled = allowed
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -1588,7 +1502,7 @@ test("provider env fallback - second env var used if first missing", async () =>
directory: tmp.path,
fn: async (ctx) => {
// Only set fallback, not primary
set(ctx, "FALLBACK_KEY", "fallback-api-key")
await set(ctx, "FALLBACK_KEY", "fallback-api-key")
const providers = await list(ctx)
// Provider should load because fallback env var is set
expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
@@ -1610,7 +1524,7 @@ test("getModel returns consistent results", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
expect(model1.providerID).toEqual(model2.providerID)
@@ -1669,7 +1583,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
try {
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4"), ctx) // typo: sonet instead of sonnet
expect(true).toBe(false) // Should not reach here
@@ -1695,7 +1609,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
try {
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4"), ctx) // typo: antropic
expect(true).toBe(false) // Should not reach here
@@ -1721,7 +1635,7 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
remove(ctx, "OPENCODE_API_KEY")
await remove(ctx, "OPENCODE_API_KEY")
try {
await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"), ctx)
throw new Error("expected model lookup to fail")
@@ -1767,7 +1681,7 @@ test("getProvider returns provider info", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const provider = await getProvider(ProviderID.anthropic, ctx)
expect(provider).toBeDefined()
expect(String(provider?.id)).toBe("anthropic")
@@ -1789,7 +1703,7 @@ test("closest returns undefined when no partial match found", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"], ctx)
expect(result).toBeUndefined()
},
@@ -1810,7 +1724,7 @@ test("closest checks multiple query terms in order", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
// First term won't match, second will
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"], ctx)
expect(result).toBeDefined()
@@ -1880,7 +1794,7 @@ test("provider options are deeply merged", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
// Custom options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
@@ -2010,7 +1924,7 @@ test("custom model inherits npm package from models.dev provider config", async
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "OPENAI_API_KEY", "test-api-key")
await set(ctx, "OPENAI_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.openai].models["my-custom-model"]
expect(model).toBeDefined()
@@ -2043,7 +1957,7 @@ test("custom model inherits api.url from models.dev provider", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "OPENROUTER_API_KEY", "test-api-key")
await set(ctx, "OPENROUTER_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.openrouter]).toBeDefined()
@@ -2174,7 +2088,7 @@ test("model variants are generated for reasoning models", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
// Claude sonnet 4 has reasoning capability
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -2210,7 +2124,7 @@ test("model variants can be disabled via config", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
@@ -2251,7 +2165,7 @@ test("model variants can be customized via config", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
@@ -2288,7 +2202,7 @@ test("disabled key is stripped from variant config", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined()
@@ -2324,7 +2238,7 @@ test("all variants can be disabled via config", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
@@ -2360,7 +2274,7 @@ test("variant config merges with generated variants", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
@@ -2396,7 +2310,7 @@ test("variants filtered in second pass for database models", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "OPENAI_API_KEY", "test-api-key")
await set(ctx, "OPENAI_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.openai].models["gpt-5"]
expect(model.variants).toBeDefined()
@@ -2498,7 +2412,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
await set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = await list(ctx)
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
@@ -2541,7 +2455,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
await set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = await list(ctx)
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
@@ -2565,9 +2479,9 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
await set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
await set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
await set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
const providers = await list(ctx)
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
},
@@ -2595,9 +2509,9 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
await set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
await set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
await set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
const providers = await list(ctx)
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
@@ -2673,8 +2587,10 @@ test("plugin config providers persist after instance dispose", async () => {
test("plugin config enabled and disabled providers are honored", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const root = path.join(dir, ".opencode", "plugin")
const configDir = path.join(dir, ".opencode")
const root = path.join(configDir, "plugin")
await mkdir(root, { recursive: true })
await markPluginDependenciesReady(configDir)
await Bun.write(
path.join(root, "provider-filter.ts"),
[
@@ -2696,8 +2612,8 @@ test("plugin config enabled and disabled providers are honored", async () => {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
set(ctx, "OPENAI_API_KEY", "test-openai-key")
await set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
@@ -5,9 +5,11 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Config } from "../../src/config/config"
import { ConfigReference } from "../../src/config/reference"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Git } from "../../src/git"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -18,8 +20,7 @@ afterEach(async () => {
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
@@ -81,44 +82,89 @@ const waitForContent = (
})
describe("reference", () => {
it.live("resolves local and git references", () =>
it.live("resolves supported local and git config forms", () =>
Effect.gen(function* () {
const root = path.resolve("opencode-reference-root")
const local = Reference.resolve({
name: "docs",
reference: { path: "../docs" },
reference: ConfigReference.normalizeEntry({ path: "../docs" }),
directory: path.join(root, "packages", "app"),
worktree: root,
})
const repo = Reference.resolve({
name: "effect",
reference: { repository: "Effect-TS/effect", branch: "main" },
reference: ConfigReference.normalizeEntry({ repository: "Effect-TS/effect", branch: "main" }),
directory: path.join(root, "packages", "app"),
worktree: root,
})
const localString = Reference.resolve({
name: "notes",
reference: ConfigReference.normalizeEntry("./notes"),
directory: path.join(root, "packages", "app"),
worktree: root,
})
const repoString = Reference.resolve({
name: "repo",
reference: ConfigReference.normalizeEntry("owner/repo"),
directory: path.join(root, "packages", "app"),
worktree: root,
})
expect(local.kind).toBe("local")
if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs"))
expect(localString.kind).toBe("local")
if (localString.kind === "local") expect(localString.path).toBe(path.resolve(root, "notes"))
expect(repo.kind).toBe("git")
if (repo.kind === "git") {
expect(repo.repository).toBe("Effect-TS/effect")
expect(repo.branch).toBe("main")
expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"))
}
expect(repoString.kind).toBe("git")
if (repoString.kind === "git") {
expect(repoString.repository).toBe("owner/repo")
expect(repoString.path).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
}
}),
)
it.live("keeps invalid repository references visible without materializing", () =>
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
const reference = yield* Reference.Service
const references = yield* reference.list()
const invalid = yield* reference.get("bad")
expect(references.map((item) => item.name)).toEqual(["bad"])
expect(invalid).toMatchObject({
name: "bad",
kind: "invalid",
repository: "not-a-repo",
})
if (invalid?.kind === "invalid") expect(invalid.message).toContain("Repository must be a git URL")
}),
{
config: {
reference: {
bad: "not-a-repo",
},
},
},
),
)
it.live("marks same-cache references with different branches invalid", () =>
Effect.gen(function* () {
const root = path.resolve("opencode-reference-root")
const references = Reference.resolveAll({
directory: root,
worktree: root,
references: {
references: ConfigReference.normalize({
main: { repository: "owner/repo", branch: "main" },
dev: { repository: "github.com/owner/repo", branch: "dev" },
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
},
}),
})
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
@@ -130,6 +176,27 @@ describe("reference", () => {
}),
)
it.live("represents invalid aliases as invalid references", () =>
Effect.gen(function* () {
const root = path.resolve("opencode-reference-root")
const references = Reference.resolveAll({
directory: root,
worktree: root,
references: ConfigReference.normalize({
"bad/name": "owner/repo",
}),
})
expect(references).toEqual([
{
name: "bad/name",
kind: "invalid",
message: "Reference alias must not contain /, whitespace, comma, or backtick",
},
])
}),
)
scout.live("materializes configured git references during init", () =>
provideTmpdirInstance(
(_dir) =>
@@ -0,0 +1,443 @@
// Diagnostic suite for /event SSE delivery.
//
// Each test isolates ONE variable in the publisher chain while keeping the
// subscriber path constant (raw `app().request` reading the SSE body — no SDK
// consumer involvement). The pass/fail pattern across tests tells us where the
// bug lives:
//
// D1 (baseline): publish via Bus.Service.use via AppRuntime — mirror of the
// existing httpapi-event.test.ts test 3. Confirms /event SSE delivery
// works for a SOME publish path.
//
// D2: publish N times in quick succession via Bus.Service.use. If the bus
// subscription is acquired correctly there should be no message loss.
//
// D3: publish via SyncEvent.use.run via AppRuntime — exercises the same path
// the HTTP handlers use (Session.updatePart → sync.run → bus.publish)
// without the HTTP roundtrip. Tells us whether the sync path itself can
// deliver in-process.
//
// D4: publish via SyncEvent.use.run from a fresh `Effect.provide` scope
// (mimicking what happens if a handler's layer was scoped per-request).
//
// D5: in-process Bus.Service callback subscriber AND raw /event SSE subscriber
// receive the same publish. If both receive: no bug. If only the
// callback receives: the /event handler has an acquisition race.
import { afterEach, describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { Event as ServerEvent } from "../../src/server/event"
import { SyncEvent } from "../../src/sync"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
function app() {
return Server.Default().app
}
const EventData = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
type SseEvent = Schema.Schema.Type<typeof EventData>
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>, timeoutMs = 3_000) {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)
}),
])
} finally {
if (timeout) clearTimeout(timeout)
}
}
const textDecoder = new TextDecoder()
function decodeFrame(value: Uint8Array): SseEvent[] {
// SSE frames are separated by blank lines and each starts with "data: ".
// For our happy-path tests one chunk == one frame, but be defensive.
const text = textDecoder.decode(value)
return text
.split(/\n\n+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => {
const payload = part.replace(/^data: /, "")
return Schema.decodeUnknownSync(EventData)(JSON.parse(payload))
})
}
async function readNextEvent(reader: ReadableStreamDefaultReader<Uint8Array>, timeoutMs = 3_000): Promise<SseEvent> {
const result = await readChunk(reader, timeoutMs)
if (result.done || !result.value) throw new Error("event stream closed")
const frames = decodeFrame(result.value)
if (frames.length === 0) throw new Error("empty SSE frame")
return frames[0]
}
async function collectUntil(
reader: ReadableStreamDefaultReader<Uint8Array>,
predicate: (event: SseEvent) => boolean,
timeoutMs = 3_000,
): Promise<SseEvent[]> {
const events: SseEvent[] = []
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const remaining = deadline - Date.now()
const result = await readChunk(reader, remaining).catch((cause) => {
throw new Error(`collectUntil timed out after ${events.length} events: ${cause}`)
})
if (result.done || !result.value) throw new Error("event stream closed mid-collect")
for (const event of decodeFrame(result.value)) {
events.push(event)
if (predicate(event)) return events
}
}
throw new Error(`collectUntil deadline exceeded; collected ${events.length}: ${JSON.stringify(events)}`)
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("/event SSE delivery diagnostics", () => {
// Sanity: baseline same as httpapi-event.test.ts test 3 (already known to pass)
// but explicit about timing — publish happens with NO wait after reading
// server.connected. If this fails we have a deeper problem than just sync.
test("D1: delivers a single bus event published right after server.connected", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
const first = await readNextEvent(reader)
expect(first.type).toBe("server.connected")
const ctx = await reloadTestInstance({ directory: tmp.path })
// NO wait — publish immediately
await AppRuntime.runPromise(
Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(Effect.provideService(InstanceRef, ctx)),
)
const next = await readNextEvent(reader)
expect(next.type).toBe("server.connected") // ServerEvent.Connected.type === "server.connected"
} finally {
await reader.cancel()
}
})
// If D1 passes but D2 fails, we have a queue-drain or partial-loss issue.
test("D2: delivers all N bus events published in rapid succession", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
const first = await readNextEvent(reader)
expect(first.type).toBe("server.connected")
const ctx = await reloadTestInstance({ directory: tmp.path })
const N = 5
for (let i = 0; i < N; i++) {
await AppRuntime.runPromise(
Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(
Effect.provideService(InstanceRef, ctx),
),
)
}
const received: SseEvent[] = []
for (let i = 0; i < N; i++) {
received.push(await readNextEvent(reader))
}
expect(received).toHaveLength(N)
for (const event of received) expect(event.type).toBe("server.connected")
} finally {
await reader.cancel()
}
})
// The critical test. If D1 passes but this fails, the bus-identity fix is
// incomplete OR the sync.run publish path doesn't reach the same bus
// /event subscribes to, even within the same AppRuntime.
test("D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
const first = await readNextEvent(reader)
expect(first.type).toBe("server.connected")
const ctx = await reloadTestInstance({ directory: tmp.path })
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const part: MessageV2.Part = {
id: partID,
sessionID,
messageID,
type: "text",
text: "diag",
}
await AppRuntime.runPromise(
SyncEvent.use
.run(MessageV2.Event.PartUpdated, {
sessionID,
part: structuredClone(part) as MessageV2.Part,
time: Date.now(),
})
.pipe(Effect.provideService(InstanceRef, ctx)),
)
const collected = await collectUntil(reader, (event) => event.type === MessageV2.Event.PartUpdated.type, 4_000)
const updated = collected.find((event) => event.type === MessageV2.Event.PartUpdated.type)
expect(updated).toBeDefined()
expect((updated as any).properties.part.id).toBe(partID)
} finally {
await reader.cancel()
}
})
// If D3 passes but D5 (the SDK E2E in httpapi-sdk.test.ts) fails, then the
// bug is specifically in the cross-request / cross-fiber HTTP path, not in
// the publish itself. If D3 also fails, the publish chain is broken.
//
// D4: ensure the publish reaches an in-process Bus subscriber too. Confirms
// pub/sub identity end-to-end without involving /event SSE.
test("D4: SyncEvent.use.run publish reaches an in-process Bus.Service.use callback", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const ctx = await reloadTestInstance({ directory: tmp.path })
let resolveReceived: (event: { id: string; type: string; properties: unknown }) => void
const received = new Promise<{ id: string; type: string; properties: unknown }>(
(resolve) => (resolveReceived = resolve as typeof resolveReceived),
)
const dispose = await AppRuntime.runPromise(
Bus.Service.use((svc) =>
svc.subscribeAllCallback((event) => {
if (event.type === MessageV2.Event.PartUpdated.type) resolveReceived(event)
}),
).pipe(Effect.provideService(InstanceRef, ctx)),
)
try {
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const part: MessageV2.Part = { id: partID, sessionID, messageID, type: "text", text: "diag-d4" }
await AppRuntime.runPromise(
SyncEvent.use
.run(MessageV2.Event.PartUpdated, {
sessionID,
part: structuredClone(part) as MessageV2.Part,
time: Date.now(),
})
.pipe(Effect.provideService(InstanceRef, ctx)),
)
const event = await Promise.race([
received,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("D4 timed out")), 3_000)),
])
expect(event.type).toBe(MessageV2.Event.PartUpdated.type)
expect((event.properties as any).part.id).toBe(partID)
} finally {
dispose()
}
})
// D5: BOTH subscribers attached simultaneously. Trigger ONE publish via
// SyncEvent.use.run. Both subscribers should receive it. If only one does
// we know exactly which side of the chain is failing.
test("D5: same SyncEvent.use.run publish reaches BOTH /event SSE and in-process callback", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const ctx = await reloadTestInstance({ directory: tmp.path })
// In-process callback subscriber
let resolveCallback: (event: { type: string; properties: unknown }) => void
const callbackReceived = new Promise<{ type: string; properties: unknown }>(
(resolve) => (resolveCallback = resolve as typeof resolveCallback),
)
const dispose = await AppRuntime.runPromise(
Bus.Service.use((svc) =>
svc.subscribeAllCallback((event) => {
if (event.type === MessageV2.Event.PartUpdated.type) resolveCallback(event)
}),
).pipe(Effect.provideService(InstanceRef, ctx)),
)
// SSE subscriber via raw HTTP
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
const first = await readNextEvent(reader)
expect(first.type).toBe("server.connected")
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const part: MessageV2.Part = { id: partID, sessionID, messageID, type: "text", text: "diag-d5" }
await AppRuntime.runPromise(
SyncEvent.use
.run(MessageV2.Event.PartUpdated, {
sessionID,
part: structuredClone(part) as MessageV2.Part,
time: Date.now(),
})
.pipe(Effect.provideService(InstanceRef, ctx)),
)
const sseCollected = await collectUntil(
reader,
(event) => event.type === MessageV2.Event.PartUpdated.type,
4_000,
).catch((err) => err as Error)
const callbackResult = await Promise.race([
callbackReceived,
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 1_000)),
])
const sseSaw =
Array.isArray(sseCollected) && sseCollected.some((event) => event.type === MessageV2.Event.PartUpdated.type)
const callbackSaw = callbackResult !== "timeout"
// Both should see it. The reason we use a single assert with the boolean
// pair is so the test failure message tells us exactly which side broke.
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
} finally {
await reader.cancel()
dispose()
}
})
// D7: like D5 but the "second subscriber" is a NO-OP AppRuntime.runPromise
// call (no PubSub.subscribe). If D7 passes, the specific subscribeAllCallback
// is what breaks SSE — not arbitrary AppRuntime usage. If D7 fails, anything
// running through AppRuntime concurrently with /event SSE breaks delivery.
test("D7: SSE receives sync.run publish even with concurrent no-op AppRuntime activity", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const ctx = await reloadTestInstance({ directory: tmp.path })
// No-op: just touches the runtime, no bus interaction
await AppRuntime.runPromise(Effect.void)
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
const first = await readNextEvent(reader)
expect(first.type).toBe("server.connected")
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const part: MessageV2.Part = { id: partID, sessionID, messageID, type: "text", text: "diag-d7" }
await AppRuntime.runPromise(
SyncEvent.use
.run(MessageV2.Event.PartUpdated, {
sessionID,
part: structuredClone(part) as MessageV2.Part,
time: Date.now(),
})
.pipe(Effect.provideService(InstanceRef, ctx)),
)
const collected = await collectUntil(reader, (event) => event.type === MessageV2.Event.PartUpdated.type, 4_000)
const updated = collected.find((event) => event.type === MessageV2.Event.PartUpdated.type)
expect(updated).toBeDefined()
} finally {
await reader.cancel()
}
})
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. If D5 fails and D6 passes, the order of
// subscriber setup is the determining factor.
test("D6: /event SSE receives sync.run publish when callback is attached AFTER /event opens", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const ctx = await reloadTestInstance({ directory: tmp.path })
// Open SSE FIRST
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
const first = await readNextEvent(reader)
expect(first.type).toBe("server.connected")
// THEN attach callback subscriber
let resolveCallback: (event: { type: string; properties: unknown }) => void
const callbackReceived = new Promise<{ type: string; properties: unknown }>(
(resolve) => (resolveCallback = resolve as typeof resolveCallback),
)
const dispose = await AppRuntime.runPromise(
Bus.Service.use((svc) =>
svc.subscribeAllCallback((event) => {
if (event.type === MessageV2.Event.PartUpdated.type) resolveCallback(event)
}),
).pipe(Effect.provideService(InstanceRef, ctx)),
)
try {
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const part: MessageV2.Part = { id: partID, sessionID, messageID, type: "text", text: "diag-d6" }
await AppRuntime.runPromise(
SyncEvent.use
.run(MessageV2.Event.PartUpdated, {
sessionID,
part: structuredClone(part) as MessageV2.Part,
time: Date.now(),
})
.pipe(Effect.provideService(InstanceRef, ctx)),
)
const sseCollected = await collectUntil(
reader,
(event) => event.type === MessageV2.Event.PartUpdated.type,
4_000,
).catch((err) => err as Error)
const callbackResult = await Promise.race([
callbackReceived,
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 1_000)),
])
const sseSaw =
Array.isArray(sseCollected) && sseCollected.some((event) => event.type === MessageV2.Event.PartUpdated.type)
const callbackSaw = callbackResult !== "timeout"
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
} finally {
dispose()
}
} finally {
await reader.cancel()
}
})
})
@@ -169,7 +169,7 @@ async function openPtySocket(listener: Awaited<ReturnType<typeof startListener>>
describe("HttpApi Server.listen", () => {
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const listener = await startListener()
let stopped = false
try {
@@ -330,7 +330,7 @@ describe("HttpApi Server.listen", () => {
})
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
@@ -338,6 +338,29 @@ describe("HttpApi Server.listen", () => {
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
// Regression for #25698: minting without a directory uses the server cwd
// and cannot find a PTY registered in a project directory.
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
})
expect(ambiguous.status).toBe(404)
const directoryScoped = await fetch(
new URL(
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
listener.url,
),
{
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
},
)
expect(directoryScoped.status).toBe(200)
const mint = (await directoryScoped.json()) as { ticket: string }
const scopedWs = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
scopedWs.close(1000)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
const reusable = await connectTicket(listener, info.id, tmp.path)
@@ -358,51 +381,8 @@ describe("HttpApi Server.listen", () => {
}
})
// Regression for #25698 (Ope): the app's SDK call to
// `client.pty.connectToken({ ptyID })` originally omitted `directory`, so
// the server resolved the PTY in its own cwd context — where the project
// PTY isn't registered — and returned 404. The fix is to always pass
// `directory` from the app side; this test locks in two contracts:
// 1. Mint without directory cannot find a PTY registered in another dir.
// 2. Mint with the project directory succeeds; the resulting ticket
// consumes cleanly when the WS upgrade carries the same directory.
testPty("PTY connect token requires matching directory across mint and connect", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
// Mint without directory — server uses its own cwd, can't find the PTY.
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
})
expect(ambiguous.status).toBe(404)
// Mint with the project directory — succeeds, ticket binds to that scope.
const scoped = await fetch(
new URL(
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
listener.url,
),
{
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
},
)
expect(scoped.status).toBe(200)
const mint = (await scoped.json()) as { ticket: string }
// Same directory on the WS upgrade → consume succeeds.
const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined)
}
})
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener()
try {
const info = await createCat(listener, tmp.path)
@@ -6,6 +6,7 @@ import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { markPluginDependenciesReady } from "../fixture/plugin"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
@@ -118,6 +119,7 @@ function requestCallback(input: {
function writeProviderAuthPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
@@ -152,6 +154,7 @@ function writeProviderAuthPlugin(dir: string) {
function writeProviderAuthValidationPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"),
@@ -193,6 +196,7 @@ function writeProviderAuthValidationPlugin(dir: string) {
function writeFunctionOptionsPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
@@ -224,6 +228,7 @@ function writeFunctionOptionsPlugin(dir: string) {
function writeProviderModelsMutationPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"),
@@ -1,5 +1,5 @@
import { afterEach, describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer } from "effect"
import { ConfigProvider, Deferred, Effect, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { HttpRouter } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process"
@@ -22,7 +22,7 @@ import { TestLLMServer } from "../lib/llm-server"
import path from "path"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { awaitWithTimeout, testEffect } from "../lib/effect"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect(
@@ -265,7 +265,7 @@ function withProject<A, E, E2 = never>(
) {
return Effect.gen(function* () {
const directory = yield* tmpdirScoped({
git: options.git ?? true,
git: options.git ?? false,
config: { formatter: false, lsp: false, ...options.config },
})
yield* options.setup?.(directory) ?? Effect.void
@@ -516,7 +516,7 @@ describe("HttpApi SDK", () => {
)
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk, directory }) =>
withProject(serverPath, { git: true, setup: writeStandardFiles }, ({ sdk, directory }) =>
Effect.gen(function* () {
const project = yield* capture(() => sdk.project.current())
const projects = yield* capture(() => sdk.project.list())
@@ -561,6 +561,7 @@ describe("HttpApi SDK", () => {
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
listedFile: JSON.stringify(files.data).includes("hello.txt"),
vcs: { hasBranch: typeof record(vcs.data).branch === "string" },
}
}),
),
@@ -671,6 +672,70 @@ describe("HttpApi SDK", () => {
),
)
// Regression: SyncEvent must publish on the same ProjectBus the /event handler
// subscribes to, AND the /event stream must forward handler ALS/context into the
// body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run →
// bus.publish → SDK subscriber path. Goes red if either the publisher uses a
// different bus instance (Bug 2 / pre-#27825) or the stream loses context (Bug 1 /
// pre-#27425).
serverPathParity("streams sync-backed part updates to /event subscribers", (serverPath) =>
withStandardProject(serverPath, ({ sdk, directory }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "sync-backed part event" }))
const sessionID = String(record(session.data).id)
const seeded = yield* seedMessage(directory, sessionID)
const controller = new AbortController()
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
const events = yield* call(() => sdk.event.subscribe(undefined, { signal: controller.signal }))
yield* Effect.addFinalizer(() =>
call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
)
const ready = yield* Deferred.make<void>()
const received = yield* Deferred.make<unknown>()
yield* call(async () => {
for await (const event of events.stream) {
const payload = record(event).payload ?? event
const type = record(payload).type
if (type === "server.connected") {
Deferred.doneUnsafe(ready, Effect.void)
continue
}
if (type === MessageV2.Event.PartUpdated.type) {
Deferred.doneUnsafe(received, Effect.succeed(payload))
return
}
}
}).pipe(Effect.forkScoped)
yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for /event server.connected", "2 seconds")
const updated = yield* capture(() =>
sdk.part.update({
sessionID,
messageID: seeded.message.id,
partID: seeded.part.id,
part: { ...seeded.part, text: "updated via sync" } as NonNullable<
Parameters<Sdk["part"]["update"]>[0]["part"]
>,
}),
)
expect(updated.status).toBe(200)
const event = yield* awaitWithTimeout(
Deferred.await(received),
"timed out waiting for message.part.updated bus payload over /event",
"5 seconds",
)
const properties = record(record(event).properties)
expect(record(properties.part)).toMatchObject({ id: seeded.part.id, type: "text" })
return { type: record(event).type, partType: record(properties.part).type }
}),
),
)
serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
@@ -823,7 +888,7 @@ describe("HttpApi SDK", () => {
)
serverPathParity("matches generated SDK project git initialization", (serverPath) =>
withProject(serverPath, { git: false }, ({ sdk, directory }) =>
withProject(serverPath, {}, ({ sdk, directory }) =>
Effect.gen(function* () {
const before = yield* capture(() => sdk.project.current())
const init = yield* capture(() => sdk.project.initGit())
@@ -249,7 +249,7 @@ it.live("session.processor effect tests capture llm input cleanly", () =>
expect(calls).toBe(1)
expect(parts.some((part) => part.type === "text" && part.text === "hello")).toBe(true)
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -331,7 +331,7 @@ it.live("session.processor effect tests preserve text start time", () =>
if (!text?.time?.start || !text.time.end) return
expect(text.time.start).toBeLessThan(text.time.end)
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -377,7 +377,7 @@ it.live("session.processor effect tests stop after token overflow requests compa
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
expect(parts.some((part) => part.type === "step-finish")).toBe(true)
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -425,7 +425,7 @@ it.live("session.processor effect tests capture reasoning from http mock", () =>
expect(reasoning?.text).toBe("think")
expect(text?.text).toBe("done")
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -472,7 +472,7 @@ it.live("session.processor effect tests reset reasoning state across retries", (
expect(reasoning.some((part) => part.text === "two")).toBe(true)
expect(reasoning.some((part) => part.text === "onetwo")).toBe(false)
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -515,7 +515,7 @@ it.live("session.processor effect tests do not retry unknown json errors", () =>
expect(yield* llm.calls).toBe(1)
expect(handle.message.error?.name).toBe("APIError")
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -562,7 +562,7 @@ it.live("session.processor effect tests retry recognized structured json errors"
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
expect(handle.message.error).toBeUndefined()
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -614,7 +614,7 @@ it.live("session.processor effect tests publish retry status updates", () =>
expect(yield* llm.calls).toBe(2)
expect(states).toStrictEqual([1])
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -657,7 +657,7 @@ it.live("session.processor effect tests compact on structured context overflow",
expect(yield* llm.calls).toBe(1)
expect(handle.message.error).toBeUndefined()
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -721,7 +721,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
expect(call.state.time.end).toBeDefined()
}
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -793,7 +793,7 @@ it.live("session.processor effect tests record aborted errors and idle state", (
expect(state).toMatchObject({ type: "idle" })
expect(errs).toContain("MessageAbortedError")
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
@@ -850,6 +850,6 @@ it.live("session.processor effect tests mark interruptions aborted without manua
}
expect(state).toMatchObject({ type: "idle" })
}),
{ git: true, config: (url) => providerCfg(url) },
{ config: (url) => providerCfg(url) },
),
)
+323 -377
View File
@@ -48,6 +48,7 @@ import * as Database from "../../src/storage/db"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { TestInstance } from "../fixture/fixture"
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
@@ -189,6 +190,7 @@ function makeHttp(input?: { processor?: "blocking" }) {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
@@ -431,317 +433,290 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
// Loop semantics
it.instance(
"loop exits immediately when last assistant has stop finish",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* seed(chat.id, { finish: "stop" })
it.instance("loop exits immediately when last assistant has stop finish", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* seed(chat.id, { finish: "stop" })
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
expect(yield* llm.calls).toBe(0)
}),
{ git: true },
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
expect(yield* llm.calls).toBe(0)
}),
)
it.instance(
"loop calls LLM and returns assistant message",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.text("world")
it.instance("loop calls LLM and returns assistant message", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.text("world")
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.role).toBe("assistant")
const parts = result.parts.filter((p) => p.type === "text")
expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
expect(yield* llm.hits).toHaveLength(1)
}),
{ git: true },
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.role).toBe("assistant")
const parts = result.parts.filter((p) => p.type === "text")
expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
expect(yield* llm.hits).toHaveLength(1)
}),
)
it.instance(
"prompt emits v2 prompted and synthetic events",
() =>
Effect.gen(function* () {
yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
it.instance("prompt emits v2 prompted and synthetic events", () =>
Effect.gen(function* () {
yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "hello v2" },
{
type: "file",
mime: "text/plain",
filename: "note.txt",
url: "data:text/plain;base64,bm90ZSBjb250ZW50",
},
],
})
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
Effect.provide(SessionV2.layer),
)
const row = Database.use((db) =>
db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),
)
expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
expect(typeof row?.data.time.created).toBe("number")
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }),
expect.objectContaining({ type: "synthetic", text: "note content" }),
]),
)
}),
{ git: true },
)
it.instance(
"static loop returns assistant text through local provider",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.text("world")
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
expect(yield* llm.hits).toHaveLength(1)
expect(yield* llm.pending).toBe(0)
}),
{ git: true },
)
it.instance(
"static loop consumes queued replies across turns",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
})
yield* llm.text("world one")
const first = yield* prompt.loop({ sessionID: session.id })
expect(first.info.role).toBe("assistant")
expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
})
yield* llm.text("world two")
const second = yield* prompt.loop({ sessionID: session.id })
expect(second.info.role).toBe("assistant")
expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
expect(yield* llm.hits).toHaveLength(2)
expect(yield* llm.pending).toBe(0)
}),
{ git: true },
)
it.instance(
"loop continues when finish is tool-calls",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.tool("first", { value: "first" })
yield* llm.text("second")
const result = yield* prompt.loop({ sessionID: session.id })
expect(yield* llm.calls).toBe(2)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
expect(result.info.finish).toBe("stop")
}
}),
{ git: true },
)
it.instance(
"glob tool keeps instance context during prompt runs",
() =>
Effect.gen(function* () {
const { dir, llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Glob context",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
const file = path.join(dir, "probe.txt")
yield* writeText(file, "probe")
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "find text files" }],
})
yield* llm.tool("glob", { pattern: "**/*.txt" })
yield* llm.text("done")
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
const tool = msgs
.flatMap((msg) => msg.parts)
.find(
(part): part is CompletedToolPart =>
part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
)
if (!tool) return
expect(tool.state.output).toContain(file)
expect(tool.state.output).not.toContain("No context found for instance")
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
}),
{ git: true },
)
it.instance(
"loop continues when finish is stop but assistant has tool parts",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.push(reply().tool("first", { value: "first" }).stop())
yield* llm.text("second")
const result = yield* prompt.loop({ sessionID: session.id })
expect(yield* llm.calls).toBe(2)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
expect(result.info.finish).toBe("stop")
}
}),
{ git: true },
)
it.instance(
"failed subtask preserves metadata on error tool state",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig((url) => ({
...providerCfg(url),
agent: {
general: {
model: "test/missing-model",
},
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "hello v2" },
{
type: "file",
mime: "text/plain",
filename: "note.txt",
url: "data:text/plain;base64,bm90ZSBjb250ZW50",
},
}))
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.tool("task", {
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
})
yield* llm.text("done")
const msg = yield* user(chat.id, "hello")
yield* addSubtask(chat.id, msg.id)
],
})
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.role).toBe("assistant")
expect(yield* llm.calls).toBe(2)
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
Effect.provide(SessionV2.layer),
)
const row = Database.use((db) =>
db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),
)
expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
expect(typeof row?.data.time.created).toBe("number")
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }),
expect.objectContaining({ type: "synthetic", text: "note content" }),
]),
)
}),
)
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
expect(taskMsg?.info.role).toBe("assistant")
if (!taskMsg || taskMsg.info.role !== "assistant") return
it.instance("static loop returns assistant text through local provider", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
const tool = errorTool(taskMsg.parts)
if (!tool) return
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
expect(tool.state.error).toContain("Tool execution failed")
expect(tool.state.metadata).toBeDefined()
expect(tool.state.metadata?.sessionId).toBeDefined()
expect(tool.state.metadata?.model).toEqual({
providerID: ProviderID.make("test"),
modelID: ModelID.make("missing-model"),
})
}),
{ git: true },
yield* llm.text("world")
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
expect(yield* llm.hits).toHaveLength(1)
expect(yield* llm.pending).toBe(0)
}),
)
it.instance("static loop consumes queued replies across turns", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
})
yield* llm.text("world one")
const first = yield* prompt.loop({ sessionID: session.id })
expect(first.info.role).toBe("assistant")
expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
})
yield* llm.text("world two")
const second = yield* prompt.loop({ sessionID: session.id })
expect(second.info.role).toBe("assistant")
expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
expect(yield* llm.hits).toHaveLength(2)
expect(yield* llm.pending).toBe(0)
}),
)
it.instance("loop continues when finish is tool-calls", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.tool("first", { value: "first" })
yield* llm.text("second")
const result = yield* prompt.loop({ sessionID: session.id })
expect(yield* llm.calls).toBe(2)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
expect(result.info.finish).toBe("stop")
}
}),
)
it.instance("glob tool keeps instance context during prompt runs", () =>
Effect.gen(function* () {
const { dir, llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Glob context",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
const file = path.join(dir, "probe.txt")
yield* writeText(file, "probe")
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "find text files" }],
})
yield* llm.tool("glob", { pattern: "**/*.txt" })
yield* llm.text("done")
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
const tool = msgs
.flatMap((msg) => msg.parts)
.find(
(part): part is CompletedToolPart =>
part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
)
if (!tool) return
expect(tool.state.output).toContain(file)
expect(tool.state.output).not.toContain("No context found for instance")
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
}),
)
it.instance("loop continues when finish is stop but assistant has tool parts", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.push(reply().tool("first", { value: "first" }).stop())
yield* llm.text("second")
const result = yield* prompt.loop({ sessionID: session.id })
expect(yield* llm.calls).toBe(2)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
expect(result.info.finish).toBe("stop")
}
}),
)
it.instance("failed subtask preserves metadata on error tool state", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig((url) => ({
...providerCfg(url),
agent: {
general: {
model: "test/missing-model",
},
},
}))
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.tool("task", {
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
})
yield* llm.text("done")
const msg = yield* user(chat.id, "hello")
yield* addSubtask(chat.id, msg.id)
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.role).toBe("assistant")
expect(yield* llm.calls).toBe(2)
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
expect(taskMsg?.info.role).toBe("assistant")
if (!taskMsg || taskMsg.info.role !== "assistant") return
const tool = errorTool(taskMsg.parts)
if (!tool) return
expect(tool.state.error).toContain("Tool execution failed")
expect(tool.state.metadata).toBeDefined()
expect(tool.state.metadata?.sessionId).toBeDefined()
expect(tool.state.metadata?.model).toEqual({
providerID: ProviderID.make("test"),
modelID: ModelID.make("missing-model"),
})
}),
)
it.instance(
@@ -776,7 +751,6 @@ it.instance(
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
{ git: true },
5_000,
)
@@ -821,7 +795,6 @@ it.instance(
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
{ git: true },
10_000,
)
@@ -846,7 +819,6 @@ it.instance(
yield* Fiber.await(fiber)
expect((yield* status.get(chat.id)).type).toBe("idle")
}),
{ git: true },
3_000,
)
@@ -875,7 +847,6 @@ it.instance(
expect(exit.value.info.role).toBe("assistant")
}
}),
{ git: true },
3_000,
)
@@ -902,7 +873,6 @@ it.instance(
}
}
}),
{ git: true },
3_000,
)
@@ -992,7 +962,6 @@ race.instance(
expect(lastAssistant.info.parentID).toBe(lastUser?.info.id)
}
}),
{ git: true },
3_000,
)
@@ -1039,7 +1008,7 @@ it.instance(
expect(taskMsg.info.time.completed).toBeDefined()
expect(taskMsg.info.finish).toBeDefined()
}),
{ git: true, config: cfg },
{ config: cfg },
30_000,
)
@@ -1075,7 +1044,6 @@ it.instance(
expect((yield* status.get(chat.id)).type).toBe("idle")
expect((yield* status.get(childID)).type).toBe("idle")
}),
{ git: true },
10_000,
)
@@ -1109,22 +1077,19 @@ it.instance(
// Queue semantics
it.instance(
"concurrent loop callers get same result",
() =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
yield* seed(chat.id, { finish: "stop" })
it.instance("concurrent loop callers get same result", () =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
yield* seed(chat.id, { finish: "stop" })
const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
concurrency: "unbounded",
})
const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
concurrency: "unbounded",
})
expect(a.info.id).toBe(b.info.id)
expect(a.info.role).toBe("assistant")
yield* run.assertNotBusy(chat.id)
}),
{ git: true },
expect(a.info.id).toBe(b.info.id)
expect(a.info.role).toBe("assistant")
yield* run.assertNotBusy(chat.id)
}),
)
it.instance(
@@ -1145,7 +1110,6 @@ it.instance(
expect(a.info.id).toBe(b.info.id)
expect(a.info.role).toBe("assistant")
}),
{ git: true },
3_000,
)
@@ -1214,7 +1178,6 @@ it.instance(
expect(inputs).toHaveLength(2)
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
}),
{ git: true },
3_000,
)
@@ -1244,22 +1207,18 @@ it.instance(
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
{ git: true },
3_000,
)
it.instance(
"assertNotBusy succeeds when idle",
() =>
Effect.gen(function* () {
const run = yield* SessionRunState.Service
const sessions = yield* Session.Service
it.instance("assertNotBusy succeeds when idle", () =>
Effect.gen(function* () {
const run = yield* SessionRunState.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({})
const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
expect(Exit.isSuccess(exit)).toBe(true)
}),
{ git: true },
const chat = yield* sessions.create({})
const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
expect(Exit.isSuccess(exit)).toBe(true)
}),
)
// Shell semantics
@@ -1288,7 +1247,6 @@ it.instance(
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
{ git: true },
3_000,
)
@@ -1313,7 +1271,7 @@ unix(
expect(tool.state.metadata.output).toContain("err")
yield* run.assertNotBusy(chat.id)
}),
{ git: true, config: cfg },
{ config: cfg },
)
unix(
@@ -1337,7 +1295,7 @@ unix(
expect(tool.state.metadata.output).toContain(dir)
yield* run.assertNotBusy(chat.id)
}),
{ git: true, config: cfg },
{ config: cfg },
)
unix(
@@ -1359,7 +1317,7 @@ unix(
expect(tool.state.output).toContain("configured")
}),
),
{ git: true, config: { ...cfg, shell: "bash" } },
{ config: { ...cfg, shell: "bash" } },
30_000,
)
@@ -1384,7 +1342,7 @@ unix(
expect(tool.state.metadata.output).toContain(parent)
yield* run.assertNotBusy(chat.id)
}),
{ git: true, config: cfg },
{ config: cfg },
)
unix(
@@ -1410,7 +1368,7 @@ unix(
expect(tool.state.metadata.output).toContain("README.md")
yield* run.assertNotBusy(chat.id)
}),
{ git: true, config: cfg },
{ config: cfg },
)
unix(
@@ -1432,7 +1390,7 @@ unix(
expect(tool.state.metadata.output).toContain("not found")
yield* run.assertNotBusy(chat.id)
}),
{ git: true, config: cfg },
{ config: cfg },
)
unix(
@@ -1460,7 +1418,7 @@ unix(
expect(Exit.isSuccess(exit)).toBe(true)
}),
),
{ git: true, config: cfg },
{ config: cfg },
30_000,
)
@@ -1570,7 +1528,6 @@ unix(
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
}),
),
{ git: true },
30_000,
)
@@ -1806,7 +1763,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
}),
{ git: true, config: cfg },
{ config: cfg },
30_000,
)
@@ -1841,7 +1798,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
}),
{ git: true, config: cfg },
{ config: cfg },
30_000,
)
@@ -1880,7 +1837,7 @@ it.instance(
yield* sessions.remove(session.id)
}),
{ git: true, config: cfg },
{ config: cfg },
)
it.instance(
@@ -1922,7 +1879,7 @@ it.instance(
yield* sessions.remove(session.id)
}),
{ git: true, config: cfg },
{ config: cfg },
)
it.instance(
@@ -1971,7 +1928,6 @@ it.instance(
expect(agents.map((agent) => agent.name)).toEqual(["build"])
}),
{
git: true,
config: {
...cfg,
reference: {
@@ -2011,7 +1967,6 @@ it.instance(
yield* sessions.remove(session.id)
}),
{
git: true,
config: {
...cfg,
reference: {
@@ -2077,7 +2032,6 @@ it.instance(
yield* sessions.remove(session.id)
}),
{
git: true,
config: {
...cfg,
reference: {
@@ -2126,31 +2080,28 @@ it.instance(
// Regression: empty assistant turn loop
it.instance(
"does not loop empty assistant turns for a simple reply",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt regression" })
it.instance("does not loop empty assistant turns for a simple reply", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt regression" })
yield* llm.text("packages/opencode/src/session/processor.ts")
yield* llm.text("packages/opencode/src/session/processor.ts")
const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
const msgs = yield* sessions.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(yield* llm.calls).toBe(1)
}),
{ git: true },
const msgs = yield* sessions.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(yield* llm.calls).toBe(1)
}),
)
it.instance(
@@ -2191,7 +2142,6 @@ it.instance(
expect(last.info.error?.name).toBe("MessageAbortedError")
}
}),
{ git: true },
3_000,
)
@@ -2242,7 +2192,6 @@ it.instance(
yield* sessions.remove(session.id)
}),
{
git: true,
config: {
...cfg,
provider: {
@@ -2295,7 +2244,6 @@ it.instance(
}
}
}),
{ git: true },
30_000,
)
@@ -2324,7 +2272,6 @@ it.instance(
}
}
}),
{ git: true },
30_000,
)
@@ -2354,6 +2301,5 @@ it.instance(
}
}
}),
{ git: true },
30_000,
)
@@ -59,6 +59,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
@@ -138,6 +139,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
+40 -2
View File
@@ -1,14 +1,15 @@
import { describe, expect, beforeEach, afterAll } from "bun:test"
import { provideTmpdirInstance } from "../fixture/fixture"
import { Effect, Layer, Schema } from "effect"
import { Deferred, Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { SyncEvent } from "../../src/sync"
import { Database, eq } from "@/storage/db"
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { MessageID } from "../../src/session/schema"
import { initProjectors } from "../../src/server/projectors"
import { testEffect } from "../lib/effect"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
const it = testEffect(
@@ -139,6 +140,43 @@ describe("SyncEvent", () => {
}),
),
)
// Regression for the EffectBridge migration. GlobalBus.emit used to fire
// synchronously inside the Database.effect post-commit callback. After the
// migration it fires inside the forked publish Effect, AFTER bus.publish
// completes. Consumers don't care about microsecond-level ordering, but
// we still need to prove the emit actually fires.
it.live(
"emits sync events to GlobalBus after publishing to ProjectBus",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
// Filter for OUR specific event in the handler so we ignore any
// stray sync events from other tests' lingering forks.
const received = yield* Deferred.make<GlobalEvent>()
const handler = (evt: GlobalEvent) => {
if (evt.payload?.type === "sync" && evt.payload?.syncEvent?.type === "item.created.1") {
Deferred.doneUnsafe(received, Effect.succeed(evt))
}
}
GlobalBus.on("event", handler)
try {
yield* SyncEvent.use.run(Created, { id: "evt_global_1", name: "global" })
const event = yield* awaitWithTimeout(
Deferred.await(received),
"timed out waiting for sync event on GlobalBus",
"2 seconds",
)
expect(event.payload).toMatchObject({
type: "sync",
syncEvent: { type: "item.created.1", data: { id: "evt_global_1", name: "global" } },
})
} finally {
GlobalBus.off("event", handler)
}
}),
),
)
})
describe("replay", () => {
+109 -5
View File
@@ -6,22 +6,39 @@ import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { TestInstance } from "../fixture/fixture"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { Permission } from "../../src/permission"
import type * as Tool from "../../src/tool/tool"
const it = testEffect(
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
Reference.defaultLayer,
),
)
Git.defaultLayer,
referenceLayer(flags),
)
const it = testEffect(toolLayer())
const scout = testEffect(toolLayer({ experimentalScout: true }))
const ctx = {
sessionID: SessionID.make("ses_test"),
@@ -34,6 +51,52 @@ const ctx = {
ask: () => Effect.void,
}
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),
} satisfies Tool.Context,
}
}
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
}),
)
const git = Effect.fn("GlobToolTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
return stdout.trim()
})
})
describe("tool.glob", () => {
it.instance("matches files from a directory path", () =>
Effect.gen(function* () {
@@ -78,4 +141,45 @@ describe("tool.glob", () => {
}
}),
)
scout.instance(
"does not ask for external_directory permission inside configured git references",
() =>
Effect.gen(function* () {
yield* TestInstance
const fs = yield* AppFileSystem.Service
const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo")
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "opencode-glob-reference")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n")
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add source"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const { items, next } = asks()
const info = yield* GlobTool
const glob = yield* info.init()
const result = yield* githubBase(
`file://${remoteRoot}/`,
glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next),
)
expect(result.metadata.count).toBe(1)
expect(result.output).toContain(path.join(cache, "src", "index.ts"))
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
}),
{
config: {
reference: {
docs: "opencode-glob-reference/repo",
},
},
},
)
})
+103 -5
View File
@@ -4,28 +4,44 @@ import os from "os"
import path from "path"
import { Effect, Layer } from "effect"
import { GrepTool } from "../../src/tool/grep"
import { provideInstance, TestInstance } from "../fixture/fixture"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { testEffect } from "../lib/effect"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
import { Permission } from "../../src/permission"
import type * as Tool from "../../src/tool/tool"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { Filesystem } from "@/util/filesystem"
const it = testEffect(
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
Reference.defaultLayer,
),
)
Git.defaultLayer,
referenceLayer(flags),
)
const it = testEffect(toolLayer())
const scout = testEffect(toolLayer({ experimentalScout: true }))
const ctx = {
sessionID: SessionID.make("ses_test"),
@@ -39,6 +55,39 @@ const ctx = {
}
const root = path.join(__dirname, "../..")
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
}),
)
const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
return stdout.trim()
})
})
describe("tool.grep", () => {
it.live("basic search", () =>
@@ -163,4 +212,53 @@ describe("tool.grep", () => {
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
}),
)
scout.instance(
"does not ask for external_directory permission inside configured git references",
() =>
Effect.gen(function* () {
yield* TestInstance
const appfs = yield* AppFileSystem.Service
const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo")
yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)
yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore))
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "opencode-grep-reference")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n")
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add notes"])
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const next: Tool.Context = {
...ctx,
ask: (req) =>
Effect.sync(() => {
requests.push(req)
}),
}
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* githubBase(
`file://${remoteRoot}/`,
grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next),
)
expect(result.metadata.matches).toBe(1)
expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md")))
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
}),
{
config: {
reference: {
docs: "opencode-grep-reference/repo",
},
},
},
)
})
+2 -2
View File
@@ -19,6 +19,7 @@ import { Filesystem } from "@/util/filesystem"
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -40,8 +41,7 @@ const ctx = {
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
+2 -1
View File
@@ -29,6 +29,7 @@ import { Ripgrep } from "@/file/ripgrep"
import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
import { ProviderID, ModelID } from "@/provider/schema"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
@@ -51,7 +52,7 @@ const registryLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.provide(Session.defaultLayer),
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
Layer.provide(Provider.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
Layer.provide(Reference.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
@@ -10,6 +10,7 @@ import { Global } from "@opencode-ai/core/global"
import { MessageID, SessionID } from "../../src/session/schema"
import { Truncate } from "../../src/tool/truncate"
import { RepoCloneTool } from "../../src/tool/repo_clone"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -34,6 +35,7 @@ const it = testEffect(
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Git.defaultLayer,
RepositoryCache.defaultLayer,
Truncate.defaultLayer,
),
)
@@ -223,4 +225,21 @@ describe("tool.repo_clone", () => {
}),
),
)
it.live("rejects invalid branch inputs", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const tool = yield* init()
const result = yield* tool.execute({ repository: "owner/repo", branch: "bad..branch" }, ctx).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.squash(result.cause)
expect(error instanceof Error ? error.message : String(error)).toContain(
"Branch must contain only alphanumeric characters",
)
}
}),
),
)
})
+44 -46
View File
@@ -31,14 +31,13 @@ const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
describe("tool.skill", () => {
it.live("execute returns skill content block with files", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
name: tool-skill
description: Skill for tool tests.
---
@@ -47,49 +46,48 @@ description: Skill for tool tests.
Use this skill.
`,
),
)
yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo"))
),
)
yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo"))
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: (req) =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
requests.push(req)
}),
)
}
const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")
const result = yield* tool.execute({ name: "tool-skill" }, ctx)
const file = path.resolve(skill, "scripts", "demo.txt")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: (req) =>
Effect.sync(() => {
requests.push(req)
}),
}
const result = yield* tool.execute({ name: "tool-skill" }, ctx)
const file = path.resolve(skill, "scripts", "demo.txt")
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("skill")
expect(requests[0].patterns).toContain("tool-skill")
expect(requests[0].always).toContain("tool-skill")
expect(result.metadata.dir).toBe(skill)
expect(result.output).toContain(`<skill_content name="tool-skill">`)
expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`)
expect(result.output).toContain(`<file>${file}</file>`)
}),
{ git: true },
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("skill")
expect(requests[0].patterns).toContain("tool-skill")
expect(requests[0].always).toContain("tool-skill")
expect(result.metadata.dir).toBe(skill)
expect(result.output).toContain(`<skill_content name="tool-skill">`)
expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`)
expect(result.output).toContain(`<file>${file}</file>`)
}),
),
)
})
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { Global } from "@opencode-ai/core/global"
import {
isFileRepositoryReference,
isRemoteRepositoryReference,
parseRemoteRepositoryReference,
parseRepositoryReference,
repositoryCacheIdentity,
repositoryCachePath,
sameRepositoryReference,
validateRepositoryBranch,
} from "../../src/util/repository"
describe("util.repository", () => {
test("parses github shorthand and preserves cache path", () => {
const reference = parseRemoteRepositoryReference("owner/repo")
expect(reference).toMatchObject({
host: "github.com",
path: "owner/repo",
segments: ["owner", "repo"],
owner: "owner",
repo: "repo",
label: "owner/repo",
})
expect(repositoryCachePath(reference)).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
expect(repositoryCacheIdentity(reference)).toBe("github.com/owner/repo")
})
test("parses host path and scp remote references", () => {
const hostPath = parseRemoteRepositoryReference("gitlab.com/group/repo")
const scp = parseRemoteRepositoryReference("git@github.com:owner/repo.git")
expect(hostPath).toMatchObject({
host: "gitlab.com",
path: "group/repo",
remote: "https://gitlab.com/group/repo.git",
label: "gitlab.com/group/repo",
})
expect(scp).toMatchObject({
host: "github.com",
path: "owner/repo",
remote: "git@github.com:owner/repo.git",
label: "owner/repo",
})
})
test("keeps local file repositories distinct from remote repositories", () => {
const localPath = path.resolve("repo.git")
const reference = parseRepositoryReference(pathToFileURL(localPath).href)
expect(reference).toMatchObject({
host: "file",
protocol: "file:",
label: localPath,
})
expect(reference && isFileRepositoryReference(reference)).toBe(true)
expect(reference && isRemoteRepositoryReference(reference)).toBe(false)
expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow(
"Local file repositories are not supported",
)
})
test("compares cache identity independent of input spelling", () => {
const shorthand = parseRemoteRepositoryReference("owner/repo")
const url = parseRemoteRepositoryReference("https://github.com/owner/repo.git")
const hostPath = parseRemoteRepositoryReference("github.com/owner/repo")
expect(sameRepositoryReference(shorthand, url)).toBe(true)
expect(sameRepositoryReference(shorthand, hostPath)).toBe(true)
})
test("validates repository branch names", () => {
expect(() => validateRepositoryBranch("feature/docs.v1")).not.toThrow()
expect(() => validateRepositoryBranch("-bad")).toThrow("Branch must contain only alphanumeric characters")
expect(() => validateRepositoryBranch("bad..branch")).toThrow("Branch must contain only alphanumeric characters")
expect(() => validateRepositoryBranch("bad branch")).toThrow("Branch must contain only alphanumeric characters")
})
})
+3 -3
View File
@@ -22,9 +22,9 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.2.13",
"@opentui/keymap": ">=0.2.13",
"@opentui/solid": ">=0.2.13"
"@opentui/core": ">=0.2.14",
"@opentui/keymap": ">=0.2.14",
"@opentui/solid": ">=0.2.14"
},
"peerDependenciesMeta": {
"@opentui/core": {
+1 -1
View File
@@ -935,7 +935,7 @@
gap: 6px;
margin-top: 12px;
padding: 1px 1px 8px;
flex: 1;
flex-shrink: 0;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
+36 -9
View File
@@ -58,6 +58,30 @@ import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
async function writeClipboard(text: string): Promise<boolean> {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return false
return clipboard.writeText(text).then(
() => true,
() => false,
)
}
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
let valueRef: HTMLSpanElement | undefined
@@ -1064,9 +1088,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setState("copied", true)
setTimeout(() => setState("copied", false), 2000)
if (await writeClipboard(content)) {
setState("copied", true)
setTimeout(() => setState("copied", false), 2000)
}
}
const revert = () => {
@@ -1490,9 +1515,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
@@ -1834,9 +1860,10 @@ ToolRegistry.register({
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
+127
View File
@@ -0,0 +1,127 @@
# Test Suite Speed
## Goal
Speed up the `packages/opencode` test suite without reducing coverage or hiding failures.
## Benchmark Command
Run from `packages/opencode`:
```sh
bun run bench:test
```
The full-suite benchmark defaults to one measured run. Use repeated runs only after a targeted win:
```sh
BENCH_WARMUPS=1 BENCH_RUNS=3 bun run bench:test
```
To identify slow files, run:
```sh
bun run profile:test
```
Scope it while exploring:
```sh
TEST_PROFILE_GLOB='test/server/**/*.test.ts' bun run profile:test
TEST_PROFILE_LIMIT=20 bun run profile:test
```
## Primary Metric
`METRIC test_suite_seconds=<median wall clock seconds>`
## Secondary Metrics
`test_suite_best_seconds`, `test_suite_worst_seconds`, failures, and noisy spread.
For profiling: `slowest_test_file_seconds` and the slowest file list.
## Files In Scope
`packages/opencode/test/**`, test fixtures, package test scripts, and implementation setup paths only when a benchmarked bottleneck points there.
## Signals To Watch
Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/database fixture costs, and broad test globs pulling unrelated work.
## Hypothesis Loop
| Hypothesis | Change | Before | After | Decision | Notes |
| --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| Repeated full-suite runs are too expensive for discovery | Switched full-suite benchmark to one run and added per-file profiler | ~250s/run | pending | keep | Bun has no slowest-test reporter in this version; profile files directly. |
| Plugin install concurrency test spends time spawning more workers than needed to exercise lock contention | Reduced worker counts from 12/10/8 to 6/6/5; kept `holdMs: 30` | 7.800s | 6.204s | keep | Median from 3 targeted runs; still covers concurrent cross-process writes to server, server+tui, and existing json config. |
| `httpapi-listen` PTY route tests pay for git repositories they do not assert on | Removed `git: true` from temp dirs while keeping config setup | 10.554s | 7.818s | keep | Median from 3 targeted runs; HTTP routes, tickets, websocket upgrade, restart, and no-auth paths still pass. |
| `workspace.waitForSync` timeout test waits the full production timeout | Added optional timeout parameter defaulting to production timeout; timeout test uses 25ms | 12.949s | 8.305s | keep | Median from 3 targeted runs; production callers keep the 5000ms default. |
| `config.test` waits after dependencies even though `.gitignore` is written synchronously | Removed obsolete 1000ms sleep from writable `OPENCODE_CONFIG_DIR` test | 10.270s | 9.433s | keep | Median from 5 targeted runs because one run was noisy; simpler test and no fixed sleep. |
| SDK parity helpers create git repos for tests that only need files/config/session state | Changed `withProject` default to no git; explicit git init test still opts into no-git fixture | 8.011s | 5.180s | keep | Median from 5 targeted runs because first run was cold/noisy. |
| Provider plugin filter test waits on plugin dependency readiness setup | Marked local plugin dependencies ready using the existing fixture helper | 7.543s | 6.366s | keep | Median from 3 targeted runs; matches neighboring plugin provider test setup. |
| HTTP provider tests generate local plugins without dependency-ready fixture state | Marked generated `.opencode` plugin fixtures dependency-ready | 7.905s | 2.980s | keep | Median from 3 targeted runs; avoids unrelated plugin dependency setup in route tests. |
| TUI plugin lifecycle timeout coverage waits the full production cleanup timeout | Added optional runtime dispose timeout override and used 25ms in the timeout test | 7.330s | 1.507s | keep | Median from 3 targeted runs; production default remains 5000ms. |
| Skill tool test initializes git even though it only reads local skill files | Removed `git: true` from the temporary directory fixture | 2.320s | 1.425s | keep | Single targeted rerun; still exercises skill discovery, permission request, and bundled file output. |
| Prompt shell semantics tests initialize git though they only assert shell/session behavior | Removed `git: true` from shell-focused prompt fixtures while preserving config setup | 26.930s | 23.400s | keep | Three targeted reruns passed after the change: 23.80s, 23.55s, 23.40s. |
| Remaining prompt behavior tests mostly do not require repository state | Removed git setup from safe loop/reference/error fixtures; restored shell queue/cancel cases | 23.400s | 19.610s | keep | Safety review found shell runner readiness depends on git-backed setup in several tests; current single rerun passes. |
| Session processor effect tests do not require repository state | Removed git setup from all processor-effect temp server fixtures | 12.500s | 9.230s | keep | Two targeted reruns passed after the change: 9.61s, 9.23s. |
| HTTP listen PTY ticket tests restart the same listener topology twice | Folded directory-scoped ticket regression into the broader unsafe-ticket test | 7.051s | 6.170s | keep | Two targeted reruns passed after the change: 6.76s, 6.17s; still covers mint failure and successful same-directory upgrade. |
| File watcher readiness can write before async native subscriptions are active | Retried short readiness writes and accepted symlink-realpath HEAD events | failed | 4.62s | keep | Three sequential focused watcher runs passed: 4.62s, 4.57s, 4.64s; full suite no longer failed in `watcher.test.ts`. |
| First provider config/env/filtering block can use Effect-aware instance fixtures | Migrated six `tmpdir` + `withTestInstance` cases to `it.instance` | 6.06s | 6.07s | keep | Neutral timing, but removes manual config file writes and instance plumbing; use as the pattern for later provider slices. |
| Custom provider/model config cases can use Effect-aware instance fixtures | Migrated three more config-heavy provider cases to `it.instance` | 6.07s | 6.12s | keep | Neutral timing within noise, but continues removing manual config file writes on top of the first provider fixture PR. |
## Profiling Results
Command shape:
```sh
TEST_PROFILE_GLOB='test/<area>/**/*.test.ts' TEST_PROFILE_TOP=15 bun run profile:test
```
Initial slowest files observed during discovery:
| File | Seconds | Scope |
| ----------------------------------------- | ------: | ------------- |
| `test/config/config.test.ts` | 23.546 | config |
| `test/provider/provider.test.ts` | 18.747 | provider |
| `test/control-plane/workspace.test.ts` | 16.447 | control-plane |
| `test/plugin/install-concurrency.test.ts` | 14.804 | plugin |
| `test/server/httpapi-cors.test.ts` | 14.620 | server |
| `test/server/httpapi-listen.test.ts` | 10.073 | server |
| `test/server/httpapi-sdk.test.ts` | 8.661 | server |
| `test/server/httpapi-provider.test.ts` | 7.905 | server |
| `test/cli/tui/plugin-lifecycle.test.ts` | 7.330 | cli/tui |
| `test/file/index.test.ts` | 7.214 | file |
This table is historical profiling input, not the current ranking after kept changes.
Targeted 3-run baselines:
| File | Runs | Median | Notes |
| ----------------------------------------- | ---------------------- | -----: | ---------------------------------------------------------------------------- |
| `test/control-plane/workspace.test.ts` | 12.949, 12.949, 12.773 | 12.949 | Stable slow target. |
| `test/server/httpapi-listen.test.ts` | 10.554, 10.631, 10.479 | 10.554 | Stable slow target; WebSocket/listener lifecycle. |
| `test/config/config.test.ts` | 10.270, 9.042, 10.737 | 10.270 | Large serial file; initial 23s was mixed-scope contention/noise. |
| `test/server/httpapi-sdk.test.ts` | 7.600, 8.011, 8.035 | 8.011 | Stable slow target. |
| `test/plugin/install-concurrency.test.ts` | 7.949, 7.800, 7.712 | 7.800 | Stable slow target; many subprocesses. |
| `test/provider/provider.test.ts` | 8.323, 7.543, 7.474 | 7.543 | Large serial file. |
| `test/server/httpapi-cors.test.ts` | 2.621, 1.682, 1.518 | 1.682 | Not a standalone top target; initial 14s was mixed-scope noise/order effect. |
Full-suite sanity checks:
| Command | Result | Notes |
| -------------------- | -------: | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `bun run bench:test` | 225.069s | Before continuing prompt/session work. |
| `bun run bench:test` | 186.729s | After prompt, processor, and PTY wins before safety review restores. |
| `bun run bench:test` | 202.317s | After restoring prompt shell coverage and SDK VCS parity coverage. |
| `bun run bench:test` | failed | Watcher blocker cleared; current run later failed in focused-passing `tool/skill.test.ts` and prompt shell timeout cases under full-suite load. |
## Dead Ends
| Hypothesis | Change Tried | Before | After | Decision | Notes |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -----: | -----: | -------- | --------------------------------------------------------------------------------------------- |
| `file/index.test.ts` pays unnecessary per-test global instance cleanup | Removed `afterEach(disposeAllInstances)` while keeping the explicit disposal test import | 5.262s | 5.089s | discard | Improvement was within noise and the cleanup is a safety guard for many instance-state tests. |
| Socket reset retry test can shorten its idle-timeout path | Reduced Bun server idle timeout and tried forced server close | 16.46s | failed | discard | Shorter idle timeout changed the error shape; forced close hung. Keep the real socket reset. |
| `tool/webfetch` can avoid per-test instance setup | Switched local HTTP tests from `it.instance` to `it.live` | 1.219s | failed | discard | Tool execution reads instance-local agent state, so the temp instance is required. |
| LSP client interop tests can shorten coarse request-handling sleeps | Reduced fixed post-notification waits from 100ms to 10ms | 4.270s | 4.740s | discard | First run improved to 3.870s but verification was slower than baseline; not a clear win. |
+72 -5
View File
@@ -34,6 +34,7 @@ if (snapshotArg === "--snapshot=") {
const ver = raw.replace(/^v/, "")
const root = path.resolve(import.meta.dir, "..")
const lockfile = path.join(root, "bun.lock")
const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"])
const keys = ["@opentui/core", "@opentui/keymap", "@opentui/solid"] as const
@@ -115,11 +116,77 @@ const out = (
).filter((item): item is string => item !== null)
if (out.length === 0) {
console.log("No opentui deps found")
process.exit(0)
console.log(`No opentui manifest updates needed for ${ver}`)
}
console.log(`Updated opentui${snapshot ? " snapshot" : ""} to ${ver} in:`)
for (const file of out) {
console.log(`- ${file}`)
if (out.length > 0) {
console.log(`Updated opentui${snapshot ? " snapshot" : ""} to ${ver} in:`)
for (const file of out) {
console.log(`- ${file}`)
}
}
console.log("Running bun install to update bun.lock...")
const install = Bun.spawn([process.execPath, "install"], {
cwd: root,
stdout: "inherit",
stderr: "inherit",
})
const installCode = await install.exited
if (installCode !== 0) process.exit(installCode)
const fixed = await fixKnownLockfileIssues()
if (fixed.length > 0) {
console.log("Removed stale opentui-spinner peer lockfile entries:")
for (const item of fixed) {
console.log(`- ${item}`)
}
}
const stale = await findStaleLockfileEntries()
if (stale.length > 0) {
console.error(`bun.lock still contains stale opentui versions after upgrading to ${ver}:`)
for (const item of stale) {
console.error(`- ${item.entry}: ${item.pkg}@${item.version}`)
}
process.exit(1)
}
console.log("bun.lock opentui versions are consistent")
async function fixKnownLockfileIssues() {
const txt = await Bun.file(lockfile).text()
const stale = findStaleLockfileEntriesInText(txt)
if (stale.length === 0) return []
if (stale.some((item) => !item.entry.startsWith("opentui-spinner/@opentui/"))) return []
const removed = txt
.split("\n")
.map((line) => line.match(/^ "(opentui-spinner\/@opentui\/[^\"]+)": /)?.[1])
.filter((item): item is string => item !== undefined)
if (removed.length === 0) return []
await Bun.write(
lockfile,
txt
.split("\n")
.filter((line) => !line.match(/^ "opentui-spinner\/@opentui\//))
.join("\n"),
)
return removed
}
async function findStaleLockfileEntries() {
return findStaleLockfileEntriesInText(await Bun.file(lockfile).text())
}
function findStaleLockfileEntriesInText(txt: string) {
return Array.from(txt.matchAll(/^ "([^"]+)": \["(@opentui\/(?:core(?:-[^@"]+)?|keymap|solid))@([^"]+)"/gm))
.map((match) => ({
entry: match[1]!,
pkg: match[2]!,
version: match[3]!,
}))
.filter((item) => item.version !== ver)
}