Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2ecd1ed3 | |||
| 159d271e1e | |||
| 762850dfe5 | |||
| b039702e8c | |||
| 094886c848 | |||
| dc0297f4ac | |||
| 3ab67f3280 | |||
| a2e6bd503b | |||
| 05ac345696 | |||
| e3feca09b3 | |||
| 896ad7b884 | |||
| 0a945219a9 | |||
| 94828eb44b | |||
| 96192495ae | |||
| f7b5576bcc | |||
| cb35493242 | |||
| 5bfd7fd16c | |||
| 2932b41e64 | |||
| eb389c58eb | |||
| 54ff0a669b | |||
| e56999fd36 | |||
| 1124315267 | |||
| 2bf3f3041f | |||
| 6e4db5666a | |||
| 564cde393e | |||
| c813927bb6 | |||
| 5970c12d90 | |||
| 116a4e33ba | |||
| 611e48c4ac | |||
| 836a33198e | |||
| 7afd477d1a | |||
| fe143df151 | |||
| e94aecaa08 |
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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. |
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user